Skip to content

fix: bound every outbound request with a client-side deadline - #83

Merged
justinwlin merged 8 commits into
mainfrom
fix/request-timeouts
Aug 10, 2026
Merged

fix: bound every outbound request with a client-side deadline#83
justinwlin merged 8 commits into
mainfrom
fix/request-timeouts

Conversation

@justinwlin

@justinwlin justinwlin commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #81 (merged, and merged in here).

In plain terms

The problem. When our server asks the Runpod API for something, it waits for an answer with no time limit. Almost always fine — answers come back in well under a second. But if a machine on the other end accepts the connection and then goes quiet (a stuck worker, a load balancer holding the socket), there is nothing that ever says "give up." The request just waits.

On the hosted server that matters, because Vercel kills the whole function at 60 seconds. So instead of a useful error, the user gets a blank 504, and anything the tool had already collected is thrown away. Nothing in the message says which call got stuck.

The fix. Give every request a deadline. If no answer comes back in 30 seconds, stop waiting and return a real error that names the API, the deadline, and what to do next. The user gets an explanation in 30 seconds instead of a blank failure at 60.

The one exception. runsync-endpoint asks the Runpod API to hold the line while a job runs — waiting is the whole point of it. It gets a longer deadline derived from the wait it requested, so the shared 30-second limit does not cut off a call that was working as intended.

The subtle part. Deadlines have to be layered so they fire in the right order — the server-side wait first, our deadline next, the platform's kill last. Get that wrong and the "fix" hangs up on a reply that was already on its way. That is what the 45 / 50 / 60 second ladder below is about.

The gap #81 couldn't close

No outbound request has a client-side timeout. RequestInitLike in src/_shared/http.ts is {method, headers, body?} — no signal — and node-fetch applies no default. A host that accepts the connection then goes silent never errors and never responds, so the call hangs until Vercel reaps it: bare 504, collected work discarded, nothing naming which call stalled.

#81 fixed the budgets that were arithmetically guaranteed to exceed 60s. It can't fix this one: a budget checked between polls never runs if a single poll never returns.

Before #81 After #81 This PR
Slow but responsive always reaped ends ~46s ends ~46s
Wedged connection reaped still reaped ends at the deadline

How

  • withRequestTimeout wraps every call through createHttpClient, following the pattern _shared/credential-check.ts and _shared/backend.ts already use — the general client was the gap.
  • 30s default. Control-plane calls answer sub-second, so this is generous, and it leaves the function half its 60s to serialize a real error instead of being reaped.
  • runsync-endpoint opts out, deriving its deadline from the wait it actually sent plus slack. Since wait is a server-side parameter with its own 90s default, an omitted wait needs the long deadline too — a flat 30s would truncate the common case.
  • RequestTimeoutError names the API, the deadline, and the remedy instead of a bare AbortError.
  • graphqlRequest had the same bug; same wrapper covers it.

Why the cap is 50s

The deadline is a backstop for a wedged socket, not a budget — so it must sit above the wait it brackets:

45s  server-side hold (#81's ?wait= on http)
50s  this backstop — only fires if the response never comes
60s  vercel.json maxDuration, minus the 4s pre-flight that precedes dispatch

Two ways to get this wrong, both found in review and both now asserted by tests that read the real constants:

  • Too low. At 45s the backstop equals the hold, and a reply cannot arrive before the wait elapses plus a round trip — so every slow hosted runsync would abort a response already in flight, turning fix(tools): clamp long-poll budgets to 45s on the hosted HTTP transport #81's job-ID-to-poll path back into an error.
  • Too high. 55s looked right measured from the request, but the platform counts the whole invocation, and the credential pre-flight runs before the tool is even dispatched. 55 + 4 left about a second to write the error.

Tests

11 new: signal on every request; default fires with the right message; per-call override both shortens and lengthens; maxTimeoutMs caps; a network failure isn't relabelled a timeout; end-to-end against a stalling fake server, runsync with and without wait.

557 pass / 0 fail / 0 cancelled on Node 18.20 and 24.2. Type-check, lint, prettier, build clean.

Two failures found during review, and what caused them

The 45s collision. The first draft capped at 45s, matching #81's hold exactly. Neither branch's tests caught it because the failure only exists in the merged state. Mutation-verified now: at 45s the guard fails with "does not clear the 45000ms server-side hold by a round trip."

18 tests cancelled on Node 18 (exit 1) while Node 24 was green. AbortSignal.timeout uses an unref'd timer — correct in production, where a real socket keeps the loop alive, but the test fakes do no I/O, so Node 18's runner drained the loop and cancelled rather than letting the deadline fire ("Promise resolution is still pending but the event loop has already resolved"). Node 20+ holds the loop, so it only reproduced on the oldest lane. Fixed in the fakes with a ref'd keep-alive; production behavior unchanged.

@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
runpod-mcp Ignored Ignored Preview Aug 7, 2026 6:18pm

Request Review

@justinwlin
justinwlin marked this pull request as ready for review August 5, 2026 17:28
@donovanclarke

Copy link
Copy Markdown
Member

@justinwlin Great work 🎉 , just a few comments here:

  1. flashGraphql is still unbounded. The /token handler polls getFlashAuthStatus up to 20 times through this fetch, inside the same 60s Vercel function, "for most of the function's budget." One wedged poll reproduces exactly the bare-504 failure this PR exists to fix, on the OAuth handshake, arguably the worst place to show a blank error, since the user can't even authenticate. Either wire AbortSignal.timeout per poll (a few lines; the poll loop already tolerates per-attempt failure) or scope the PR title/changeset to "every tool request."

  2. stream-job's per-poll deadline defeats its own retry machinery. The poll deadline is Math.max(remaining, 2s), up to 45s (http) or 300s (stdio) for the first poll, while the poll itself asks the server to hold for only 1s (http) or ~10s (stdio default). The loop has MAX_CONSECUTIVE_ERRORS = 5 retry machinery, but a single wedged socket now consumes the entire remaining budget before that machinery can engage. One wedge means 5 minutes on stdio with zero recovery attempts, versus aborting at ~server-hold-plus-slack and retrying on a fresh connection. Math.min(remaining, hold + slack) floored at 2s would preserve the budget invariant and let the retry loop do its job.

  3. The stream-job change is untested. The three new handler tests cover the default and both runsync shapes, but nothing exercises them. A stalled stream poll ending in pollingTimedOut: true with lastError surfaced. Given how much arithmetic sits in that one expression (remaining, the floor, the http clamp), I dont think its covered very well unit test wise.

@justinwlin

Copy link
Copy Markdown
Contributor Author

Reviewed this locally and found two gaps. Both are fixed in 66b3a38.

The per-request deadline didn't bound a whole invocation

Four handlers issue two sequential calls, and each was getting its own fresh 30s:

Tool First call Second call
get-job-status /status/{id} queued-job worker diagnosis
deploy-hub-repo Hub catalog query saveEndpoint mutation
set-endpoint-gpus read current endpoint saveEndpoint mutation
update-endpoint (v2, scalerValue alone) read current scaler PATCH

4s pre-flight + 30s + 30s = 64s against a 60s maxDuration. So the bare 504 this branch set out to replace still happened, on exactly the paths that make two calls. The budget-checked-between-polls argument in the description applies here too: bounding each call individually doesn't bound their sum.

maxTimeoutMs now takes a thunk as well as a number. On http it reports what is left of HTTP_TRANSPORT_BUDGET_MS rather than a fixed ceiling, so the first call may spend 30s and the second is clamped to the remaining 20s. Worst case is 54s including the pre-flight.

That rests on the runtime being rebuilt per request, which src/http.ts does — the single-use server/transport pair. invocationBudget is exported so a test pins the reset, because hoisting registerTools out of the handler to save cold-start time is a tempting change that would leave every request after the first running on the floor. Silent and load-shaped, so it's worth a guard rather than a comment.

A ceiling that has decayed to zero is floored at 1s instead of aborting before the socket opens: no response after 0ms reads as a server fault rather than a budget we had already spent. Only thunks are floored — a static maxTimeoutMs is a number someone chose, and raising it silently would be the surprise.

The queued-job diagnosis also gets an explicit 5s on top of the budget. It decorates a status already in hand and is discarded on any failure, so spending the caller's remaining seconds to enrich a reply that was ready is the wrong trade even when it can't cause a 504.

A timed-out GraphQL read was described as a possible write

The retry advice keys off the method, and GraphQL is always POST on the wire. So a stalled list-gpu-types told the agent the call may have succeeded upstream and to "check with the matching list-/get- tool first" — which is the tool that had just failed. Now only actual mutations carry that warning; the operation keyword is what says whether anything could have been written.

Worth noting the write half is only reachable behind a preceding read in both call sites, so the classifier is pinned directly rather than by stalling a handler's second call.

Verification

568 pass / 0 fail / 0 cancelled on both Node 18.20 and 24.2 (was 558; +10 new). Type-check, lint, build clean.

The new tests assert the clamp arithmetic as pure units rather than waiting out real deadlines, so the suite is still ~2.4s.

One thing left for a follow-up

runsync's slack is now ~4.99s rather than exactly 5s, since its 50s request meets a ceiling that has already decayed a few milliseconds. It still clears the 45s hold, so the in-flight-reply failure mode doesn't return.

But the ladder assertion (HTTP_TRANSPORT_BUDGET_MS >= HTTP_LONG_POLL_BUDGET_MS + RUNSYNC_TIMEOUT_SLACK_MS) holds at exact equality with no margin. A future bump to either constant is worth re-deriving against maxDuration rather than nudging.

node-fetch applies no timeout, so a host that accepts the connection then
goes silent leaves a tool call pending until Vercel reaps the function at
60s — a bare 504 with collected output discarded.

Every request through createHttpClient and graphqlRequest now carries an
AbortSignal with a 30s default, surfacing a named RequestTimeoutError that
reports the API, the deadline, and method-appropriate retry advice.
runsync-endpoint derives its deadline from the wait it requested (90s
server default when omitted), clamped to 50s so the backstop clears #81's
45s server-side hold but stays under the platform limit. On the hosted
transport the ceiling is a thunk over the remaining invocation budget, so
multi-call handlers cannot spend two full deadlines back to back.
@justinwlin
justinwlin marked this pull request as draft August 7, 2026 16:55
@justinwlin
justinwlin force-pushed the fix/request-timeouts branch from 66b3a38 to a39b304 Compare August 7, 2026 16:56
Three review findings.

stream-job set each poll's deadline to the remaining budget, so one wedged
socket spent the whole run (45s hosted, 5 minutes on stdio) in a single
attempt and MAX_CONSECUTIVE_STREAM_ERRORS never engaged — on stdio that was
worse than the flat 30s default it replaced. A poll now gets the hold the
server was asked for plus a round trip: above the hold so a reply in flight
is never aborted, well inside the budget so the loop can reconnect. stdio's
hold is the upstream /stream default (10s), which it never sent as ?wait=.
The loop itself moves out of the handler into collectJobStream.

api/index.ts still made unbounded fetches on the OAuth path — the one flow
where a blank 504 leaves the user with no credential to retry with. Each
flash-backend call now carries its own deadline (MCP_FLASH_TIMEOUT_MS),
clamped to what is left of a 45s /token poll budget, and the install
wizard's key check no longer hangs on "Verifying…". The changeset and the
RequestInitLike comment were claiming coverage those paths did not have;
both now say what is actually true.

Ten tests: the poll deadline observed rather than inferred, both bounds
pinned against the real constants on both transports, a retry loop that
reaches its error cap, and a wedged socket driven end to end through the
real handler to pollingTimedOut with lastError surfaced.
Review pass over the two commits below.

`consecutiveErrors = 0` on a successful poll had no test: six failures
spread across a recovery would have ended a healthy stream and every
existing test still passed, since none of them ever succeeded after a
failure. Covered now, along with the cross-file invariant on
TOKEN_POLL_BUDGET_MS that matched HTTP_LONG_POLL_BUDGET_MS's assertion in
name only, and MCP_FLASH_TIMEOUT_MS parsing, which CLAUDE.md promises
cannot be disabled by a typo.

`MIN_FLASH_GRAPHQL_TIMEOUT_MS` claimed to floor the per-call deadline when
it floors the remaining budget; it is `MIN_TOKEN_POLL_REMAINDER_MS`, and
the disclaimer under it shrinks to nothing. The clamp it fed becomes
`tokenPollDeadlineMs`, the sibling `streamPollTimeoutMs` already was, and
is now unit-testable rather than inline in a call argument.
`FLASH_GRAPHQL_TIMEOUT_MS` is a default, so it says so.

Comments: the stdio poll comment still said "no deadline to race" after
this branch gave it one; `streamPollTimeoutMs` opened by denying the
budget was a bound three lines above a `Math.min` against it; the five
constants exported for tests said nothing about why, unlike the two this
file already exported; and nothing in `handleToken` recorded that a failed
read is deliberately fatal because an APPROVED read consumes the code
upstream — only the test knew.

`collectJobStream` no longer annotates the object `poll` returned, now
that `poll` is caller-supplied. The wedged-socket test waits on the
recorded AbortSignal instead of sleeping 3× the deadline and hoping, and
`useFakeClock` restores `Date.now` through `t.after`, so a throw mid-drive
cannot leave the rest of the file on a frozen clock.

tests/handlers.test.ts is prettier-clean; it was already failing on main,
including on two lines this branch added.
Second review pass.

The reworded `streamPollTimeoutMs` opener said "never past what is left of
the budget", which is false in the one case the floor exists for: below
2s remaining it overshoots by design, as its own test asserts. A summary
the next paragraph has to walk back is the defect it replaced, inverted.
"One read, then we stop" sat above a loop that reads up to 20 times — it
is one FAILED read that ends the poll.

The vercel.json lookup took the first function declaring a maxDuration
rather than the one both budgets run in, so adding a function ahead of
`api/index.ts` would have asserted against a limit governing neither.
tests/http.test.ts already keys it correctly.

Both direct drives of the poll loop awaited a promise that a
non-terminating loop never settles — node:test has no default timeout, so
that stalls the rest of the file instead of failing. `settled()` now
carries the guard `driveStreamJobToBudget` had, and the driver uses it too.

`DEFAULT_FLASH_GRAPHQL_TIMEOUT_MS` is exported so the parsing test stops
restating 10_000, and that test's env mutation is restored in a finally
like its neighbour.

Also removes the node_modules symlink `git add -A` re-tracked in 1c8e2b7.
`.gitignore` lists `node_modules/`, which does not match a symlink, so it
is staged by name rather than caught by the ignore.
…amping the last OAuth read

Correctness review of the two commits below.

`getFlashTimeoutMs` accepted any positive finite number, but
`AbortSignal.timeout` takes a uint32 and throws ERR_OUT_OF_RANGE
synchronously on anything else. `MCP_FLASH_TIMEOUT_MS=10000.5` therefore
500s BOTH /authorize and /token — sign-in impossible — and a value just
above int32 does not throw at all: it warns and fires immediately, so
turning the dial up past ~2.1e9 turns the deadline into 1ms. CLAUDE.md
promises a typo cannot disable the deadline; now only a positive integer
inside the timer's range is honored, and the test drives the value into
AbortSignal.timeout rather than only reading it back.

/token no longer clamps a read down to the last second of its budget. An
APPROVED read consumes the code atomically upstream, so a read abandoned
mid-flight can burn it and leave the user with "already used" on retry.
The loop now declines to start a read below MIN_TOKEN_POLL_REMAINDER_MS
(5s) and falls through to authorization_pending, which is retryable and
consumes nothing. It also sleeps the full interval every time: the old
"skip the sleep if it would spend the rest of the budget" re-read the
backend back to back for the final ~2s, which bought the user no extra
approval time.

stream-job's transport choice moves into `streamPollPlan`, which bundles
budget, hold and ?wait= — replacing the stdio hold with the http constant
left the whole suite green while giving every stdio poll a 6s deadline
against a server holding 10s, i.e. aborting a reply in flight five times
and ending the run at the error cap in ~30s. Bundled with the budget, that
mistake now fails the stdio budget test as well as its own.

`lastError` is cleared on a successful poll, so a blip in the first second
is no longer reported as the trailing error five minutes later, and the
error-cap exit carries the same resume guidance the budget exit does —
chunks with no way forward is not an answer.

`verifyApiKey` takes its deadline as a parameter so the wizard's fetch,
which the changeset advertises as bounded, is actually covered.
…nging

node:test has no default timeout, so without the deadline this test held
the file open until CI killed the job. Same treatment the OAuth stall test
already has.
@justinwlin

justinwlin commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

All three findings fixed, plus four more from a self-review pass. Rebased onto main (674fa2c).

Your findings

1. The stream-job deadline was worse than what it replaced. Math.max(remaining, MIN) capped each poll at the budget — the one value that guarantees the retry loop never runs. Now Math.min(remaining, hold + slack), floored:

hold deadline budget attempts before the cap
http ?wait=1000 6s 45s 7
stdio server default 15s 300s 20

RUNSYNC_TIMEOUT_SLACK_MS is reused as UPSTREAM_HOLD_SLACK_MS (runsync is no longer its only caller), and stdio got the sibling constant you called for — STREAM_UPSTREAM_DEFAULT_WAIT_MS = 10_000, the hold an empty /stream takes when no ?wait= is sent. The loop moved into collectJobStream.

2. flashGraphql. Fixed rather than scoped down — the OAuth handshake is the one place a blank 504 is unrecoverable, since the user has no credential yet to retry with. Every flash call now carries a deadline (MCP_FLASH_TIMEOUT_MS, documented), and /token stops at a 45s poll budget: 20 attempts × 2s of sleeping plus 20 round trips can reach maxDuration on latency alone. wizard.ts got the same treatment. Both overclaiming statements — the changeset opener and the RequestInitLike comment — now say what is actually covered.

3. Tests. Ten, in three layers: the deadline observed (collectJobStream is exported, so a test records what the loop asks for); both bounds pinned against the real constants on both transports; and a wedged socket driven end to end with your stall(ms, signal) helper, ending pollingTimedOut with lastError after two polls — the retry the old code could never reach.

Found while reviewing this

A bug I introduced. AbortSignal.timeout takes a uint32; my check only tested isFinite && > 0. MCP_FLASH_TIMEOUT_MS=10000.5 threw ERR_OUT_OF_RANGE500 on both OAuth routes, and 3000000000 silently became a 1 ms deadline.

/token could burn an authorization code. An APPROVED read consumes it upstream, and the clamp could shrink the last read to 1s. It now won't start a read below a 5s remainder, answering authorization_pending — retryable, consumes nothing.

A regression the suite was blind to. Giving stdio the http hold kept 660/660 green while breaking every stdio poll (6s deadline against a 10s hold → aborts a reply in flight → dies at the error cap in ~30s). The transport choice now goes through streamPollPlan(hosted), which bundles budget + hold + ?wait=, so getting it wrong fails the budget test too.

Nothing exercised the real fetch. Every client test injects a fake, and defaultFetch = fetch as HttpFetch is a cast — nothing checked the init object this PR adds signal to against node-fetch's own RequestInit. Had node-fetch ignored that field, every deadline here would be inert in production with the suite fully green. One test now drives the real client over a real socket, against a server that answers and one that never does.

Node 18 couldn't load the new test filewizard.ts@clack/promptsstyleText from node:util, which lands in Node 20. verifyApiKey moved to a clack-free module.

Smaller: lastError is cleared on a successful poll; the error-cap exit gives the same resume guidance the budget exit does; the vercel.json lookup is keyed to api/index.ts rather than the first function declaring a maxDuration; both stall tests carry explicit timeouts, since without a deadline they hang rather than fail.

Not taken: retrying a timed-out pre-approval poll. The read may already have consumed the code, so the retry would say "already used" — worse than the honest failure. That reasoning now lives at the call site.

Verification

675 tests, 667 pass / 0 fail / 8 skipped, on Node 18.20.8 and 24.2. Type-check, lint, build clean.

Mutation-verified rather than assumed — each of these turns the suite red:

reintroduce caught by
streamPollTimeoutMsremainingMs 4 tests
stdio hold → http constant picks budget, hold and ?wait= together per transport
loosen the timeout parse ignores a MCP_FLASH_TIMEOUT_MS that is not a positive number
drop consecutiveErrors = 0 counts only CONSECUTIVE failures
drop lastError = undefined reports only a trailing error…
drop signal in flashGraphql the OAuth stall test
drop signal in createHttpClient aborts a wedged socket and names it…
drop the wizard signal reports a stalled host as "check failed"…

CI Node 18 failed to LOAD tests/install-clients.test.ts: importing
wizard.ts pulls in @clack/prompts, and @clack/core imports `styleText`
from node:util, which does not exist until Node 20. The whole file died
before a single test ran — Node 20, 22 and Windows were all green, and so
was my local Node 24.

verifyApiKey moves to src/install/verify-key.ts, which has no interactive
dependencies, and the wizard imports it from there. Behaviour is
unchanged; the deadline is now reachable from a test on every supported
runtime.

Verified on Node 18.20.8 as well as 24.2: 673 tests, 665 pass, 0 fail.
@justinwlin
justinwlin marked this pull request as ready for review August 7, 2026 18:14
Every client test injects a fake fetch, and `defaultFetch = fetch as
HttpFetch` (src/tools/runtime.ts) is a cast — nothing checks the init
object this release added `signal` to against node-fetch's own
RequestInit. If node-fetch ignored that field, every deadline here would
be inert in production and the whole suite would still be green.

So one test builds the real client over a real socket: a server that
answers, and one that accepts and never does. Removing `signal` from
createHttpClient now hangs this test instead of passing.
@justinwlin
justinwlin merged commit f20d987 into main Aug 10, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants