Skip to content
This repository was archived by the owner on Apr 1, 2026. It is now read-only.

Commit f7cc46c

Browse files
set cap for max time to wait between retries (anomalyco#4135)
Co-authored-by: GitHub Action <action@github.com>
1 parent d9ffe07 commit f7cc46c

4 files changed

Lines changed: 205 additions & 42 deletions

File tree

packages/opencode/src/session/compaction.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,15 +267,24 @@ export namespace SessionCompaction {
267267
max: maxRetries,
268268
})
269269
if (result.shouldRetry) {
270+
const start = Date.now()
270271
for (let retry = 1; retry < maxRetries; retry++) {
271272
const lastRetryPart = result.parts.findLast((p): p is MessageV2.RetryPart => p.type === "retry")
272273

273274
if (lastRetryPart) {
274-
const delayMs = SessionRetry.getRetryDelayInMs(lastRetryPart.error, retry)
275+
const delayMs = SessionRetry.getBoundedDelay({
276+
error: lastRetryPart.error,
277+
attempt: retry,
278+
startTime: start,
279+
})
280+
if (!delayMs) {
281+
break
282+
}
275283

276284
log.info("retrying with backoff", {
277285
attempt: retry,
278286
delayMs,
287+
elapsed: Date.now() - start,
279288
})
280289

281290
const stop = await SessionRetry.sleep(delayMs, signal)

packages/opencode/src/session/prompt.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,15 +355,24 @@ export namespace SessionPrompt {
355355
max: maxRetries,
356356
})
357357
if (result.shouldRetry) {
358+
const start = Date.now()
358359
for (let retry = 1; retry < maxRetries; retry++) {
359360
const lastRetryPart = result.parts.findLast((p): p is MessageV2.RetryPart => p.type === "retry")
360361

361362
if (lastRetryPart) {
362-
const delayMs = SessionRetry.getRetryDelayInMs(lastRetryPart.error, retry)
363+
const delayMs = SessionRetry.getBoundedDelay({
364+
error: lastRetryPart.error,
365+
attempt: retry,
366+
startTime: start,
367+
})
368+
if (!delayMs) {
369+
break
370+
}
363371

364372
log.info("retrying with backoff", {
365373
attempt: retry,
366374
delayMs,
375+
elapsed: Date.now() - start,
367376
})
368377

369378
const stop = await SessionRetry.sleep(delayMs, abort.signal)
Lines changed: 53 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import { iife } from "@/util/iife"
12
import { MessageV2 } from "./message-v2"
23

34
export namespace SessionRetry {
45
export const RETRY_INITIAL_DELAY = 2000
56
export const RETRY_BACKOFF_FACTOR = 2
7+
export const RETRY_MAX_DELAY = 600_000 // 10 minutes
8+
export const RETRY_HEADER_BUFFER = 1000 // add 1s buffer to server-provided delays
69

710
export async function sleep(ms: number, signal: AbortSignal): Promise<void> {
811
return new Promise((resolve, reject) => {
@@ -18,40 +21,57 @@ export namespace SessionRetry {
1821
})
1922
}
2023

21-
export function getRetryDelayInMs(error: MessageV2.APIError, attempt: number): number {
22-
const base = RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1)
23-
const headers = error.data.responseHeaders
24-
if (!headers) return base
25-
26-
const retryAfterMs = headers["retry-after-ms"]
27-
if (retryAfterMs) {
28-
const parsed = Number.parseFloat(retryAfterMs)
29-
const normalized = normalizeDelay({ base, candidate: parsed })
30-
if (normalized != null) return normalized
31-
}
32-
33-
const retryAfter = headers["retry-after"]
34-
if (!retryAfter) return base
35-
36-
const seconds = Number.parseFloat(retryAfter)
37-
if (!Number.isNaN(seconds)) {
38-
const normalized = normalizeDelay({ base, candidate: seconds * 1000 })
39-
if (normalized != null) return normalized
40-
return base
41-
}
42-
43-
const dateMs = Date.parse(retryAfter) - Date.now()
44-
const normalized = normalizeDelay({ base, candidate: dateMs })
45-
if (normalized != null) return normalized
46-
47-
return base
24+
export function getRetryDelayInMs(error: MessageV2.APIError, attempt: number) {
25+
const delay = iife(() => {
26+
const headers = error.data.responseHeaders
27+
if (headers) {
28+
const retryAfterMs = headers["retry-after-ms"]
29+
if (retryAfterMs) {
30+
const parsedMs = Number.parseFloat(retryAfterMs)
31+
if (!Number.isNaN(parsedMs)) {
32+
return parsedMs
33+
}
34+
}
35+
36+
const retryAfter = headers["retry-after"]
37+
if (retryAfter) {
38+
const parsedSeconds = Number.parseFloat(retryAfter)
39+
if (!Number.isNaN(parsedSeconds)) {
40+
// convert seconds to milliseconds
41+
return Math.ceil(parsedSeconds * 1000)
42+
}
43+
// Try parsing as HTTP date format
44+
const parsed = Date.parse(retryAfter) - Date.now()
45+
if (!Number.isNaN(parsed) && parsed > 0) {
46+
return Math.ceil(parsed)
47+
}
48+
}
49+
}
50+
51+
return RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1)
52+
})
53+
54+
// dont retry if wait is too far from now
55+
if (delay > RETRY_MAX_DELAY) return undefined
56+
57+
return delay
4858
}
4959

50-
function normalizeDelay(input: { base: number; candidate: number }): number | undefined {
51-
if (Number.isNaN(input.candidate)) return undefined
52-
if (input.candidate < 0) return undefined
53-
if (input.candidate < 60_000) return input.candidate
54-
if (input.candidate < input.base) return input.candidate
55-
return undefined
60+
export function getBoundedDelay(input: {
61+
error: MessageV2.APIError
62+
attempt: number
63+
startTime: number
64+
maxDuration?: number
65+
}) {
66+
const elapsed = Date.now() - input.startTime
67+
const maxDuration = input.maxDuration ?? RETRY_MAX_DELAY
68+
const remaining = maxDuration - elapsed
69+
70+
if (remaining <= 0) return undefined
71+
72+
const delay = getRetryDelayInMs(input.error, input.attempt)
73+
if (!delay) return undefined
74+
75+
return Math.min(delay, remaining)
5676
}
5777
}

packages/opencode/test/session/retry.test.ts

Lines changed: 132 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ function apiError(headers?: Record<string, string>): MessageV2.APIError {
1313
describe("session.retry.getRetryDelayInMs", () => {
1414
test("doubles delay on each attempt when headers missing", () => {
1515
const error = apiError()
16-
const delays = Array.from({ length: 7 }, (_, index) => SessionRetry.getRetryDelayInMs(error, index + 1))
17-
expect(delays).toStrictEqual([2000, 4000, 8000, 16000, 32000, 64000, 128000])
16+
const delays = Array.from({ length: 10 }, (_, index) => SessionRetry.getRetryDelayInMs(error, index + 1))
17+
expect(delays).toStrictEqual([2000, 4000, 8000, 16000, 32000, 64000, 128000, 256000, 512000, undefined])
1818
})
1919

2020
test("prefers retry-after-ms when shorter than exponential", () => {
@@ -27,11 +27,6 @@ describe("session.retry.getRetryDelayInMs", () => {
2727
expect(SessionRetry.getRetryDelayInMs(error, 3)).toBe(30000)
2828
})
2929

30-
test("falls back to exponential when server delay is long", () => {
31-
const error = apiError({ "retry-after": "120" })
32-
expect(SessionRetry.getRetryDelayInMs(error, 2)).toBe(4000)
33-
})
34-
3530
test("accepts http-date retry-after values", () => {
3631
const date = new Date(Date.now() + 20000).toUTCString()
3732
const error = apiError({ "retry-after": date })
@@ -44,4 +39,134 @@ describe("session.retry.getRetryDelayInMs", () => {
4439
const error = apiError({ "retry-after": "not-a-number" })
4540
expect(SessionRetry.getRetryDelayInMs(error, 1)).toBe(2000)
4641
})
42+
43+
test("ignores malformed date retry hints", () => {
44+
const error = apiError({ "retry-after": "Invalid Date String" })
45+
expect(SessionRetry.getRetryDelayInMs(error, 1)).toBe(2000)
46+
})
47+
48+
test("ignores past date retry hints", () => {
49+
const pastDate = new Date(Date.now() - 5000).toUTCString()
50+
const error = apiError({ "retry-after": pastDate })
51+
expect(SessionRetry.getRetryDelayInMs(error, 1)).toBe(2000)
52+
})
53+
54+
test("returns undefined when delay exceeds 10 minutes", () => {
55+
const error = apiError()
56+
expect(SessionRetry.getRetryDelayInMs(error, 10)).toBeUndefined()
57+
})
58+
59+
test("returns undefined when retry-after exceeds 10 minutes", () => {
60+
const error = apiError({ "retry-after": "50" })
61+
expect(SessionRetry.getRetryDelayInMs(error, 1)).toBe(50000)
62+
63+
const longError = apiError({ "retry-after-ms": "700000" })
64+
expect(SessionRetry.getRetryDelayInMs(longError, 1)).toBeUndefined()
65+
})
66+
})
67+
68+
describe("session.retry.getBoundedDelay", () => {
69+
test("returns full delay when under time budget", () => {
70+
const error = apiError()
71+
const startTime = Date.now()
72+
const delay = SessionRetry.getBoundedDelay({
73+
error,
74+
attempt: 1,
75+
startTime,
76+
})
77+
expect(delay).toBe(2000)
78+
})
79+
80+
test("returns remaining time when delay exceeds budget", () => {
81+
const error = apiError()
82+
const startTime = Date.now() - 598_000 // 598 seconds elapsed, 2 seconds remaining
83+
const delay = SessionRetry.getBoundedDelay({
84+
error,
85+
attempt: 1,
86+
startTime,
87+
})
88+
expect(delay).toBeGreaterThanOrEqual(1900)
89+
expect(delay).toBeLessThanOrEqual(2100)
90+
})
91+
92+
test("returns undefined when time budget exhausted", () => {
93+
const error = apiError()
94+
const startTime = Date.now() - 600_000 // exactly 10 minutes elapsed
95+
const delay = SessionRetry.getBoundedDelay({
96+
error,
97+
attempt: 1,
98+
startTime,
99+
})
100+
expect(delay).toBeUndefined()
101+
})
102+
103+
test("returns undefined when time budget exceeded", () => {
104+
const error = apiError()
105+
const startTime = Date.now() - 700_000 // 11+ minutes elapsed
106+
const delay = SessionRetry.getBoundedDelay({
107+
error,
108+
attempt: 1,
109+
startTime,
110+
})
111+
expect(delay).toBeUndefined()
112+
})
113+
114+
test("respects custom maxDuration", () => {
115+
const error = apiError()
116+
const startTime = Date.now() - 58_000 // 58 seconds elapsed
117+
const delay = SessionRetry.getBoundedDelay({
118+
error,
119+
attempt: 1,
120+
startTime,
121+
maxDuration: 60_000, // 1 minute max
122+
})
123+
expect(delay).toBeGreaterThanOrEqual(1900)
124+
expect(delay).toBeLessThanOrEqual(2100)
125+
})
126+
127+
test("caps exponential backoff to remaining time", () => {
128+
const error = apiError()
129+
const startTime = Date.now() - 595_000 // 595 seconds elapsed, 5 seconds remaining
130+
const delay = SessionRetry.getBoundedDelay({
131+
error,
132+
attempt: 5, // would normally be 32 seconds
133+
startTime,
134+
})
135+
expect(delay).toBeGreaterThanOrEqual(4900)
136+
expect(delay).toBeLessThanOrEqual(5100)
137+
})
138+
139+
test("respects server retry-after within budget", () => {
140+
const error = apiError({ "retry-after": "30" })
141+
const startTime = Date.now() - 550_000 // 550 seconds elapsed, 50 seconds remaining
142+
const delay = SessionRetry.getBoundedDelay({
143+
error,
144+
attempt: 1,
145+
startTime,
146+
})
147+
expect(delay).toBe(30000)
148+
})
149+
150+
test("caps server retry-after to remaining time", () => {
151+
const error = apiError({ "retry-after": "30" })
152+
const startTime = Date.now() - 590_000 // 590 seconds elapsed, 10 seconds remaining
153+
const delay = SessionRetry.getBoundedDelay({
154+
error,
155+
attempt: 1,
156+
startTime,
157+
})
158+
expect(delay).toBeGreaterThanOrEqual(9900)
159+
expect(delay).toBeLessThanOrEqual(10100)
160+
})
161+
162+
test("returns undefined when getRetryDelayInMs returns undefined", () => {
163+
const error = apiError()
164+
const startTime = Date.now()
165+
const delay = SessionRetry.getBoundedDelay({
166+
error,
167+
attempt: 10, // exceeds RETRY_MAX_DELAY
168+
startTime,
169+
})
170+
expect(delay).toBeUndefined()
171+
})
47172
})

0 commit comments

Comments
 (0)