From 8b19c6c7e4a62808335b9f5f74c02be5d5a349bd Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 17 Nov 2025 11:30:55 -0500 Subject: [PATCH] better retry display --- .opencode/opencode.jsonc | 2 +- .../src/cli/cmd/tui/routes/session/index.tsx | 33 +++- packages/opencode/src/session/processor.ts | 19 ++- packages/opencode/src/session/retry.ts | 69 +++------ packages/opencode/src/session/status.ts | 1 + packages/opencode/test/session/retry.test.ts | 141 ++---------------- packages/sdk/js/src/gen/types.gen.ts | 64 +++++--- 7 files changed, 118 insertions(+), 211 deletions(-) diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 02278ce3..97b5cef2 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -4,7 +4,7 @@ "provider": { "opencode": { "options": { - // "baseURL": "http://localhost:8080" + "baseURL": "http://localhost:8080", }, }, }, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 192123e0..40ecfe4e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -6,6 +6,8 @@ import { For, Match, on, + onCleanup, + onMount, Show, Switch, useContext, @@ -972,11 +974,32 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las {Locale.titlecase(props.message.mode)} - - - {(status() as any).message} [attempt #{(status() as any).attempt}] - - + {(() => { + const retry = createMemo(() => { + const s = status() + if (s.type !== "retry") return + return s + }) + const [seconds, setSeconds] = createSignal(0) + onMount(() => { + const timer = setInterval(() => { + const next = retry()?.next + if (next) setSeconds(Math.round((next - Date.now()) / 1000)) + }, 1000) + + onCleanup(() => { + clearInterval(timer) + }) + }) + return ( + + + {retry()!.message} [attempt #{retry()!.attempt} + {seconds() > 0 ? `, retrying in ${seconds()}s` : ""}] + + + ) + })()} {}) - continue - } + const delay = SessionRetry.delay(error, attempt) + SessionStatus.set(input.sessionID, { + type: "retry", + attempt, + message: error.data.message, + next: Date.now() + delay, + }) + await SessionRetry.sleep(delay, input.abort).catch(() => {}) + continue } input.assistantMessage.error = error Bus.publish(Session.Event.Error, { diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 68f33d95..ae044013 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -4,7 +4,7 @@ import { MessageV2 } from "./message-v2" export namespace SessionRetry { export const RETRY_INITIAL_DELAY = 2000 export const RETRY_BACKOFF_FACTOR = 2 - export const RETRY_MAX_DELAY = 600_000 // 10 minutes + export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds export async function sleep(ms: number, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { @@ -20,57 +20,34 @@ export namespace SessionRetry { }) } - export function getRetryDelayInMs(error: MessageV2.APIError, attempt: number) { - const delay = iife(() => { - const headers = error.data.responseHeaders - if (headers) { - const retryAfterMs = headers["retry-after-ms"] - if (retryAfterMs) { - const parsedMs = Number.parseFloat(retryAfterMs) - if (!Number.isNaN(parsedMs)) { - return parsedMs - } + export function delay(error: MessageV2.APIError, attempt: number) { + const headers = error.data.responseHeaders + if (headers) { + const retryAfterMs = headers["retry-after-ms"] + if (retryAfterMs) { + const parsedMs = Number.parseFloat(retryAfterMs) + if (!Number.isNaN(parsedMs)) { + return parsedMs } + } - const retryAfter = headers["retry-after"] - if (retryAfter) { - const parsedSeconds = Number.parseFloat(retryAfter) - if (!Number.isNaN(parsedSeconds)) { - // convert seconds to milliseconds - return Math.ceil(parsedSeconds * 1000) - } - // Try parsing as HTTP date format - const parsed = Date.parse(retryAfter) - Date.now() - if (!Number.isNaN(parsed) && parsed > 0) { - return Math.ceil(parsed) - } + const retryAfter = headers["retry-after"] + if (retryAfter) { + const parsedSeconds = Number.parseFloat(retryAfter) + if (!Number.isNaN(parsedSeconds)) { + // convert seconds to milliseconds + return Math.ceil(parsedSeconds * 1000) + } + // Try parsing as HTTP date format + const parsed = Date.parse(retryAfter) - Date.now() + if (!Number.isNaN(parsed) && parsed > 0) { + return Math.ceil(parsed) } } return RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1) - }) + } - // dont retry if wait is too far from now - if (delay > RETRY_MAX_DELAY) return undefined - - return delay - } - - export function getBoundedDelay(input: { - error: MessageV2.APIError - attempt: number - startTime: number - maxDuration?: number - }) { - const elapsed = Date.now() - input.startTime - const maxDuration = input.maxDuration ?? RETRY_MAX_DELAY - const remaining = maxDuration - elapsed - - if (remaining <= 0) return undefined - - const delay = getRetryDelayInMs(input.error, input.attempt) - if (!delay) return undefined - - return Math.min(delay, remaining) + return Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS) } } diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index ecac222f..25936f51 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -12,6 +12,7 @@ export namespace SessionStatus { type: z.literal("retry"), attempt: z.number(), message: z.string(), + next: z.number(), }), z.object({ type: z.literal("busy"), diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 27148e2a..cf2e55ac 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -10,163 +10,52 @@ function apiError(headers?: Record): MessageV2.APIError { }).toObject() as MessageV2.APIError } -describe("session.retry.getRetryDelayInMs", () => { - test("doubles delay on each attempt when headers missing", () => { +describe("session.retry.delay", () => { + test("caps delay at 30 seconds when headers missing", () => { const error = apiError() - const delays = Array.from({ length: 10 }, (_, index) => SessionRetry.getRetryDelayInMs(error, index + 1)) - expect(delays).toStrictEqual([2000, 4000, 8000, 16000, 32000, 64000, 128000, 256000, 512000, undefined]) + const delays = Array.from({ length: 10 }, (_, index) => SessionRetry.delay(error, index + 1)) + 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.getRetryDelayInMs(error, 4)).toBe(1500) + expect(SessionRetry.delay(error, 4)).toBe(1500) }) test("uses retry-after seconds when reasonable", () => { const error = apiError({ "retry-after": "30" }) - expect(SessionRetry.getRetryDelayInMs(error, 3)).toBe(30000) + expect(SessionRetry.delay(error, 3)).toBe(30000) }) test("accepts http-date retry-after values", () => { const date = new Date(Date.now() + 20000).toUTCString() const error = apiError({ "retry-after": date }) - const delay = SessionRetry.getRetryDelayInMs(error, 1) - expect(delay).toBeGreaterThanOrEqual(19000) - expect(delay).toBeLessThanOrEqual(20000) + const d = SessionRetry.delay(error, 1) + expect(d).toBeGreaterThanOrEqual(19000) + expect(d).toBeLessThanOrEqual(20000) }) test("ignores invalid retry hints", () => { const error = apiError({ "retry-after": "not-a-number" }) - expect(SessionRetry.getRetryDelayInMs(error, 1)).toBe(2000) + expect(SessionRetry.delay(error, 1)).toBe(2000) }) test("ignores malformed date retry hints", () => { const error = apiError({ "retry-after": "Invalid Date String" }) - expect(SessionRetry.getRetryDelayInMs(error, 1)).toBe(2000) + expect(SessionRetry.delay(error, 1)).toBe(2000) }) test("ignores past date retry hints", () => { const pastDate = new Date(Date.now() - 5000).toUTCString() const error = apiError({ "retry-after": pastDate }) - expect(SessionRetry.getRetryDelayInMs(error, 1)).toBe(2000) + expect(SessionRetry.delay(error, 1)).toBe(2000) }) - test("returns undefined when delay exceeds 10 minutes", () => { - const error = apiError() - expect(SessionRetry.getRetryDelayInMs(error, 10)).toBeUndefined() - }) - - test("returns undefined when retry-after exceeds 10 minutes", () => { + test("returns undefined when retry-after exceeds 10 minutes with headers", () => { const error = apiError({ "retry-after": "50" }) - expect(SessionRetry.getRetryDelayInMs(error, 1)).toBe(50000) + expect(SessionRetry.delay(error, 1)).toBe(50000) const longError = apiError({ "retry-after-ms": "700000" }) - expect(SessionRetry.getRetryDelayInMs(longError, 1)).toBeUndefined() - }) -}) - -describe("session.retry.getBoundedDelay", () => { - test("returns full delay when under time budget", () => { - const error = apiError() - const startTime = Date.now() - const delay = SessionRetry.getBoundedDelay({ - error, - attempt: 1, - startTime, - }) - expect(delay).toBe(2000) - }) - - test("returns remaining time when delay exceeds budget", () => { - const error = apiError() - const startTime = Date.now() - 598_000 // 598 seconds elapsed, 2 seconds remaining - const delay = SessionRetry.getBoundedDelay({ - error, - attempt: 1, - startTime, - }) - expect(delay).toBeGreaterThanOrEqual(1900) - expect(delay).toBeLessThanOrEqual(2100) - }) - - test("returns undefined when time budget exhausted", () => { - const error = apiError() - const startTime = Date.now() - 600_000 // exactly 10 minutes elapsed - const delay = SessionRetry.getBoundedDelay({ - error, - attempt: 1, - startTime, - }) - expect(delay).toBeUndefined() - }) - - test("returns undefined when time budget exceeded", () => { - const error = apiError() - const startTime = Date.now() - 700_000 // 11+ minutes elapsed - const delay = SessionRetry.getBoundedDelay({ - error, - attempt: 1, - startTime, - }) - expect(delay).toBeUndefined() - }) - - test("respects custom maxDuration", () => { - const error = apiError() - const startTime = Date.now() - 58_000 // 58 seconds elapsed - const delay = SessionRetry.getBoundedDelay({ - error, - attempt: 1, - startTime, - maxDuration: 60_000, // 1 minute max - }) - expect(delay).toBeGreaterThanOrEqual(1900) - expect(delay).toBeLessThanOrEqual(2100) - }) - - test("caps exponential backoff to remaining time", () => { - const error = apiError() - const startTime = Date.now() - 595_000 // 595 seconds elapsed, 5 seconds remaining - const delay = SessionRetry.getBoundedDelay({ - error, - attempt: 5, // would normally be 32 seconds - startTime, - }) - expect(delay).toBeGreaterThanOrEqual(4900) - expect(delay).toBeLessThanOrEqual(5100) - }) - - test("respects server retry-after within budget", () => { - const error = apiError({ "retry-after": "30" }) - const startTime = Date.now() - 550_000 // 550 seconds elapsed, 50 seconds remaining - const delay = SessionRetry.getBoundedDelay({ - error, - attempt: 1, - startTime, - }) - expect(delay).toBe(30000) - }) - - test("caps server retry-after to remaining time", () => { - const error = apiError({ "retry-after": "30" }) - const startTime = Date.now() - 590_000 // 590 seconds elapsed, 10 seconds remaining - const delay = SessionRetry.getBoundedDelay({ - error, - attempt: 1, - startTime, - }) - expect(delay).toBeGreaterThanOrEqual(9900) - expect(delay).toBeLessThanOrEqual(10100) - }) - - test("returns undefined when getRetryDelayInMs returns undefined", () => { - const error = apiError() - const startTime = Date.now() - const delay = SessionRetry.getBoundedDelay({ - error, - attempt: 10, // exceeds RETRY_MAX_DELAY - startTime, - }) - expect(delay).toBeUndefined() + expect(SessionRetry.delay(longError, 1)).toBeUndefined() }) }) diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 2309f8b7..aada8e58 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -367,6 +367,15 @@ export type CompactionPart = { export type Part = | TextPart + | { + id: string + sessionID: string + messageID: string + type: "subtask" + prompt: string + description: string + agent: string + } | ReasoningPart | FilePart | ToolPart @@ -425,6 +434,28 @@ export type EventPermissionReplied = { } } +export type SessionStatus = + | { + type: "idle" + } + | { + type: "retry" + attempt: number + message: string + next: number + } + | { + type: "busy" + } + +export type EventSessionStatus = { + type: "session.status" + properties: { + sessionID: string + status: SessionStatus + } +} + export type EventSessionCompacted = { type: "session.compacted" properties: { @@ -476,27 +507,6 @@ export type EventCommandExecuted = { } } -export type SessionStatus = - | { - type: "idle" - } - | { - type: "retry" - attempt: number - message: string - } - | { - type: "busy" - } - -export type EventSessionStatus = { - type: "session.status" - properties: { - sessionID: string - status: SessionStatus - } -} - export type EventSessionIdle = { type: "session.idle" properties: { @@ -639,11 +649,11 @@ export type Event = | EventMessagePartRemoved | EventPermissionUpdated | EventPermissionReplied + | EventSessionStatus | EventSessionCompacted | EventFileEdited | EventTodoUpdated | EventCommandExecuted - | EventSessionStatus | EventSessionIdle | EventSessionCreated | EventSessionUpdated @@ -1248,6 +1258,14 @@ export type AgentPartInput = { } } +export type SubtaskPartInput = { + id?: string + type: "subtask" + prompt: string + description: string + agent: string +} + export type Command = { name: string description?: string @@ -2142,7 +2160,7 @@ export type SessionPromptData = { tools?: { [key: string]: boolean } - parts: Array + parts: Array } path: { /**