feat(core): add retry for transient HTTP and stream-level errors

Add retry mechanism driven by pi settings.json retry.provider config
(timeoutMs, maxRetries, maxRetryDelayMs).

HTTP-level retries handle 429/5xx with exponential backoff and jitter,
respecting Retry-After headers (seconds and HTTP-date formats).

Stream-level retries handle cases where the API returns 200 OK but
sends an error event in the stream body. Retries only when no content
has been emitted yet.

Per-attempt timeout via AbortController with automatic retry. Clean
abort propagation through the retry loop.
This commit is contained in:
jizhejiang
2026-06-02 11:38:10 +08:00
parent f48291f252
commit c3107dbc1c
7 changed files with 610 additions and 1995 deletions
+189 -65
View File
@@ -42,6 +42,37 @@ export const DEFAULT_API_BASE = "https://api.commandcode.ai"
export const COMMAND_CODE_CLI_VERSION = "0.29.0"
const DEFAULT_GENERATE_MAX_TOKENS = 64_000
const DEFAULT_MAX_RETRIES = 2
const DEFAULT_MAX_RETRY_DELAY_MS = 60_000
const BASE_RETRY_DELAY_MS = 500
function isRetryableStatus(status: number): boolean {
return status === 429 || (status >= 500 && status < 600)
}
function parseRetryAfterSeconds(value: string | null): number | undefined {
if (!value) return undefined
const seconds = Number(value)
if (Number.isFinite(seconds) && seconds >= 0) return seconds
const date = Date.parse(value)
if (!Number.isNaN(date)) return Math.max(0, (date - Date.now()) / 1000)
return undefined
}
function retryDelayMs(
attempt: number,
retryAfterHeader: string | null,
maxDelayMs: number,
): number {
const retryAfterMs = parseRetryAfterSeconds(retryAfterHeader)
if (retryAfterMs !== undefined) {
if (retryAfterMs * 1000 > maxDelayMs) return -1
return retryAfterMs * 1000
}
const exponential = BASE_RETRY_DELAY_MS * 2 ** attempt
const jitter = exponential * 0.2 * Math.random()
return Math.min(exponential + jitter, maxDelayMs)
}
function defaultUsage(): Usage {
return {
@@ -104,6 +135,22 @@ export function createStreamCommandCode(deps: CoreDependencies) {
const cwd = deps.cwd ?? (() => process.cwd())
const now = deps.now ?? (() => Date.now())
const uuid = deps.uuid ?? (() => randomUUID())
const delay =
deps.delay ??
((ms: number, signal: AbortSignal) => {
if (signal.aborted) return Promise.reject(abortError())
return new Promise<void>((resolve, reject) => {
const id = setTimeout(() => {
signal.removeEventListener("abort", onAbort)
resolve()
}, ms)
const onAbort = () => {
clearTimeout(id)
reject(abortError())
}
signal.addEventListener("abort", onAbort, { once: true })
})
})
function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) return Promise.reject(abortError())
@@ -386,81 +433,158 @@ export function createStreamCommandCode(deps: CoreDependencies) {
)
if (nextBody !== undefined) body = nextBody
const response = await raceAbort(
fetchImpl(`${apiBase}/alpha/generate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
"x-command-code-version": COMMAND_CODE_CLI_VERSION,
"x-cli-environment": "production",
"x-project-slug": projectSlugFromPath(workingDir),
"x-taste-learning": "true",
"x-co-flag": "false",
...options?.headers,
},
body: JSON.stringify(body),
signal: controller.signal,
}),
controller.signal,
)
const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES
const maxRetryDelayMs = options?.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS
const timeoutMs = options?.timeoutMs
const requestHeaders = {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
"x-command-code-version": COMMAND_CODE_CLI_VERSION,
"x-cli-environment": "production",
"x-project-slug": projectSlugFromPath(workingDir),
"x-taste-learning": "true",
"x-co-flag": "false",
...options?.headers,
}
const bodyStr = JSON.stringify(body)
await raceAbort(
Promise.resolve(
options?.onResponse?.(
{
status: response.status,
headers: headersToRecord(response.headers),
},
model,
let response!: Response
retryLoop: for (let attempt = 0; ; attempt++) {
const attemptController = new AbortController()
let attemptTimedOut = false
let attemptTimeoutId: ReturnType<typeof setTimeout> | undefined
if (timeoutMs !== undefined) {
attemptTimeoutId = setTimeout(() => {
attemptTimedOut = true
attemptController.abort()
}, timeoutMs)
}
const onOuterAbort = () => attemptController.abort()
controller.signal.addEventListener("abort", onOuterAbort, { once: true })
try {
response = await fetchImpl(`${apiBase}/alpha/generate`, {
method: "POST",
headers: requestHeaders,
body: bodyStr,
signal: attemptController.signal,
})
} catch (fetchError: unknown) {
if (controller.signal.aborted) throw abortError("Aborted")
if (
timeoutMs !== undefined &&
attemptController.signal.aborted &&
attempt < maxRetries
) {
continue
}
throw fetchError
} finally {
controller.signal.removeEventListener("abort", onOuterAbort)
if (attemptTimeoutId !== undefined) clearTimeout(attemptTimeoutId)
}
// --- HTTP-level retry ---
if (!response.ok && isRetryableStatus(response.status) && attempt < maxRetries) {
const retryAfter = response.headers.get("retry-after")
const waitMs = retryDelayMs(attempt, retryAfter, maxRetryDelayMs)
if (waitMs < 0) {
const requestedSeconds = parseRetryAfterSeconds(retryAfter) ?? 0
throw new Error(
`Retry-After delay ${requestedSeconds}s exceeds max ${maxRetryDelayMs}ms`,
)
}
await response.text().catch(() => "")
if (waitMs > 0) await delay(waitMs, controller.signal)
continue retryLoop
}
await raceAbort(
Promise.resolve(
options?.onResponse?.(
{
status: response.status,
headers: headersToRecord(response.headers),
},
model,
),
),
),
controller.signal,
)
if (!response.ok) {
const errBody = await raceAbort(
response.text().catch(() => ""),
controller.signal,
)
throw new Error(`Command Code API error ${response.status}: ${errBody.slice(0, 500)}`)
}
reader = response.body?.getReader()
if (!reader) throw new Error("No response body")
const decoder = new TextDecoder()
let buffer = ""
readLoop: for (;;) {
if (controller.signal.aborted) throw abortError("Aborted")
const { done, value } = await raceAbort(reader.read(), controller.signal)
if (done) {
if (buffer.trim()) handleEvent(parseStreamEventLine(buffer))
break
if (!response.ok) {
const errBody = await raceAbort(
response.text().catch(() => ""),
controller.signal,
)
throw new Error(`Command Code API error ${response.status}: ${errBody.slice(0, 500)}`)
}
if (controller.signal.aborted) throw abortError("Aborted")
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() ?? ""
// --- Read response stream ---
reader = response.body?.getReader()
if (!reader) throw new Error("No response body")
for (const line of lines) {
if (controller.signal.aborted) throw abortError("Aborted")
handleEvent(parseStreamEventLine(line))
if (finished) break readLoop
const decoder = new TextDecoder()
let buffer = ""
try {
readLoop: for (;;) {
if (controller.signal.aborted) throw abortError("Aborted")
const { done, value } = await raceAbort(reader.read(), controller.signal)
if (done) {
if (buffer.trim()) handleEvent(parseStreamEventLine(buffer))
break
}
if (controller.signal.aborted) throw abortError("Aborted")
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() ?? ""
for (const line of lines) {
if (controller.signal.aborted) throw abortError("Aborted")
handleEvent(parseStreamEventLine(line))
if (finished) break readLoop
}
}
} catch (streamError: unknown) {
// Stream-level error (e.g. API returned 200 OK but sent an error event)
// or per-attempt timeout during stream reading.
await reader.cancel().catch(() => {})
try {
reader.releaseLock()
} catch {}
reader = undefined
if (controller.signal.aborted) throw streamError
const canRetry =
(output.content.length === 0 || attemptTimedOut) && attempt < maxRetries
if (canRetry) {
// Reset state for the next attempt.
output.content.length = 0
output.stopReason = "stop"
output.errorMessage = undefined
finished = false
const waitMs = attemptTimedOut ? 0 : retryDelayMs(attempt, null, maxRetryDelayMs)
if (waitMs > 0) await delay(waitMs, controller.signal)
continue retryLoop
}
throw streamError
}
// Stream completed successfully.
endTextBlock()
endThinking()
stream.push({
type: "done",
reason: successStopReason(output.stopReason),
message: output,
})
stream.end()
break retryLoop
}
endTextBlock()
endThinking()
stream.push({
type: "done",
reason: successStopReason(output.stopReason),
message: output,
})
stream.end()
} catch (error: unknown) {
const reason: ErrorReason = controller.signal.aborted ? "aborted" : "error"
output.stopReason = reason