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
+21 -6
View File
@@ -112,6 +112,7 @@ export function createTestDeps(overrides: Partial<CoreDependencies> = {}): TestD
now: () => new Date("2026-05-05T12:00:00Z").getTime(),
uuid: () => "00000000-0000-4000-8000-000000000000",
cwd: () => "/repo",
delay: async () => {},
...overrides,
})
return { streamCommandCode, calculatedUsages }
@@ -124,12 +125,15 @@ type SuccessPlan = {
chunks?: string[]
delays?: number[]
hangAfterLast?: boolean
/** Delay in ms before the server starts sending the response. */
responseDelay?: number
}
type ErrorPlan = {
type: "error"
status: number
body: string
headers?: Record<string, string>
}
export type ResponsePlan = SuccessPlan | ErrorPlan
@@ -146,6 +150,7 @@ function headersToRecord(headers: IncomingHttpHeaders): Record<string, string> {
export interface MockCommandCodeServer {
baseUrl(): string
mockResponse(plan: ResponsePlan): void
mockResponseQueue(plans: ResponsePlan[]): void
reset(): void
close(): Promise<void>
lastRequestBody(): unknown
@@ -155,7 +160,7 @@ export interface MockCommandCodeServer {
}
export async function startMockCommandCodeServer(): Promise<MockCommandCodeServer> {
let nextPlan: ResponsePlan = { type: "success", events: [] }
let planQueue: ResponsePlan[] = [{ type: "success", events: [] }]
let lastBody: unknown
let lastHeaders: Record<string, string> = {}
let requests = 0
@@ -183,9 +188,12 @@ export async function startMockCommandCodeServer(): Promise<MockCommandCodeServe
lastBody = undefined
}
const plan = nextPlan
// Pop the first plan from the queue; keep the last one as fallback.
const plan = planQueue.length > 1 ? planQueue.shift()! : planQueue[0]
if (plan.type === "error") {
res.writeHead(plan.status, { "Content-Type": "text/plain" })
const headers: Record<string, string> = { "Content-Type": "text/plain", ...plan.headers }
res.writeHead(plan.status, headers)
res.end(plan.body)
return
}
@@ -223,7 +231,11 @@ export async function startMockCommandCodeServer(): Promise<MockCommandCodeServe
}
}
sendNext()
if (plan.responseDelay) {
setTimeout(sendNext, plan.responseDelay)
} else {
sendNext()
}
})
})
@@ -238,10 +250,13 @@ export async function startMockCommandCodeServer(): Promise<MockCommandCodeServe
return {
baseUrl: () => `http://127.0.0.1:${port}`,
mockResponse(plan: ResponsePlan) {
nextPlan = plan
planQueue = [plan]
},
mockResponseQueue(plans: ResponsePlan[]) {
planQueue = [...plans]
},
reset() {
nextPlan = { type: "success", events: [] }
planQueue = [{ type: "success", events: [] }]
lastBody = undefined
lastHeaders = {}
requests = 0