mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-02 16:51:13 +08:00
fix(api): suppress consecutive identical retry errors in chat (#79)
withRetry yielded a SystemAPIErrorMessage on every retry as long as the error was an APIError. A flaky upstream that recovered after 1-2 retries painted the chat with a wall of identical error bubbles — exactly the "100% display" behaviour the user complained about. Add a per-call same-error counter: - First sighting of a new error key (Error.name + APIError.status + message): yield immediately. New errors carry new info. - Subsequent identical errors: silent until the count hits the threshold (default 3, env CLAUDE_CODE_RETRY_REPORT_AFTER). - 429 rate-limit errors: always yield. The wait-time embedded in the SystemAPIErrorMessage is the whole point of surfacing 429s. Effect: a 3-retry streak of identical errors now produces 2 chat bubbles (first + threshold) instead of 3 — and longer streaks compress much more aggressively while still letting the user know the call has not silently disappeared. State is local to each withRetry generator (closure variables, not module-level) so concurrent or sequential calls don't share counts. The persistent-mode heartbeat decision is computed once per outer attempt and cached, so long persistent waits don't re-increment the counter on each heartbeat tick. Tests: 3 new cases covering suppression after 3 identical errors, immediate yield on a different error key, and the env override raising the threshold. All 4 passing (1 pre-existing). Verification subagent independently PASSED including state-isolation and heartbeat-amplification adversarial probes. Co-authored-by: 你的姓名 <you@example.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
e1a8a8ff56
commit
d9b16a86c3
@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { afterEach, describe, expect, test } from 'bun:test'
|
||||||
import type Anthropic from '@anthropic-ai/sdk'
|
import type Anthropic from '@anthropic-ai/sdk'
|
||||||
import { APIConnectionError } from '@anthropic-ai/sdk'
|
import { APIConnectionError, APIError } from '@anthropic-ai/sdk'
|
||||||
import { _resetKeepAliveForTesting, getProxyFetchOptions } from '../../utils/proxy.js'
|
import { _resetKeepAliveForTesting, getProxyFetchOptions } from '../../utils/proxy.js'
|
||||||
import { withRetry } from './withRetry.js'
|
import { withRetry } from './withRetry.js'
|
||||||
|
|
||||||
@ -47,3 +47,109 @@ describe('withRetry stale connections', () => {
|
|||||||
_resetKeepAliveForTesting()
|
_resetKeepAliveForTesting()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Same-error suppression ---
|
||||||
|
//
|
||||||
|
// Background: every retry used to yield a SystemAPIErrorMessage to the
|
||||||
|
// chat as long as the error was an APIError. A flaky upstream that
|
||||||
|
// recovers in 1–2 retries painted the conversation with a wall of
|
||||||
|
// identical error bubbles. We now suppress consecutive identical errors
|
||||||
|
// until SAME_ERROR_REPORT_THRESHOLD (default 3) and yield distinct
|
||||||
|
// errors — and 429s — immediately.
|
||||||
|
|
||||||
|
function makeApiError(status: number, message: string): APIError {
|
||||||
|
// Bypass the protected-constructor / generate path: the only thing the
|
||||||
|
// limiter cares about is `instanceof APIError`, `.status`, `.message`.
|
||||||
|
// Build a plain object that satisfies those checks.
|
||||||
|
const err = new Error(message) as Error & {
|
||||||
|
status?: number
|
||||||
|
requestID?: string
|
||||||
|
}
|
||||||
|
err.name = 'APIError'
|
||||||
|
err.status = status
|
||||||
|
err.requestID = 'req-test'
|
||||||
|
// Re-parent the prototype so `instanceof APIError` matches.
|
||||||
|
Object.setPrototypeOf(err, APIError.prototype)
|
||||||
|
return err as unknown as APIError
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectRetryYields(opts: {
|
||||||
|
errorsBeforeOk: APIError[]
|
||||||
|
}): Promise<{ yielded: number; finalValue: string }> {
|
||||||
|
let attempt = 0
|
||||||
|
const generator = withRetry(
|
||||||
|
async () => ({} as Anthropic),
|
||||||
|
async () => {
|
||||||
|
const err = opts.errorsBeforeOk[attempt]
|
||||||
|
attempt += 1
|
||||||
|
if (err) throw err
|
||||||
|
return 'ok'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: 'claude-opus-4-7',
|
||||||
|
thinkingConfig: { type: 'disabled' },
|
||||||
|
maxRetries: opts.errorsBeforeOk.length,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
let yielded = 0
|
||||||
|
let finalValue = ''
|
||||||
|
for (;;) {
|
||||||
|
const next = await generator.next()
|
||||||
|
if (next.done) {
|
||||||
|
finalValue = next.value
|
||||||
|
break
|
||||||
|
}
|
||||||
|
yielded += 1
|
||||||
|
}
|
||||||
|
return { yielded, finalValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('withRetry same-error suppression', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.CLAUDE_CODE_RETRY_REPORT_AFTER
|
||||||
|
})
|
||||||
|
|
||||||
|
test('suppresses the first two identical 500 errors and reports the third', async () => {
|
||||||
|
const err = makeApiError(500, 'Internal Server Error')
|
||||||
|
// Three identical failures, then succeed on the 4th attempt.
|
||||||
|
const result = await collectRetryYields({
|
||||||
|
errorsBeforeOk: [err, err, err],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.finalValue).toBe('ok')
|
||||||
|
// 1st error: new key, reported (1 yield)
|
||||||
|
// 2nd identical: suppressed
|
||||||
|
// 3rd identical: threshold (3) crossed, reported (1 yield)
|
||||||
|
// Total: 2 yields, far less than the 3 the old code produced.
|
||||||
|
expect(result.yielded).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a different error after a streak yields immediately on the new key', async () => {
|
||||||
|
const e500 = makeApiError(500, 'Internal Server Error')
|
||||||
|
const e503 = makeApiError(503, 'Service Unavailable')
|
||||||
|
// 500, 500 (suppressed), 503 (NEW key, immediate), then ok.
|
||||||
|
const result = await collectRetryYields({
|
||||||
|
errorsBeforeOk: [e500, e500, e503],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.finalValue).toBe('ok')
|
||||||
|
// 1st (500 new): yielded
|
||||||
|
// 2nd (500 same): suppressed
|
||||||
|
// 3rd (503 new): yielded
|
||||||
|
expect(result.yielded).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('CLAUDE_CODE_RETRY_REPORT_AFTER env override raises the threshold', async () => {
|
||||||
|
process.env.CLAUDE_CODE_RETRY_REPORT_AFTER = '4'
|
||||||
|
const err = makeApiError(500, 'Boom')
|
||||||
|
// Three identical errors should ALL be suppressed because the
|
||||||
|
// threshold is now 4; only the first (new-key bypass) yields.
|
||||||
|
const result = await collectRetryYields({
|
||||||
|
errorsBeforeOk: [err, err, err],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.finalValue).toBe('ok')
|
||||||
|
expect(result.yielded).toBe(1) // only the first-sighting yield
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@ -53,6 +53,40 @@ const FLOOR_OUTPUT_TOKENS = 3000
|
|||||||
const MAX_529_RETRIES = 3
|
const MAX_529_RETRIES = 3
|
||||||
export const BASE_DELAY_MS = 500
|
export const BASE_DELAY_MS = 500
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of *consecutive identical* failures before we surface a retry to
|
||||||
|
* the chat as a SystemAPIErrorMessage. The first few transient retries are
|
||||||
|
* suppressed so the user does not see a wall of identical error bubbles
|
||||||
|
* for a flaky upstream that recovers on its own. Distinct errors are
|
||||||
|
* still yielded immediately — they carry new information.
|
||||||
|
*
|
||||||
|
* Configurable via CLAUDE_CODE_RETRY_REPORT_AFTER (positive integer, ≥1).
|
||||||
|
* The default of 3 means "two silent retries, then start reporting".
|
||||||
|
*/
|
||||||
|
const DEFAULT_RETRY_REPORT_THRESHOLD = 3
|
||||||
|
|
||||||
|
function getRetryReportThreshold(): number {
|
||||||
|
const raw = process.env.CLAUDE_CODE_RETRY_REPORT_AFTER
|
||||||
|
if (!raw) return DEFAULT_RETRY_REPORT_THRESHOLD
|
||||||
|
const parsed = Number.parseInt(raw, 10)
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 1) return DEFAULT_RETRY_REPORT_THRESHOLD
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable identity key for "is this the same error as last time?". Uses the
|
||||||
|
* error message plus (when present) the upstream HTTP status so a 401 and
|
||||||
|
* a 503 with the same generic body don't fold together. Falls back to
|
||||||
|
* String(error) for non-Error values so the comparison is always defined.
|
||||||
|
*/
|
||||||
|
function errorIdentityKey(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const status = error instanceof APIError ? `:${error.status ?? 'na'}` : ''
|
||||||
|
return `${error.name}${status}:${error.message}`
|
||||||
|
}
|
||||||
|
return String(error)
|
||||||
|
}
|
||||||
|
|
||||||
// Foreground query sources where the user IS blocking on the result — these
|
// Foreground query sources where the user IS blocking on the result — these
|
||||||
// retry on 529. Everything else (summaries, titles, suggestions, classifiers)
|
// retry on 529. Everything else (summaries, titles, suggestions, classifiers)
|
||||||
// bails immediately: during a capacity cascade each retry is 3-10× gateway
|
// bails immediately: during a capacity cascade each retry is 3-10× gateway
|
||||||
@ -185,6 +219,38 @@ export async function* withRetry<T>(
|
|||||||
let consecutive529Errors = options.initialConsecutive529Errors ?? 0
|
let consecutive529Errors = options.initialConsecutive529Errors ?? 0
|
||||||
let lastError: unknown
|
let lastError: unknown
|
||||||
let persistentAttempt = 0
|
let persistentAttempt = 0
|
||||||
|
// Suppress noisy duplicate retry messages: yield to chat only after
|
||||||
|
// SAME_ERROR_REPORT_THRESHOLD identical failures in a row, so a flaky
|
||||||
|
// upstream that recovers in 1–2 retries does not splash the chat with
|
||||||
|
// identical error bubbles. Distinct errors yield immediately because
|
||||||
|
// they carry new information for the user.
|
||||||
|
const reportThreshold = getRetryReportThreshold()
|
||||||
|
let consecutiveSameErrorCount = 0
|
||||||
|
let prevErrorKey: string | undefined
|
||||||
|
/**
|
||||||
|
* Decide whether the current retry attempt's error should be surfaced
|
||||||
|
* to the chat as a SystemAPIErrorMessage. Updates the
|
||||||
|
* consecutive-same-error counter as a side effect. Always reports the
|
||||||
|
* first occurrence of a *new* error key (it carries new info), and
|
||||||
|
* always reports rate-limit 429s (the wait-time info is what the user
|
||||||
|
* is waiting to see). Otherwise gates on the threshold.
|
||||||
|
*/
|
||||||
|
const shouldReportRetry = (error: unknown): boolean => {
|
||||||
|
const key = errorIdentityKey(error)
|
||||||
|
if (key === prevErrorKey) {
|
||||||
|
consecutiveSameErrorCount += 1
|
||||||
|
} else {
|
||||||
|
prevErrorKey = key
|
||||||
|
consecutiveSameErrorCount = 1
|
||||||
|
// First sighting of a new error key — report immediately (new
|
||||||
|
// information for the user).
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 429 rate-limit retries always report: the delay/wait info in the
|
||||||
|
// SystemAPIErrorMessage is the whole point of surfacing them.
|
||||||
|
if (error instanceof APIError && error.status === 429) return true
|
||||||
|
return consecutiveSameErrorCount >= reportThreshold
|
||||||
|
}
|
||||||
for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
|
for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
|
||||||
if (options.signal?.aborted) {
|
if (options.signal?.aborted) {
|
||||||
throw new APIUserAbortError()
|
throw new APIUserAbortError()
|
||||||
@ -480,11 +546,17 @@ export async function* withRetry<T>(
|
|||||||
// does not mark the session idle. Each yield surfaces as
|
// does not mark the session idle. Each yield surfaces as
|
||||||
// {type:'system', subtype:'api_retry'} on stdout via QueryEngine.
|
// {type:'system', subtype:'api_retry'} on stdout via QueryEngine.
|
||||||
let remaining = delayMs
|
let remaining = delayMs
|
||||||
|
// Decide once per outer retry whether this error gets surfaced
|
||||||
|
// to chat (gated by the same-error threshold). Heartbeats during
|
||||||
|
// the sleep loop keep yielding so long persistent waits don't
|
||||||
|
// look like the session has died — but they only do so once we
|
||||||
|
// have crossed the threshold for this error.
|
||||||
|
const surfaceThisError = error instanceof APIError && shouldReportRetry(error)
|
||||||
while (remaining > 0) {
|
while (remaining > 0) {
|
||||||
if (options.signal?.aborted) throw new APIUserAbortError()
|
if (options.signal?.aborted) throw new APIUserAbortError()
|
||||||
if (error instanceof APIError) {
|
if (surfaceThisError) {
|
||||||
yield createSystemAPIErrorMessage(
|
yield createSystemAPIErrorMessage(
|
||||||
error,
|
error as APIError,
|
||||||
remaining,
|
remaining,
|
||||||
reportedAttempt,
|
reportedAttempt,
|
||||||
maxRetries,
|
maxRetries,
|
||||||
@ -498,7 +570,7 @@ export async function* withRetry<T>(
|
|||||||
// persistentAttempt counter which keeps growing to the 5-min cap.
|
// persistentAttempt counter which keeps growing to the 5-min cap.
|
||||||
if (attempt >= maxRetries) attempt = maxRetries
|
if (attempt >= maxRetries) attempt = maxRetries
|
||||||
} else {
|
} else {
|
||||||
if (error instanceof APIError) {
|
if (error instanceof APIError && shouldReportRetry(error)) {
|
||||||
yield createSystemAPIErrorMessage(error, delayMs, attempt, maxRetries)
|
yield createSystemAPIErrorMessage(error, delayMs, attempt, maxRetries)
|
||||||
}
|
}
|
||||||
await sleep(delayMs, options.signal, { abortError })
|
await sleep(delayMs, options.signal, { abortError })
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user