fix(ui): bound OpenCode read requests so half-open sockets cannot freeze bootstrap (#2470)
The SDK client fetch wrapper now applies a 30s timeout to non-streaming reads. Without it, a socket that neither resolves nor rejects keeps the directory bootstrap concurrency slot busy forever and the UI stays on "loading sessions". Long-lived streams (POST prompts, the /event SSE) are explicitly excluded so they are not cut off mid-flight. The normalized "request timed out" error is added to the retry allowlist alongside undici's "terminated" (the exact failure observed in #2470 when undici tears down a half-open upstream connection); both are transient while the managed OpenCode process restarts. Caller- initiated aborts keep their original error shape so a user-cancelled request is not retried. Tests cover: GET timeout fires after the bound, POST is not timed out, /event SSE is not timed out, caller abort wins, AbortError is not retried, and the SDK normalized error is retried 3x.
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { retry } from './retry';
|
||||
|
||||
describe('retry transient classification (#2470)', () => {
|
||||
test("'terminated' (undici half-open socket teardown) is retried", async () => {
|
||||
let attempts = 0;
|
||||
await expect(
|
||||
retry(async () => {
|
||||
attempts += 1;
|
||||
throw new TypeError('terminated');
|
||||
}),
|
||||
).rejects.toThrow('terminated');
|
||||
expect(attempts).toBe(3);
|
||||
});
|
||||
|
||||
test("normalized 'request timed out' (SDK read timeout) is retried", async () => {
|
||||
let attempts = 0;
|
||||
await expect(
|
||||
retry(async () => {
|
||||
attempts += 1;
|
||||
throw new Error('OpenCode request timed out after 30000ms');
|
||||
}),
|
||||
).rejects.toThrow('request timed out');
|
||||
expect(attempts).toBe(3);
|
||||
});
|
||||
|
||||
test('caller-initiated abort (AbortError) is NOT retried', async () => {
|
||||
let attempts = 0;
|
||||
await expect(
|
||||
retry(async () => {
|
||||
attempts += 1;
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}),
|
||||
).rejects.toThrow('Aborted');
|
||||
expect(attempts).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,10 @@ export interface RetryOptions {
|
||||
retryIf?: (error: unknown) => boolean
|
||||
}
|
||||
|
||||
// undici tears down half-open upstream connection with `TypeError: terminated`
|
||||
// (exact failure from the #2470 logs); the SDK client also rejects reads with
|
||||
// normalized "request timed out" error after OPENCODE_REQUEST_TIMEOUT_MS.
|
||||
// Both are transient — managed process may be restarting.
|
||||
const TRANSIENT_MESSAGES = [
|
||||
"load failed",
|
||||
"network connection was lost",
|
||||
@@ -18,6 +22,8 @@ const TRANSIENT_MESSAGES = [
|
||||
"opencode api unavailable",
|
||||
"503",
|
||||
"502",
|
||||
"terminated",
|
||||
"request timed out",
|
||||
]
|
||||
|
||||
function isTransientError(error: unknown): boolean {
|
||||
|
||||
Reference in New Issue
Block a user