Skip to content

fix: keep request timeouts active while reading response bodies - #1711

Merged
gjtorikian merged 5 commits into
workos:mainfrom
CodingCossack:fix/1679-body-timeout
Sep 21, 2026
Merged

gjtorikian merged 5 commits into
workos:mainfrom
CodingCossack:fix/1679-body-timeout

Conversation

@CodingCossack

Copy link
Copy Markdown
Contributor

Requests could hang indefinitely when a server returned headers but stalled the response body.

Keep the original per-attempt timeout active through body consumption. Preserve the original response object, prevent competing readers from disarming another reader’s deadline, and leave unrelated body-read errors unchanged.

Timeouts while reading error responses remain subject to the existing retry policy, preserving Retry-After and POST idempotency keys. Timeouts while reading successful responses are reported as 408 errors but are not automatically retried.

Validated with deterministic regressions and real HTTP tests using native Fetch and node-fetch 2.7.0. The full suite passes with 1,061 tests, alongside typecheck, lint, formatting and build checks.

Fixes #1679.

Keep the request timeout active while the response body is read so a response that stalls after headers cannot hang indefinitely.

Preserve existing non-2xx retry handling, ParseError behaviour and the original Response object. Successful-body timeouts are reported but are not automatically retried.

Only the SDK read that owns the body disarms the deadline; a competing toJSON() or raw consumer cannot release it. On the successful-body path only the deadline's own expiry is reported as a timeout, and the WorkOS client translates only that 408 so other body-read errors propagate unchanged.

Fixes workos#1679
@CodingCossack
CodingCossack requested review from a team as code owners September 21, 2026 10:23
@greptile-apps

greptile-apps Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

The changes since the previous review appear safe to merge, with no accepted new findings or outstanding manually unresolved findings.

Summary

This PR extends each fetch attempt’s timeout through response-body consumption while preserving the original response object and existing retry behavior.

  • Reads successful response bodies outside the retry boundary and translates deadline expirations into timeout errors.
  • Reads error bodies within the retry boundary so retry headers and POST idempotency behavior remain available.
  • Adds deterministic and real-HTTP coverage for native Fetch and node-fetch.
  • Restores void deletion methods so they do not parse or require JSON response bodies.
  • Documents the body-wide timeout behavior.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Start fetch attempt and deadline] --> B[Receive response headers]
  B --> C{Successful status?}
  C -- No --> D[Read error body within retry attempt]
  D --> E{Body read timed out?}
  E -- Yes --> F[Create 408 with response headers]
  E -- No --> G[Return HTTP error]
  F --> H{Retry policy permits retry?}
  G --> H
  H -- Yes --> A
  H -- No --> I[Surface error]
  C -- Yes --> J[Start body read outside retry boundary]
  J --> K{Caller consumes JSON response?}
  K -- Yes --> L[Await body and parse JSON]
  K -- No --> M[Drain body in background]
  L --> N[Clear deadline]
  M --> N
Loading

Reviews (5) · Last reviewed commit: "fix(workos): let delete() complete on th..."

Comment thread src/workos.ts Outdated
…empt

The body-timeout fix kept the body lazy so getRawResponse() could hand
back an unconsumed Response, and paid for it with a deadline that had
to be handed from the attempt to the response object: a RequestTimeout
interface plus null object, ref/unref bookkeeping, competing-reader
detection, two AbortError policies and a 408 translation layer in
WorkOS. Nothing reads raw bodies, and the API is JSON-only.

Reading res.text() before clearing the existing timer covers the same
stall with the timer we already had. The 408 then leaves fetchRequest()
like any other timeout, so the retry policy, Idempotency-Key handling
and handleHttpError() apply unchanged, and bodies nobody reads (delete,
non-JSON) are drained instead of left on the connection.

The timeout is now detected on the attempt's own signal rather than the
error's name: the AbortError undici raises for an aborted body can come
from another realm, where instanceof Error is false.
Comment thread src/common/net/fetch-client.ts Outdated
… out

Reading the whole body inside the retryable attempt meant a 2xx response
whose body stalled or failed re-entered the retry policy. The server had
already applied that request, and PUT, PATCH and DELETE carry no
idempotency key, so the retry could apply a mutation twice or turn a
completed DELETE into a 404.

Keep the error-body read inside the attempt, where the existing retry
policy and Retry-After handling belong, but start the successful-body
read as a promise handed to the response: it still runs under the
attempt's deadline and a stall still surfaces as a 408 from toJSON(),
translated by WorkOS like a timeout before the headers, but the retry
loop never sees it. A body nobody awaits is still drained.
Comment thread src/common/net/fetch-client.ts
…nse as read

toJSON() returned null for a non-JSON content type without observing
the body read, so a stalled non-JSON body resolved the call early and
its 408 was swallowed. Await the body first. delete() and
deleteWithBody() now consume the response the same way, so every
WorkOS request waits for the full body the timeout is documented to
cover.
Comment thread src/workos.ts Outdated
Routing delete() and deleteWithBody() through toJSON() made a
successful deletion depend on its body parsing as JSON, so an empty or
malformed body labelled application/json would have surfaced as a
ParseError. Neither method uses the body; they resolve once the
response arrives, as before, while the body is still drained in the
background and bounded by the attempt's deadline.
@gjtorikian

Copy link
Copy Markdown
Contributor

thanks !

@gjtorikian
gjtorikian merged commit 26eaed6 into workos:main Sep 21, 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

Development

Successfully merging this pull request may close these issues.

FetchHttpClient: request timeout stops covering the response body once headers arrive

2 participants