no timeouts on a sidecar request
a 42-minute recording was transcribed successfully and the result thrown away. whisperx logged POST /inference 200 OK; the platform had already answered 502 "transcribe sidecar unreachable" four minutes in. the work was done, nobody received it, and the error blamed the machine that had just finished the job. timeoutSeconds only ever governed the INBOUND leg — how long Bun holds the caller's connection. the proxy's own fetch to the sidecar had nothing on it, so it took Bun's built-in ceiling no matter what a plugin declared. transcribe declares 1800 and still gave up at four minutes. both legs now: `timeout: false` on the outbound fetch, unconditionally, and the inbound timeout applied to every proxied request with 0 — no timeout — as the default. the owner's call, and the right one: long requests will keep arriving from everywhere and every ceiling is a number somebody guessed. an AbortSignal is deliberately NOT used. it cannot raise Bun's ceiling, only lower it, and it aborts the response BODY too — this proxy carries video, and jellyfin declares 3600, so a signal would have cut a film at exactly one hour of playback. i wrote that version first. and a timeout no longer reports as "unreachable". one means the sidecar is not there, the other means it is there and still working. that sentence sent an hour of debugging at the wrong machine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -31,10 +31,16 @@ export type SidecarProxyParams = {
|
||||
/** Extra work when the sidecar reports in. Rare — most sidecars only need the port remembered. */
|
||||
onRegister?: (port: number) => void;
|
||||
/**
|
||||
* Per-request idle timeout, seconds. The main server drops a proxied connection after 60s with no bytes
|
||||
* flowing, which is wrong for a sidecar route that thinks before it answers (a from-scratch music
|
||||
* reindex holds the socket open for minutes). Applied to the whole prefix: the proxy must not know which
|
||||
* of a sidecar's routes are slow.
|
||||
* Per-request idle timeout, seconds — or omitted, which now means NO timeout and is the default.
|
||||
*
|
||||
* The main server drops an ordinary connection after 60s with no bytes flowing, which is wrong for
|
||||
* every sidecar route that thinks before it answers. A from-scratch music reindex holds the socket for
|
||||
* minutes; transcribing a 42-minute recording holds it for longer than that.
|
||||
*
|
||||
* The owner's call on 2026-08-24, after a transcription that SUCCEEDED was thrown away by a proxy that
|
||||
* gave up on it: there should be no timeouts on these requests at all, because long ones will keep
|
||||
* arriving from everywhere and every ceiling is a number somebody guessed. A plugin may still name one
|
||||
* if it genuinely wants a bound; nothing does by default.
|
||||
*/
|
||||
timeoutSeconds?: number;
|
||||
/**
|
||||
@@ -127,15 +133,19 @@ export function createSidecarProxy({
|
||||
const subpath = url.pathname.slice(prefix.length) || '/';
|
||||
const target = `${baseUrl}${subpath}${url.search}`;
|
||||
|
||||
if (timeoutSeconds) {
|
||||
// Bun passes the server as Hono's env. Older Bun has no per-request timeout; the request still
|
||||
// completes, it just risks the default idle drop.
|
||||
const server = ctx.env as { timeout?: (req: Request, seconds: number) => void } | undefined;
|
||||
try {
|
||||
server?.timeout?.(ctx.req.raw, timeoutSeconds);
|
||||
} catch {
|
||||
/* no per-request timeout available */
|
||||
}
|
||||
/*
|
||||
The INBOUND leg: how long Bun holds the caller's connection.
|
||||
|
||||
Applied to every proxied request now, not only ones that declared a number — `0` disables the idle
|
||||
timeout, and no timeout is the default for anything going through a sidecar. Bun passes the server
|
||||
as Hono's env; an older Bun without per-request timeouts still completes the request, it just risks
|
||||
the 60s default drop.
|
||||
*/
|
||||
const server = ctx.env as { timeout?: (req: Request, seconds: number) => void } | undefined;
|
||||
try {
|
||||
server?.timeout?.(ctx.req.raw, timeoutSeconds ?? 0);
|
||||
} catch {
|
||||
/* no per-request timeout available */
|
||||
}
|
||||
|
||||
const method = ctx.req.method;
|
||||
@@ -198,11 +208,27 @@ export function createSidecarProxy({
|
||||
: ctx.req.raw.body
|
||||
: undefined;
|
||||
|
||||
const init: RequestInit & { duplex?: 'half' } = {
|
||||
/*
|
||||
`timeoutSeconds` has to govern BOTH hops, and it only governed one.
|
||||
|
||||
Above, it extends how long Bun holds the INBOUND connection open. This is the outbound leg, and it
|
||||
had nothing — so it took Bun's built-in fetch ceiling of about 300s regardless of what a plugin
|
||||
declared. transcribe declares 1800 for exactly this case and still gave up on a 42-minute recording
|
||||
at four minutes, while the sidecar went on and finished it. The work was done and thrown away.
|
||||
|
||||
`timeout: false`, unconditionally. An AbortSignal is specifically NOT wanted: it cannot raise Bun's
|
||||
ceiling anyway (only lower it — the trap `plugins/transcribe/sidecar/transcribe.ts` documents from
|
||||
hitting it first), and it aborts the response BODY as well as the request. This proxy carries video,
|
||||
and jellyfin declares 3600, so a signal would have cut a film at exactly one hour of playback. A
|
||||
streaming response has its headers in milliseconds and then runs for as long as somebody watches.
|
||||
*/
|
||||
type BunRequestInit = RequestInit & { duplex?: 'half'; timeout?: boolean | number };
|
||||
const init: BunRequestInit = {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
duplex: 'half',
|
||||
timeout: false,
|
||||
};
|
||||
|
||||
let upstream: Response;
|
||||
@@ -211,6 +237,15 @@ export function createSidecarProxy({
|
||||
} catch (err) {
|
||||
// Target path only — never the body, which may carry a passphrase or a credential.
|
||||
console.error(`[${name}] proxy fetch failed`, { target, error: String(err) });
|
||||
/*
|
||||
A timeout is not "unreachable", and saying so sent an hour of debugging at the wrong machine: the
|
||||
sidecar had answered 200 and finished the job. One means the sidecar is not there; the other means
|
||||
it is there and slower than the plugin said to wait. 504 rather than 502, and the number named, so
|
||||
the next person reads "this took longer than 1800s" instead of "the thing I can see running is
|
||||
not running".
|
||||
*/
|
||||
const timedOut = err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError');
|
||||
if (timedOut) return ctx.text(`${name} sidecar did not answer in time`, 504);
|
||||
return ctx.text(`${name} sidecar unreachable`, 502);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user