Skip to content

Commit 429cd2f

Browse files
committed
fix: 修复计费周期清零、思考能力误关与额度显示取错数据源
本次修复覆盖代码审查中发现的全部 23 项缺陷。原则是「最小改动、不过度修复」: 能在桥接层/统计层根治的不在前端打补丁,能在上游语义层确认的不猜测网关行为。 - stats: 周期边界接受规则改为「先本地推进 → 拒绝非前进的陈旧报告 → 前进 幅度在 7 天内视为同周期 → 超过才清零」。原先按派生起点精确比较,边界 抖动 1 小时就抹掉本周期累计;且 /user/status 有 1 小时缓存,陈旧报告会 把已经滚进新周期的累计一起清零。首次观测只采纳边界、不清零,否则新装 会丢弃同步前累计的 credits。 - stats: addMonthsMs 把日夹取到目标月长度(3/31 减一月得 2/28,闰年得 2/29)。原先 AddDate 规范化月溢出(3/31 → 3/3),31 号重置的账号每月 都被误判为新周期并清零。 - stats: Report() 改为纯投影,不再推进边界、不再置 dirty。原先读路径带写 副作用,面板 15 秒轮询在跨周期瞬间就会清零并持久化本周期累计。 - stats: 滚动循环加「不推进即丢弃边界」与 2400 次循环上限。原先 addMonthsMs 对少数历史时区(如 America/Asuncion 1972-09)非严格递增, 循环在持锁下死循环会挂死整个进程。 - stats: 新增 ClearAccount()。原先 SetAccount(nil) 是 no-op,没有任何入口 能清除已持久化的账号快照。 - models: 能力判定改为「thinking_config.enabled 存在且非 null → 顶层 is_reasoning → 默认 true」,两个兜底方向统一为 true。原先只信顶层 flag,导致 dfmodel / kmodel_latest 的思考能力被静默关闭;且字段缺失时 为 false、整个目录不可达时为 true,同一部署内方向矛盾。 - models: 档位元数据改读真实的嵌套路径 thinking_config.disabled / thinking_config.enabled.efforts,保留扁平旧格式兼容。原先读的扁平 efforts / supports_disabled 在真实目录里不存在,档位校验形同虚设,任意 字符串(含畸形值)都会被原样转发给上游。 - models: 新增 MaxRequestedOutputTokens = 1_000_000,调用方传入的 max_tokens 超出即钳制。原先 1e18 之类异常值会原样写入网关请求。 - models: normalizeEfforts 的 map 分支先排序,输出确定化。原先迭代顺序 随机,结果不可复现。 - bridge: 计费周期改以 /user/status 的 nextResetAt 为准,OpenAPI 的 expiresAt 仅在缺失时兜底且必须小于 2100-01-01。无期限套餐会把 expiresAt 填成 9999-12-31 哨兵值,原先它会覆盖真实边界,导致本地周期 滚动永久失效、面板显示「剩 2912178 天」。统计层加同样的守卫,使已落盘 的哨兵值不会永久拒绝后续真实上报。 - bridge: 元数据不可用时不再放行 reasoning_effort=none。未声明 thinking_config.disabled 的模型(gmodel / gfmodel / kmodel / kmodel_latest)会以 HTTP 400 拒绝整个请求。 - bridge: resolveAccountStatus 用 5 秒派生超时包住 /user/status 查询。该 调用位于 refreshMu 临界区内,原先失败会串行阻塞所有等待续期的请求。 - bridge: 新增 errNoUserID 哨兵,首次 jobToken 交换不带 user id 属预期 降级路径,原先同样打 WARN。 - bridge: fetchAccountStatus 缓存命中分支改为返回克隆。原先直接交出内部 指针,调用方可绕过 accountMu 改写缓存。 - bridge: resolveMaxTokens 的钳制在 float64 域完成。原先依赖 int,32 位 构建下 int(1e18) 会回绕并被判为非法,静默回退目录缺省值。 - bridge: parallel_tool_calls 保持原有顶层落点,注释改为陈述事实(网关 是否读取尚未验证),并补测试锁定,搬移或删除都必须是显式变更。 - admin: 「合计剩余」只累加 available 的池子。原先不可用池子仍全额计入, 同一张卡上逐行标「不可用」、汇总却把它算进可用合计。 - admin: 额度池 total=0 视为不存在,首行不再固定取 userQuota。免费账号 的套餐池 total=0、真实额度在 addOnQuota,原先首行渲染成「套餐 8 / 0 · 剩 0」加红色超额徽标。 - admin: 移除恒真的 total >= 0 死分支。 - auth: 上游响应日志降为 status + duration,去掉 headers 与 body;非 200 路径的错误体先按 key 脱敏、再按 rune 边界截断,AuthError.Error() 内部 同样脱敏(Detail 保持原始供分类使用)。原先每分钟一次打印完整响应体 (含 userId、trace id、配额明细),约 2 MB/天。 - transform: Usage.Billable 加 json:"-",仅内部传给 stats.Usage.NonBillable。原先它会随 usage chunk 序列化给 OpenAI 客户端, billable=false 会被误读为「这次没算钱」。 - main: 额度刷新 goroutine 纳入优雅关闭(独立的 accountStop / accountDone,先 join 再触发最终 flush)。原先它的写回会落在 flush 之后 并静默丢失。 - main: shutdown 的 drain 与 join 并发启动,但等待 drain 完成后才取消 context。原先 join 一完成即 cancel,SIGTERM 时会中断所有在途流式请求。 - main: 停止流程用 sync.Once 保证幂等,首个信号后注销 handler,重复触发 不再 panic。 - main: PAT 变更(含清空)后清除账号快照;写入与清除共享 patWriteMu 并 在锁内复核 PAT,关闭「在途刷新写回已被清除的旧账号」的时序窗口;启动 时清一次持久化快照以覆盖重启场景。 - README: 说明 parameters.max_tokens 总是发送属本次的行为变更、档位实效 (除 none 外差异落在采样噪声内)、周期边界接受规则,以及「合计剩余」 只统计可用池子的口径。 验证:go build ./... / go vet ./... / go test ./... -count=2 全绿; GOARCH=386 与 5 个时区(UTC、Asia/Shanghai、America/New_York、 Pacific/Chatham、Australia/Lord_Howe)全绿;改动文件去 CRLF 后 gofmt 无差异。关键回归测试均经变异验证:回退修复即失败。
1 parent 4272ed6 commit 429cd2f

15 files changed

Lines changed: 2450 additions & 113 deletions

File tree

‎README.md‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ curl http://localhost:10081/v1/models \
153153

154154
### 5. 控制思考强度(`reasoning_effort`)
155155

156-
请求体支持 OpenAI 风格的 `reasoning_effort`。有效档位写入网关请求的 `parameters` 对象(与官方客户端一致),**无需**额外的 device token、Bearer token 或模型服务域名配置;未携带该字段的请求保持原有行为。
156+
请求体支持 OpenAI 风格的 `reasoning_effort`。有效档位写入网关请求的 `parameters` 对象(与官方客户端一致),**无需**额外的 device token、Bearer token 或模型服务域名配置;未携带 `reasoning_effort` 的请求不改变思考档位,保持模型默认强度(但请求体仍会带上 `parameters.max_tokens`,见下文「`is_reasoning` 与 `max_tokens` 的来源」)。
157157

158158
```json
159159
{
@@ -179,12 +179,16 @@ curl http://localhost:10081/v1/models \
179179

180180
其余档位不会写入 `max_thinking_tokens`:官方客户端仅在技能 / 子代理覆盖场景才把档位换算为思考预算,主交互路径只发送档位字符串。
181181

182+
> 档位之间的区别在上游是否可观测仍存疑(见下方「档位实效说明」):`none` 是唯一实测稳定生效的档位,其余档位不应被当作可靠的强度旋钮。
183+
182184
#### `is_reasoning` 与 `max_tokens` 的来源
183185

184186
两项能力信息来自网关模型目录,与官方客户端的解析方式一致:
185187

186-
- `is_reasoning`:取目录的 `is_reasoning` 字段,缺失时为 `false`。动态目录不可用时回退为 `true`,以保持桥接层原有的"默认开启思考"行为,避免静默关闭所有模型的思考能力。
187-
- `max_tokens`:取目录的 `max_output_tokens`,缺失或非法(非正整数)时回退为 `32000`,与官方客户端的 `LS()` 归一化结果相同。
188+
- `is_reasoning`:优先看目录条目是否存在 `thinking_config.enabled`(存在且非 null 即视为"可思考"——这是目录真正声明可思考能力的位置;`dfmodel` / `kmodel_latest` 的顶层 `is_reasoning=false` 与嵌套块矛盾,故以嵌套块为准),其次才看顶层 `is_reasoning`;两者都缺失时回退为 `true`。动态目录整体不可用时同样回退为 `true`(保持原有"默认开启思考")。
189+
- `max_tokens`:调用方传入的 `max_tokens` / `max_completion_tokens` 优先,但被钳制在 1,000,000 上限(避免 `1e18` 之类异常值直达上游);未传或非法时才取目录的 `max_output_tokens`,目录缺失时为 `32000`。该值总是写入网关请求的 `parameters.max_tokens`,这是本 PR 引入的行为变更:此前版本的请求体不含该字段,客户端传入的 `max_tokens` 会被静默丢弃。
190+
191+
> **档位实效说明**:实测显示,除 `none`(且仅当目录声明了 `thinking_config.disabled` 时)外,各档位字符串对 `reasoning_tokens` 的影响落在采样噪声内。档位仍会经目录校验后透传,但不应据此期待精细的思考强度控制;下面的 pi `thinkingLevelMap` 示例只用于显式表达调用方意图。
188192
189193
**pi 客户端配置示例**:Qoder 上游不接受 OpenAI 的 `developer` 角色,而 pi 会用该角色承载代理指令;因此必须在 `~/.pi/agent/models.json` 的 `qoder-local` provider 上设置 `supportsDeveloperRole: false`,使 pi 改用 `system` 角色。为模型设置 `reasoning: true`,并将 pi 的档位映射到模型目录实际支持的值:
190194

@@ -246,6 +250,8 @@ curl http://localhost:10081/v1/models \
246250
合计剩余 4,941 · 09-25 重置
247251
```
248252

253+
> **合计口径**:`合计剩余` 只累加可消费的额度——标记为「不可用」的池子(如组织资源包的 `available=false`)虽仍会单独成行展示,但不计入合计,否则汇总数字会与紧上方那一行的「不可用」自相矛盾。
254+
249255
> 免费账号的套餐池为 `userQuota.total = 0`(套餐本身不含额度),此时面板首行直接展示真正持有额度的池子(通常是加购额度),而不是渲染成「套餐 0 / 0 · 已用尽」。`total`/`cap` 为 0 的池子视为不存在,不会出现在额度明细卡中。
250256
251257
### 数据来源
@@ -268,7 +274,8 @@ curl http://localhost:10081/v1/models \
268274
- **官方额度优先**:面板的套餐用量来自账号级 OpenAPI,包含 IDE、CLI 和本服务产生的全部消耗,也能覆盖服务启动前的当期用量。
269275
- **额度来源分别展示**:套餐额度、加购额度和组织资源包在额度明细卡中分别成行展示,并汇总各额度来源返回的剩余 Credits;组织资源包的“可用/不可用”状态以 OpenAPI 返回值为准。
270276
- **本地累计仅作降级**:usage 帧中 `billable=false` 的请求仍计入 token,但不计入本地 credits;OpenAPI 从未成功时,面板才显示本服务观察到的周期消耗,并明确标注口径。
271-
- **周期边界权威同步**:重置时刻以网关 `/user/status` 的 `nextResetAt` 为权威,OpenAPI `expiresAt` 仅在其缺失时兜底;且 `expiresAt` 必须小于 `2100-01-01` 才会被采纳——无期限套餐会把它填成 `9999-12-31` 哨兵值,该值会被忽略。本地周期起点按自然月回退推导,断网期间仍可完成一次性滚动。
277+
- **周期边界权威同步**:重置时刻以网关 `/user/status` 的 `nextResetAt` 为权威,OpenAPI `expiresAt` 仅在其缺失时兜底;且 `expiresAt` 必须小于 `2100-01-01` 才会被采纳——无期限套餐会把它填成 `9999-12-31` 哨兵值,该值会被忽略。本地周期起点按自然月回退推导(月末会夹取到目标月最后一天,3/31 → 2/28),断网期间仍可完成一次性滚动。
278+
- **边界变更的接受规则**(防止累计值被抖动或陈旧缓存误清):首次拿到边界时**保留**已累计的 credits;边界**未向前推进**(`/user/status` 缓存 1 小时,可能上报已滚过的旧值)则直接忽略;前进幅度小于 7 天时视为同一周期,不清零;达到 7 天以上才判定为真实续期并清零上一周期。
272279
- **累计值仍保留**:`stats.credits` 保存本服务历史累计扣费,鼠标悬停在额度行可查看。
273280

274281
### 刷新时机

‎admin/admin_test.go‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,55 @@ func TestQuotaSourceSelectionIgnoresEmptyPools(t *testing.T) {
337337
}
338338
}
339339

340+
// quotaDetailsSource returns the renderQuotaDetails body from the embedded UI,
341+
// delimited by the next top-level function.
342+
func quotaDetailsSource(t *testing.T) string {
343+
t.Helper()
344+
start := strings.Index(indexHTML, "function renderQuotaDetails(d) {")
345+
if start < 0 {
346+
t.Fatal("renderQuotaDetails not found in the embedded UI")
347+
}
348+
end := strings.Index(indexHTML[start:], "function makeBadge(")
349+
if end < 0 {
350+
t.Fatal("could not delimit renderQuotaDetails")
351+
}
352+
return indexHTML[start : start+end]
353+
}
354+
355+
// The 合计剩余 summary must not absorb credits the account cannot spend: an
356+
// unavailable org_resource_package still gets a row marked 不可用, so counting
357+
// its remaining credits made the summary contradict the row right above it.
358+
func TestQuotaSummaryOnlyCountsAvailablePools(t *testing.T) {
359+
details := quotaDetailsSource(t)
360+
361+
guard := "if (pool.available) totalRemaining += remaining;"
362+
if !strings.Contains(details, guard) {
363+
t.Errorf("the summary must accumulate only available pools; missing %q", guard)
364+
}
365+
// Any other accumulation must carry the same availability guard.
366+
for _, line := range strings.Split(details, "\n") {
367+
if strings.Contains(line, "totalRemaining += remaining") && !strings.Contains(line, "pool.available") {
368+
t.Errorf("totalRemaining accumulated without the availability guard: %q", strings.TrimSpace(line))
369+
}
370+
}
371+
}
372+
373+
// fmtCredits never returns a negative string and total comes from
374+
// Number(pool.quota[...] || 0), so the '—' branch of the total ternary was
375+
// unreachable. The cell must render fmtCredits(total) directly.
376+
func TestQuotaDetailsHasNoDeadTotalBranch(t *testing.T) {
377+
details := quotaDetailsSource(t)
378+
379+
for _, dead := range []string{"total >= 0 ?", "? fmtCredits(total) : '—'"} {
380+
if strings.Contains(details, dead) {
381+
t.Errorf("dead total branch %q must be removed from renderQuotaDetails", dead)
382+
}
383+
}
384+
if !strings.Contains(details, "' / ' + fmtCredits(total) + '</td>'") {
385+
t.Error("renderQuotaDetails must render the total via fmtCredits(total)")
386+
}
387+
}
388+
340389
// Repeated wrong passwords trigger per-IP rate limiting (HTTP 429).
341390
func TestLoginRateLimit(t *testing.T) {
342391
a, _ := newAdmin(t)

‎admin/webui.go‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1345,12 +1345,15 @@ function renderQuotaDetails(d) {
13451345
var ratio = total > 0 ? Math.min(Math.max(used / total, 0), 1) : 0;
13461346
var statusText = pool.available ? (remaining > 0 ? '可用' : '已用尽') : '不可用';
13471347
var statusKind = pool.available && remaining > 0 ? 'ok' : (pool.available ? 'bad' : 'neutral');
1348-
totalRemaining += remaining;
1348+
// Only spendable credits count towards 合计剩余: an unavailable pool still
1349+
// gets a row marked 不可用, but adding its remaining credits to the summary
1350+
// would contradict that very row.
1351+
if (pool.available) totalRemaining += remaining;
13491352
13501353
var tr = document.createElement('tr');
13511354
tr.innerHTML =
13521355
'<td>' + pool.name + '</td>' +
1353-
'<td class="num">' + fmtCredits(used) + ' / ' + (total >= 0 ? fmtCredits(total) : '—') + '</td>' +
1356+
'<td class="num">' + fmtCredits(used) + ' / ' + fmtCredits(total) + '</td>' +
13541357
'<td class="num">' + fmtCredits(remaining) + '</td>' +
13551358
'<td><div class="rate-cell"><div class="rate-bar"><div class="rate-fill" style="width:' + (ratio * 100) + '%"></div></div>' +
13561359
'<span class="rate-text num">' + Math.round(ratio * 100) + '%</span></div></td>' +

‎auth/auth.go‎

Lines changed: 139 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"sync"
2727
"sync/atomic"
2828
"time"
29+
"unicode/utf8"
2930

3031
"github.com/google/uuid"
3132
)
@@ -343,7 +344,11 @@ type AuthError struct {
343344
func (e *AuthError) Error() string {
344345
s := fmt.Sprintf("HTTP %d", e.StatusCode)
345346
if e.Detail != "" {
346-
s += " " + e.Detail
347+
// Detail carries the raw upstream body, which includes userId and trace
348+
// ids. Error() is what the bridge logs, so redact it here: doing it at
349+
// every construction site instead would be easy to miss, and Detail
350+
// itself is kept raw for callers that classify the failure.
351+
s += " " + truncateErrorBody([]byte(e.Detail))
347352
}
348353
return s
349354
}
@@ -408,6 +413,130 @@ func truncateBusyMessage(s string) string {
408413
return string(r[:maxBusyMessageRunes]) + "…"
409414
}
410415

416+
// maxErrorBodyChars caps how much of an upstream error body is embedded in an
417+
// error message. The account loop logs these at WARN, so the payload must not
418+
// reach the log verbatim: the quota response carries userId and trace ids.
419+
const maxErrorBodyChars = 200
420+
421+
// redactedErrorKeys are JSON keys whose values are stripped from an upstream
422+
// error body before it is embedded in an error message. Truncation alone is not
423+
// enough: these fields sit near the start of the real payload.
424+
var redactedErrorKeys = []string{"userId", "userType", "traceId", "traceID", "requestId"}
425+
426+
// truncateErrorBody returns a short, single-line, identity-free prefix of an
427+
// upstream error body for embedding in an error message. Identity-valued JSON
428+
// keys are replaced before the length cap, so a short-but-sensitive payload does
429+
// not slip through as-is.
430+
func truncateErrorBody(body []byte) string {
431+
s := strings.TrimSpace(string(body))
432+
for _, key := range redactedErrorKeys {
433+
s = redactJSONValue(s, key)
434+
}
435+
s = strings.ReplaceAll(s, "\n", " ")
436+
s = strings.ReplaceAll(s, "\r", " ")
437+
if len(s) <= maxErrorBodyChars {
438+
return s
439+
}
440+
// Cut on a rune boundary: a byte-wise slice through a multi-byte character
441+
// yields an invalid string, which then reaches the log garbage-escaped.
442+
cut := maxErrorBodyChars
443+
for cut > 0 && !utf8.RuneStart(s[cut]) {
444+
cut--
445+
}
446+
return s[:cut] + "…"
447+
}
448+
449+
// redactJSONValue replaces the value of a JSON field with "[REDACTED]". It works
450+
// on the raw text (the body may not even be valid JSON), which is exactly the
451+
// shape an upstream error page takes. A missing field is a no-op. The scan
452+
// resumes after the replacement so a value is never rescanned.
453+
//
454+
// Whitespace around the colon is accepted because upstreams pretty-print
455+
// (`"userId" : "..."`), and a missing closing quote is handled because a
456+
// truncated response body is a realistic case: matching only the compact form
457+
// would leak the identity value in both.
458+
func redactJSONValue(s, key string) string {
459+
needle := `"` + key + `"`
460+
var out strings.Builder
461+
for {
462+
keyStart := strings.Index(s, needle)
463+
if keyStart < 0 {
464+
break
465+
}
466+
valueStart, ok := jsonValueStart(s, keyStart+len(needle))
467+
if !ok {
468+
// The key is not a field name here (e.g. it appears inside a string
469+
// value). Emit it unchanged and keep scanning past it.
470+
out.WriteString(s[:keyStart+len(needle)])
471+
s = s[keyStart+len(needle):]
472+
continue
473+
}
474+
out.WriteString(s[:valueStart])
475+
if s[valueStart] == '"' {
476+
out.WriteString(`"[REDACTED]"`)
477+
} else {
478+
out.WriteString("[REDACTED]")
479+
}
480+
s = s[jsonValueEnd(s, valueStart):]
481+
}
482+
out.WriteString(s)
483+
return out.String()
484+
}
485+
486+
// jsonValueStart returns the index of the value's first byte, delimiter
487+
// included: the opening quote for a string, the first character for a scalar.
488+
// Everything before it (the key, whitespace, the colon) is copied verbatim. It
489+
// reports false when the text after the key is not a `: <value>` pair.
490+
func jsonValueStart(s string, from int) (int, bool) {
491+
i := from
492+
for i < len(s) && isJSONSpace(s[i]) {
493+
i++
494+
}
495+
if i >= len(s) || s[i] != ':' {
496+
return 0, false
497+
}
498+
i++
499+
for i < len(s) && isJSONSpace(s[i]) {
500+
i++
501+
}
502+
if i >= len(s) {
503+
return 0, false
504+
}
505+
return i, true
506+
}
507+
508+
// jsonValueEnd returns the index of the first byte PAST the value starting at
509+
// start. For a string that is one past its closing quote; for a scalar it is the
510+
// next JSON delimiter. A string whose closing quote is missing (a truncated
511+
// response) is treated as running to the end of the text so its content cannot
512+
// leak.
513+
func jsonValueEnd(s string, start int) int {
514+
if s[start] == '"' {
515+
for i := start + 1; i < len(s); i++ {
516+
if s[i] == '\\' {
517+
i++ // skip the escaped byte
518+
continue
519+
}
520+
if s[i] == '"' {
521+
return i + 1
522+
}
523+
}
524+
return len(s) // unterminated value: redact through the end
525+
}
526+
for i := start; i < len(s); i++ {
527+
switch s[i] {
528+
case ',', '}', ']', ' ', '\t', '\n', '\r':
529+
return i
530+
}
531+
}
532+
return len(s)
533+
}
534+
535+
// isJSONSpace reports whether b is insignificant whitespace between JSON tokens.
536+
func isJSONSpace(b byte) bool {
537+
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
538+
}
539+
411540
// busyCodeOf normalizes a JSON "code" field that may arrive as a string
412541
// ("10605") or a number (10605).
413542
func busyCodeOf(raw json.RawMessage) string {
@@ -498,7 +627,7 @@ func postEncoded(ctx context.Context, urlStr string, obj interface{}, machineID,
498627
}
499628
return nil, &AuthError{StatusCode: resp.StatusCode, Detail: string(detail)}
500629
}
501-
return nil, fmt.Errorf("HTTP %d at %s body=%s", resp.StatusCode, urlStr, string(detail))
630+
return nil, fmt.Errorf("HTTP %d at %s body=%s", resp.StatusCode, urlStr, truncateErrorBody(detail))
502631
}
503632

504633
var result map[string]interface{}
@@ -695,10 +824,13 @@ func FetchQuotaUsage(ctx context.Context, bearer string, region *RegionConfig) (
695824
if err != nil {
696825
return nil, err
697826
}
698-
log.Printf("[auth] OpenAPI response: method=GET url=%s status=%d duration=%s headers=%v body=%s",
699-
urlStr, resp.StatusCode, time.Since(started), resp.Header, body)
827+
log.Printf("[auth] OpenAPI response: method=GET url=%s status=%d duration=%s",
828+
urlStr, resp.StatusCode, time.Since(started))
700829
if resp.StatusCode != http.StatusOK {
701-
return nil, fmt.Errorf("quota usage returned HTTP %d: %s", resp.StatusCode, body)
830+
// The body is redacted and truncated before it reaches the error: the
831+
// account loop logs this at WARN, and the quota payload carries the
832+
// account's userId and trace ids. The status alone identifies the failure.
833+
return nil, fmt.Errorf("quota usage returned HTTP %d: %s", resp.StatusCode, truncateErrorBody(body))
702834
}
703835
var usage QuotaUsage
704836
if err := json.Unmarshal(body, &usage); err != nil {
@@ -841,7 +973,7 @@ func call(ctx context.Context, sess *SessionContext, method, fullURL string, jso
841973
}
842974
return nil, &AuthError{StatusCode: resp.StatusCode, Detail: string(detail)}
843975
}
844-
return nil, fmt.Errorf("HTTP %d body=%s", resp.StatusCode, string(detail))
976+
return nil, fmt.Errorf("HTTP %d body=%s", resp.StatusCode, truncateErrorBody(detail))
845977
}
846978

847979
var result map[string]interface{}
@@ -1084,7 +1216,7 @@ func OpenStreamLines(ctx context.Context, sess *SessionContext, fullURL string,
10841216
}
10851217
return &AuthError{StatusCode: resp.StatusCode, Detail: string(errBody)}
10861218
}
1087-
return fmt.Errorf("HTTP %d %s", resp.StatusCode, string(errBody))
1219+
return fmt.Errorf("HTTP %d %s", resp.StatusCode, truncateErrorBody(errBody))
10881220
}
10891221

10901222
// Cut off silent streams: long generations are fine (lines keep

0 commit comments

Comments
 (0)