feat: callModel per-turn stall timeouts (DEV-723 2/5) - #773
Conversation
…s (DEV-723 phase 2)
Adds the opt-in 'timeout' option to callModel:
client.callModel({
model, input,
timeout: { firstContentMs: 15_000, contentIntervalMs: 30_000 },
})
Integration points:
- CallModelInput gains 'timeout' (client-only field, stripped from the
API request in resolveAsyncFunctions and resolveRequestForContext).
- ModelResult.createTurnWatchContext() builds a per-turn AbortController
whose signal is merged (combineSignals) with the caller's signal and,
when the caller set none, a replicated timeoutMs signal - configuring
the watchdog never silently disables existing timeout behavior. The
watchdog's onStall aborts the turn's HTTP request, tearing down the
hung connection.
- All three responsesSend call sites (initial request, follow-up tool
turns, approval-resume continuation) route through the watch context,
so deadlines re-arm independently for every turn, matching the
per-step semantics of vercel/ai firstChunkMs.
- ToolEventBroadcaster.complete() is now idempotent (first call wins):
the pipe's error completion must not be overwritten by the later
unconditional .finally() completion, or concurrent consumers would
see a clean close instead of the stall error.
- StreamStalledError, StreamStallPhase, and StreamTimeoutOptions are
exported from the package root via the sdk.ts custom region.
Tests drive the real transport path (HTTPClient fetcher serving
scripted SSE bytes through responsesSend -> EventStream -> ModelResult):
stall before content, keepalive-comment immunity, healthy streams,
default-off, mid-generation stall, multi-consumer rejection, per-turn
re-arm across a tool loop (turn 1 passes, turn 2 stalls), and caller
abort signals surviving signal merging.
| const timeoutMs = baseOptions?.timeoutMs ?? this.options.client._options.timeoutMs; | ||
| const timeoutSignal = | ||
| !callerSignal && timeoutMs && timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : null; |
There was a problem hiding this comment.
🟡 Overall request time limit is no longer refreshed for retry attempts once stall detection is enabled
The per-attempt request time limit is replaced by a single shared countdown started once (AbortSignal.timeout(timeoutMs) at src/lib/model-result.ts:980-981) instead of being restarted for each retry, so a retried request can be cut off almost immediately.
Impact: Users who enable stall detection and rely on automatic retries can see requests fail with a timeout even though each individual attempt was well within the allowed time.
Why the replicated timeout diverges from the generated retry loop
ClientSDK._createRequest only records context.timeoutMs when no signal was supplied (src/lib/sdks.ts:230-232), and ClientSDK._do then creates a fresh AbortSignal.timeout(timeoutMs) inside the retry callback, i.e. once per attempt (src/lib/sdks.ts:282-289).
createTurnWatchContext always supplies a signal when the watchdog is active, so context.timeoutMs stays unset and _do's per-attempt timer never runs. The replicated AbortSignal.timeout(timeoutMs) is created a single time, before the request is issued, and is merged into the signal used for every retry attempt of that turn. With the default retry config (backoff, retry on 5XX) a turn that gets a 502 after most of the budget has elapsed will have its retry aborted with a timeout error, whereas without timeout configured the retry would have received a full fresh budget.
A secondary, smaller divergence: responsesSend computes the effective value as options?.timeoutMs || client._options.timeoutMs || -1 (src/funcs/responsesSend.ts:282) while the new code uses ??, so an explicit timeoutMs: 0 in the caller's options no longer falls back to the client-level value.
Prompt for agents
In ModelResult.createTurnWatchContext (src/lib/model-result.ts), the code replicates the SDK's timeoutMs behaviour by creating a single AbortSignal.timeout up front and merging it into the request signal. However, the generated client applies its timeout per retry attempt: ClientSDK._createRequest only sets context.timeoutMs when no signal is present (src/lib/sdks.ts:230-232) and ClientSDK._do constructs a fresh AbortSignal.timeout inside the retry callback (src/lib/sdks.ts:282-289). Because the watchdog always supplies a signal, the per-attempt timer is disabled and the replicated one becomes a cumulative deadline spanning all retries of the turn, which can abort a retry that would otherwise have had a full budget. Consider a mechanism that preserves per-attempt semantics — e.g. passing the turn abort signal through a path that still lets _do arm its own per-attempt timeout, or merging the turn signal inside a request hook rather than as options.signal. Also note the effective-value computation uses ?? whereas responsesSend uses || (src/funcs/responsesSend.ts:282), which differs when timeoutMs is 0.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Perry's Review
Verdict: 💬 Comments / questions
Well-structured wiring of the phase-1 watchdog into callModel. Per-turn abort re-arming, signal merging, and the ToolEventBroadcaster.complete() idempotency fix are all correct. The timeout field is properly stripped from the outgoing API request in both resolution paths. Three comments below — the timeout-retry interaction is the one I'd like to see addressed or explicitly acknowledged before this lands.
Full review
What's good
- Per-turn abort re-arming: Each of the three
responsesSendsites (initial, follow-up, approval-resume) gets its ownAbortControllerviacreateTurnWatchContext(). Deadlines are correctly independent per turn — a stall in turn N+1 isn't masked by a generousfirstContentMsfrom turn 1. - Signal merging:
combineSignals(turnAbort.signal, callerSignal, timeoutSignal)correctly preserves the caller's abort (surfaces as the caller's reason, not a stall) and replicatestimeoutMsbehavior so enabling the watchdog never silently disables the existing timeout. - Broadcaster idempotency: Making
ToolEventBroadcaster.complete()first-call-wins is the right fix — without it, the.finally()completion instartTurnBroadcasterExecutionwould overwrite the stall error recorded by the pipe's.catch()handler, and late-joining consumers would see a clean close instead of the error. - Field stripping:
timeoutis correctly stripped from the API request in bothresolveAsyncFunctions(clientOnlyFieldsset) andresolveRequestForContext(destructuringtimeout: _t). - Tests: 8 integration tests driving the real transport path (scripted SSE →
HTTPClient→responsesSend→EventStream→ModelResult) — stall before content, keepalive immunity, healthy streams, default-off, mid-generation stall, multi-consumer rejection, per-turn re-arm, caller-abort merging. Solid coverage of the core scenarios.
Comments
-
Per-attempt timeout becomes cumulative across retries (see inline comment on
model-result.ts:979). The SDK's_domethod normally creates a freshAbortSignal.timeout(timeoutMs)inside the retry callback — each retry attempt gets a full timeout budget. When the watchdog is active,createTurnWatchContextsuppliesoptions.signal, which causes_createRequestto skip settingcontext.timeoutMs, disabling_do's per-attempt timer entirely. The replicatedAbortSignal.timeout(timeoutMs)created increateTurnWatchContextis a single timer started before the first request — it counts down across ALL retry attempts of the turn. A request that gets a 502 after most of the budget has elapsed will have its retry cut off by the cumulative timeout, whereas without the watchdog the retry would have gotten a fresh budget. Since the default retry config isbackoffon5XX, this affects the default configuration whenevertimeoutMsis set. Consider letting_doarm its own per-attempt timeout (e.g., via a request hook or by not settingoptions.signalwhen onlytimeoutMsis active), or document thattimeoutMsbecomes a per-turn cumulative budget when the watchdog is enabled. -
??vs||fortimeoutMs: 0(same line).createTurnWatchContextuses??(baseOptions?.timeoutMs ?? this.options.client._options.timeoutMs), whileresponsesSenduses||(options?.timeoutMs || client._options.timeoutMs || -1). An explicittimeoutMs: 0falls back to the client default inresponsesSendbut stays0(no timeout signal) in the watchdog path. This is arguably more correct (0should mean "no timeout"), but it's an inconsistency — consider aligning the operator. -
Test gap: multi-turn stall with concurrent stream consumers (see inline comment on test file). The "propagates the stall to all concurrent stream consumers" test covers the no-tools path (direct stream →
ReusableReadableStream). For the tools path, a follow-up turn stall propagates throughpipeAndConsumeStream→executeToolsIfNeeded→await executionPromise, not through theturnBroadcaster'scompletionError. This works (all stream methodsawait executionPromise), but it's untested — a test exercising concurrentgetTextStream()+getFullResponsesStream()consumers with a stall in a follow-up turn would verify the error reaches both.
Risk assessment
| Dimension | Severity | Risk | Reasoning |
|---|---|---|---|
| Implementation risk | 🟨🟨 | Medium | Core watchdog wiring is correct; the timeout-retry interaction is a subtle behavioral change not covered by tests. |
| Premise risk | 🟩 | Low | Per-turn stall detection is a sound design; the approach matches vercel/ai's per-step semantics. |
| Estimated impact | 🟩 | Low | Feature is opt-in (default off); worst case is degraded retry behavior for users who enable the watchdog with timeoutMs and retries. |
| Risk Factor | Severity | Risk | Reasoning |
|---|---|---|---|
| Reversibility | 🟩 | Low | Opt-in feature, unset by default. |
| Detectability | 🟩 | Low | Retry failures surface as timeout errors, immediately observable. |
| Blast radius | 🟩 | Low | Only affects callers who opt into timeout with timeoutMs + retries. |
| Data integrity | 🟩 | None | No persisted state is touched by the watchdog. |
| Financial exposure | 🟩 | None | No billing or payment paths affected. |
| Security and privacy exposure | 🟩 | None | No credentials or tenant isolation involved. |
| Propagation | 🟩 | Low | Errors surface to the caller; no downstream systems absorb them. |
| Availability | 🟨🟨 | Medium | Premature retry timeouts could cause requests to fail that would otherwise succeed on retry. |
| Recovery cost | 🟩 | Low | Disable the watchdog or adjust timeoutMs to recover. |
| Time to correct | 🟩 | Low | Fix is localized to createTurnWatchContext. |
| */ | ||
| const timeoutMs = baseOptions?.timeoutMs ?? this.options.client._options.timeoutMs; | ||
| const timeoutSignal = | ||
| !callerSignal && timeoutMs && timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : null; |
There was a problem hiding this comment.
▶ Suggestion: per-attempt timeout becomes cumulative across retries
The SDK's _do method creates a fresh AbortSignal.timeout(timeoutMs) inside the retry callback (sdks.ts:284-288), so each retry attempt gets a full timeout budget. When the watchdog is active, createTurnWatchContext supplies options.signal, which causes _createRequest to skip context.timeoutMs (sdks.ts:230-231), disabling _do's per-attempt timer. The AbortSignal.timeout(timeoutMs) created here is a single timer started before the first request — it counts down across ALL retry attempts of the turn.
With the default backoff retry on 5XX, a request that gets a 502 after most of the budget has elapsed will have its retry cut off by this cumulative timeout, whereas without the watchdog the retry would have gotten a fresh budget.
Consider one of:
- Let
_doarm its own per-attempt timeout (e.g., pass the turn signal through a request hook instead ofoptions.signal, so_createRequeststill setscontext.timeoutMs). - Document that
timeoutMsbecomes a per-turn cumulative budget when the watchdog is enabled.
Minor note on the same line: ?? here diverges from responsesSend's || (responsesSend.ts:282) for timeoutMs: 0 — an explicit 0 stays 0 (no timeout signal) here but falls back to the client default in responsesSend. Consider aligning the operator.
▶ Prompt for agents: In ModelResult.createTurnWatchContext, the replicated AbortSignal.timeout(timeoutMs) is created once and merged into the request signal, but the SDK's _do normally creates a fresh timeout per retry attempt. Because the watchdog always supplies a signal, _do's per-attempt timer is disabled. Consider a mechanism that preserves per-attempt semantics — e.g. passing the turn abort signal through a request hook rather than as options.signal, or documenting the cumulative-budget behavior. Also align the ?? operator with responsesSend's || for timeoutMs: 0.
| await expect(consumeFullStream).rejects.toThrow(StreamStalledError); | ||
| }); | ||
|
|
||
| it('re-arms deadlines per turn and catches a stall in a follow-up turn', async () => { |
There was a problem hiding this comment.
▶ Suggestion: add a test for multi-turn stall with concurrent stream consumers
The existing "propagates the stall to all concurrent stream consumers" test (line 311) covers the no-tools path (direct stream → ReusableReadableStream). This test covers the multi-turn path but only asserts on getText().
For the tools path, a follow-up turn stall propagates through pipeAndConsumeStream → executeToolsIfNeeded → await executionPromise, not through the turnBroadcaster's completionError (the .finally() in startTurnBroadcasterExecution calls broadcaster.complete() without the error). All stream methods await executionPromise so the error does reach consumers, but this path is untested.
Consider adding a test that runs concurrent getTextStream() + getFullResponsesStream() consumers with a stall in a follow-up turn (tools enabled) and asserts both reject with StreamStalledError.
▶ Prompt for agents: Add a test to tests/unit/call-model-stream-timeout.test.ts that exercises the multi-turn (tools) path: a stall in a follow-up turn with concurrent getTextStream() and getFullResponsesStream() consumers, asserting both reject with StreamStalledError. The existing concurrent-consumers test only covers the no-tools single-turn path.
Stack layer 2/5 —
callModelintegrationBase: #770 (watchdog core). Rest of stack: #774 (typed server-failure errors) → #775 (stall retries) → #776 (raw-stream helpers + docs). Linear: DEV-723.
Changes
Wires the phase-1 watchdog into
callModelwith an opt-in per-call option:timeoutis a client-only field onCallModelInput— stripped from the outgoing API request in both resolution paths (resolveAsyncFunctions,resolveRequestForContext).ModelResult.createTurnWatchContext()gives each turn its ownAbortController; on stall the watchdog aborts the turn's HTTP request (tests assertrequest.signal.aborted === trueand body teardown).combineSignals: caller signal + replicatedtimeoutMsbehavior + watchdog — configuring the watchdog never disables the existingtimeoutMs, and a user abort still surfaces as their abort, not a stall.responsesSendsites (initial request, follow-up tool turns, approval-resume) re-arm deadlines per turn — matching vercel/ai's per-stepfirstChunkMssemantics.ToolEventBroadcaster.complete()made idempotent (first call wins): the pipe's error completion must not be overwritten by the later unconditional.finally()completion, or concurrent consumers would see a clean close instead of the stall error.StreamStalledError,StreamStallPhase,StreamTimeoutOptionsexported from the package root via thesdk.tscustom region.Tests
8 integration tests driving the real transport path (scripted SSE bytes →
HTTPClient→responsesSend→EventStream→ModelResult): stall before content, keepalive-comment immunity, healthy streams, default-off, mid-generation stall, multi-consumer rejection, per-turn re-arm across a tool loop (turn 1 passes, turn 2 stalls), caller abort signal merging.Verification
Lint / typecheck / build clean; 222 unit tests passing (8 new).