diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 463bc27a95db..48d119b85c9f 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -31,6 +31,14 @@ function cap(ms: number) { return Math.min(ms, RETRY_MAX_DELAY) } +function backoff(attempt: number) { + return cap(Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS)) +} + +function isDelayHint(value: number) { + return Number.isFinite(value) && value >= 0 +} + export function delay(attempt: number, error?: MessageV2.APIError) { if (error) { const headers = error.data.responseHeaders @@ -38,15 +46,13 @@ export function delay(attempt: number, error?: MessageV2.APIError) { const retryAfterMs = headers["retry-after-ms"] if (retryAfterMs) { const parsedMs = Number.parseFloat(retryAfterMs) - if (!Number.isNaN(parsedMs)) { - return cap(parsedMs) - } + if (isDelayHint(parsedMs)) return cap(parsedMs) } const retryAfter = headers["retry-after"] if (retryAfter) { const parsedSeconds = Number.parseFloat(retryAfter) - if (!Number.isNaN(parsedSeconds)) { + if (isDelayHint(parsedSeconds)) { // convert seconds to milliseconds return cap(Math.ceil(parsedSeconds * 1000)) } @@ -57,11 +63,11 @@ export function delay(attempt: number, error?: MessageV2.APIError) { } } - return cap(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1)) + return backoff(attempt) } } - return cap(Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS)) + return backoff(attempt) } export function retryable(error: Err, provider: string) { diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 22ff6cde811d..0c788326fdf4 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -37,6 +37,12 @@ describe("session.retry.delay", () => { expect(delays).toStrictEqual([2000, 4000, 8000, 16000, 30000, 30000, 30000, 30000, 30000, 30000]) }) + test("caps delay at 30 seconds when headers omit retry hints", () => { + const error = apiError({ date: new Date().toUTCString() }) + const delays = Array.from({ length: 10 }, (_, index) => SessionRetry.delay(index + 1, error)) + expect(delays).toStrictEqual([2000, 4000, 8000, 16000, 30000, 30000, 30000, 30000, 30000, 30000]) + }) + test("prefers retry-after-ms when shorter than exponential", () => { const error = apiError({ "retry-after-ms": "1500" }) expect(SessionRetry.delay(4, error)).toBe(1500) @@ -60,6 +66,11 @@ describe("session.retry.delay", () => { expect(SessionRetry.delay(1, error)).toBe(2000) }) + test("ignores negative retry hints", () => { + expect(SessionRetry.delay(1, apiError({ "retry-after-ms": "-1" }))).toBe(2000) + expect(SessionRetry.delay(2, apiError({ "retry-after": "-1" }))).toBe(4000) + }) + test("ignores malformed date retry hints", () => { const error = apiError({ "retry-after": "Invalid Date String" }) expect(SessionRetry.delay(1, error)).toBe(2000)