Skip to content

fix(feature-flags): Report poll timeouts and cancel abandoned requests - #1754

Open
stanleyphu wants to merge 2 commits into
mainfrom
fix/runtime-client-poll-timeout
Open

stanleyphu wants to merge 2 commits into
mainfrom
fix/runtime-client-poll-timeout

Conversation

@stanleyphu

Copy link
Copy Markdown
Contributor

Description

A feature flags runtime client poll that runs past requestTimeoutMs was reported wrong and never cleaned up:

  • The error event said "Poll aborted", not that the poll timed out. The timeout aborted the poll's controller and then rejected, but the abort listener's rejection settled the race first. Every timeout reached error listeners and the logger as Error('Poll aborted').
  • The request outlived its poll. The poll's abort signal never reached workos.get, so after a timeout, or after close(), the HTTP request stayed open until the HTTP client's own 60-second timeout. That timeout is a 408, which the client retries, so the abandoned request was sent again about a minute later, even after close(). Against an API that stops answering, each poll holds a connection for a minute and then opens another.

The timeout now aborts the poll with a Request timed out error as the reason, and the poll passes its signal to the request. To carry it, workos.get and the HTTP client's RequestOptions take a signal, the same way maxRetries flows through. Aborting cancels the request in flight and rejects with the signal's reason (not a 408, and not Unexpected error: …). An aborted request is not retried.

Proof

New tests, each failing on main and passing here:

  • runtime-client.spec.ts › requestTimeoutMs › emits a timeout error: on main the error message is "Poll aborted"; here it is "Request timed out".
  • runtime-client.spec.ts › aborts the request of a poll that takes too long, and › close › aborts the in-flight poll request: on main the signal fetch received is still aborted: false after the timeout or close(); here it is true.
  • fetch-client.spec.ts › with an abort signal: on main the signal is ignored. The request hangs until Jest's 5 s test timeout, and an abort between retries still sends the retry. Here the request is aborted, rejects with the signal's reason, and no further attempt goes out.
  • workos.spec.ts › get › when the signal aborts: same through workos.get. On main it hangs; here it rejects with the reason.

End to end, I ran builds of main and this branch against a local server that accepts /sdk/feature-flags and never answers, using real fetch. The server recorded when each request's connection closed.

requestTimeoutMs: 500, pollingIntervalMs: 5000, close() at 12 s, observed for 70 s:

main
  error events: 0.51s Poll aborted, 6.33s Poll aborted
  #1 opened 0.02s  -> closed 60.02s
  #2 opened 5.83s  -> closed 65.83s
  #3 opened 11.74s -> still open at 70s
  #4 opened 60.81s -> still open at 70s   (retry of #1, after close())
  #5 opened 66.53s -> still open at 70s   (retry of #2, after close())

this branch
  error events: 0.51s Request timed out, 6.22s Request timed out, 11.68s Request timed out
  #1 opened 0.01s  -> closed 0.51s
  #2 opened 5.72s  -> closed 6.22s
  #3 opened 11.18s -> closed 11.68s

Default requestTimeoutMs, close() at 2 s while the first poll is in flight, observed for 66 s:

main
  #1 opened 0.03s  -> closed 60.05s
  #2 opened 61.44s -> still open at 66s   (retry of #1, after close())

this branch
  #1 opened 0.02s  -> closed 2.01s

Not covered

  • An abort that lands while the HTTP client is sleeping between retries stops the next attempt, but workos.get rejects only once that sleep ends (the computed backoff, or a Retry-After of up to 60 s). No request is in flight then, and the runtime client's poll still settles at its deadline. Making the sleep abortable would mean changing HttpClient.sleep, so I left it out.
  • At the WorkOS level only get takes signal, since that is all the runtime client needs. The HTTP client honors it for every method.

Documentation

Does this require changes to the WorkOS Docs? E.g. the API Reference or code snippets need updates.

[ ] Yes

If yes, link a related docs PR and add a docs maintainer as a reviewer. Their approval is required.

🤖 Generated with Claude Code

A poll that hit requestTimeoutMs emitted 'error' with "Poll aborted"
instead of the timeout: the timeout aborted the poll's controller before
rejecting, and the abort listener's rejection won the race.

The poll also never handed its abort signal to the HTTP request, so a
timed-out poll, or one in flight at close(), left its request open until
the HTTP client's own 60-second timeout. That timeout surfaces as a 408,
which the client retries, so abandoned requests were re-sent after a
minute, including after close().

The timeout now aborts the poll with a timeout error as the reason, and
the poll passes its signal to the request. To carry it, workos.get and
the HTTP client accept a `signal` option: aborting cancels the request,
rejects with the signal's reason, and is never retried.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@stanleyphu
stanleyphu marked this pull request as ready for review November 5, 2026 07:55
@stanleyphu
stanleyphu requested review from a team as code owners November 5, 2026 07:55
@stanleyphu
stanleyphu requested a review from mattgd November 5, 2026 07:55
@greptile-apps

greptile-apps Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; the null-cancellation fix preserves successful requests and existing retry behavior.

Summary

The PR reports feature-flag poll timeouts correctly and cancels abandoned HTTP requests.

  • Feature-flag polls cancel their request on timeout or close.

No new actionable issues were found in the changes since the previous review. Tests were inspected, not run.

Reviews (2) · Last reviewed commit: "fix(feature-flags): Reject requests abor..." · Reviewed by Greptile

Comment thread src/common/net/fetch-client.ts Resolved
`controller.abort(null)` sets the signal's reason to null, which the
retry loop mistook for success, so `workos.get` failed with a TypeError
instead of rejecting with the caller's reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@stanleyphu

Copy link
Copy Markdown
Contributor Author

@greptileai review

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.

1 participant