Skip to content

Commit 8b19c6c

Browse files
committed
better retry display
1 parent a5365ce commit 8b19c6c

7 files changed

Lines changed: 118 additions & 211 deletions

File tree

.opencode/opencode.jsonc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"provider": {
55
"opencode": {
66
"options": {
7-
// "baseURL": "http://localhost:8080"
7+
"baseURL": "http://localhost:8080",
88
},
99
},
1010
},

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
For,
77
Match,
88
on,
9+
onCleanup,
10+
onMount,
911
Show,
1012
Switch,
1113
useContext,
@@ -972,11 +974,32 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
972974
<box paddingLeft={3} flexDirection="row" gap={1} marginTop={1}>
973975
<text fg={local.agent.color(props.message.mode)}>{Locale.titlecase(props.message.mode)}</text>
974976
<Shimmer text={props.message.modelID} color={theme.text} />
975-
<Show when={status().type === "retry"}>
976-
<text fg={theme.error}>
977-
{(status() as any).message} [attempt #{(status() as any).attempt}]
978-
</text>
979-
</Show>
977+
{(() => {
978+
const retry = createMemo(() => {
979+
const s = status()
980+
if (s.type !== "retry") return
981+
return s
982+
})
983+
const [seconds, setSeconds] = createSignal(0)
984+
onMount(() => {
985+
const timer = setInterval(() => {
986+
const next = retry()?.next
987+
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
988+
}, 1000)
989+
990+
onCleanup(() => {
991+
clearInterval(timer)
992+
})
993+
})
994+
return (
995+
<Show when={retry()}>
996+
<text fg={theme.error}>
997+
{retry()!.message} [attempt #{retry()!.attempt}
998+
{seconds() > 0 ? `, retrying in ${seconds()}s` : ""}]
999+
</text>
1000+
</Show>
1001+
)
1002+
})()}
9801003
</box>
9811004
</Show>
9821005
<Show

packages/opencode/src/session/processor.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -325,16 +325,15 @@ export namespace SessionProcessor {
325325
const error = MessageV2.fromError(e, { providerID: input.providerID })
326326
if (error?.name === "APIError" && error.data.isRetryable) {
327327
attempt++
328-
const delay = SessionRetry.getRetryDelayInMs(error, attempt)
329-
if (delay) {
330-
SessionStatus.set(input.sessionID, {
331-
type: "retry",
332-
attempt,
333-
message: error.data.message,
334-
})
335-
await SessionRetry.sleep(delay, input.abort).catch(() => {})
336-
continue
337-
}
328+
const delay = SessionRetry.delay(error, attempt)
329+
SessionStatus.set(input.sessionID, {
330+
type: "retry",
331+
attempt,
332+
message: error.data.message,
333+
next: Date.now() + delay,
334+
})
335+
await SessionRetry.sleep(delay, input.abort).catch(() => {})
336+
continue
338337
}
339338
input.assistantMessage.error = error
340339
Bus.publish(Session.Event.Error, {

packages/opencode/src/session/retry.ts

Lines changed: 23 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { MessageV2 } from "./message-v2"
44
export namespace SessionRetry {
55
export const RETRY_INITIAL_DELAY = 2000
66
export const RETRY_BACKOFF_FACTOR = 2
7-
export const RETRY_MAX_DELAY = 600_000 // 10 minutes
7+
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
88

99
export async function sleep(ms: number, signal: AbortSignal): Promise<void> {
1010
return new Promise((resolve, reject) => {
@@ -20,57 +20,34 @@ export namespace SessionRetry {
2020
})
2121
}
2222

23-
export function getRetryDelayInMs(error: MessageV2.APIError, attempt: number) {
24-
const delay = iife(() => {
25-
const headers = error.data.responseHeaders
26-
if (headers) {
27-
const retryAfterMs = headers["retry-after-ms"]
28-
if (retryAfterMs) {
29-
const parsedMs = Number.parseFloat(retryAfterMs)
30-
if (!Number.isNaN(parsedMs)) {
31-
return parsedMs
32-
}
23+
export function delay(error: MessageV2.APIError, attempt: number) {
24+
const headers = error.data.responseHeaders
25+
if (headers) {
26+
const retryAfterMs = headers["retry-after-ms"]
27+
if (retryAfterMs) {
28+
const parsedMs = Number.parseFloat(retryAfterMs)
29+
if (!Number.isNaN(parsedMs)) {
30+
return parsedMs
3331
}
32+
}
3433

35-
const retryAfter = headers["retry-after"]
36-
if (retryAfter) {
37-
const parsedSeconds = Number.parseFloat(retryAfter)
38-
if (!Number.isNaN(parsedSeconds)) {
39-
// convert seconds to milliseconds
40-
return Math.ceil(parsedSeconds * 1000)
41-
}
42-
// Try parsing as HTTP date format
43-
const parsed = Date.parse(retryAfter) - Date.now()
44-
if (!Number.isNaN(parsed) && parsed > 0) {
45-
return Math.ceil(parsed)
46-
}
34+
const retryAfter = headers["retry-after"]
35+
if (retryAfter) {
36+
const parsedSeconds = Number.parseFloat(retryAfter)
37+
if (!Number.isNaN(parsedSeconds)) {
38+
// convert seconds to milliseconds
39+
return Math.ceil(parsedSeconds * 1000)
40+
}
41+
// Try parsing as HTTP date format
42+
const parsed = Date.parse(retryAfter) - Date.now()
43+
if (!Number.isNaN(parsed) && parsed > 0) {
44+
return Math.ceil(parsed)
4745
}
4846
}
4947

5048
return RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1)
51-
})
52-
53-
// dont retry if wait is too far from now
54-
if (delay > RETRY_MAX_DELAY) return undefined
55-
56-
return delay
57-
}
58-
59-
export function getBoundedDelay(input: {
60-
error: MessageV2.APIError
61-
attempt: number
62-
startTime: number
63-
maxDuration?: number
64-
}) {
65-
const elapsed = Date.now() - input.startTime
66-
const maxDuration = input.maxDuration ?? RETRY_MAX_DELAY
67-
const remaining = maxDuration - elapsed
68-
69-
if (remaining <= 0) return undefined
70-
71-
const delay = getRetryDelayInMs(input.error, input.attempt)
72-
if (!delay) return undefined
49+
}
7350

74-
return Math.min(delay, remaining)
51+
return Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS)
7552
}
7653
}

packages/opencode/src/session/status.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export namespace SessionStatus {
1212
type: z.literal("retry"),
1313
attempt: z.number(),
1414
message: z.string(),
15+
next: z.number(),
1516
}),
1617
z.object({
1718
type: z.literal("busy"),

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

Lines changed: 15 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -10,163 +10,52 @@ function apiError(headers?: Record<string, string>): MessageV2.APIError {
1010
}).toObject() as MessageV2.APIError
1111
}
1212

13-
describe("session.retry.getRetryDelayInMs", () => {
14-
test("doubles delay on each attempt when headers missing", () => {
13+
describe("session.retry.delay", () => {
14+
test("caps delay at 30 seconds when headers missing", () => {
1515
const error = apiError()
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])
16+
const delays = Array.from({ length: 10 }, (_, index) => SessionRetry.delay(error, index + 1))
17+
expect(delays).toStrictEqual([2000, 4000, 8000, 16000, 30000, 30000, 30000, 30000, 30000, 30000])
1818
})
1919

2020
test("prefers retry-after-ms when shorter than exponential", () => {
2121
const error = apiError({ "retry-after-ms": "1500" })
22-
expect(SessionRetry.getRetryDelayInMs(error, 4)).toBe(1500)
22+
expect(SessionRetry.delay(error, 4)).toBe(1500)
2323
})
2424

2525
test("uses retry-after seconds when reasonable", () => {
2626
const error = apiError({ "retry-after": "30" })
27-
expect(SessionRetry.getRetryDelayInMs(error, 3)).toBe(30000)
27+
expect(SessionRetry.delay(error, 3)).toBe(30000)
2828
})
2929

3030
test("accepts http-date retry-after values", () => {
3131
const date = new Date(Date.now() + 20000).toUTCString()
3232
const error = apiError({ "retry-after": date })
33-
const delay = SessionRetry.getRetryDelayInMs(error, 1)
34-
expect(delay).toBeGreaterThanOrEqual(19000)
35-
expect(delay).toBeLessThanOrEqual(20000)
33+
const d = SessionRetry.delay(error, 1)
34+
expect(d).toBeGreaterThanOrEqual(19000)
35+
expect(d).toBeLessThanOrEqual(20000)
3636
})
3737

3838
test("ignores invalid retry hints", () => {
3939
const error = apiError({ "retry-after": "not-a-number" })
40-
expect(SessionRetry.getRetryDelayInMs(error, 1)).toBe(2000)
40+
expect(SessionRetry.delay(error, 1)).toBe(2000)
4141
})
4242

4343
test("ignores malformed date retry hints", () => {
4444
const error = apiError({ "retry-after": "Invalid Date String" })
45-
expect(SessionRetry.getRetryDelayInMs(error, 1)).toBe(2000)
45+
expect(SessionRetry.delay(error, 1)).toBe(2000)
4646
})
4747

4848
test("ignores past date retry hints", () => {
4949
const pastDate = new Date(Date.now() - 5000).toUTCString()
5050
const error = apiError({ "retry-after": pastDate })
51-
expect(SessionRetry.getRetryDelayInMs(error, 1)).toBe(2000)
51+
expect(SessionRetry.delay(error, 1)).toBe(2000)
5252
})
5353

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", () => {
54+
test("returns undefined when retry-after exceeds 10 minutes with headers", () => {
6055
const error = apiError({ "retry-after": "50" })
61-
expect(SessionRetry.getRetryDelayInMs(error, 1)).toBe(50000)
56+
expect(SessionRetry.delay(error, 1)).toBe(50000)
6257

6358
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()
59+
expect(SessionRetry.delay(longError, 1)).toBeUndefined()
17160
})
17261
})

0 commit comments

Comments
 (0)