Skip to content

fix: honor timeout when a signal is also passed - #121

Open
sensei-woo wants to merge 2 commits into
better-auth:mainfrom
sensei-woo:fix/timeout-with-signal
Open

fix: honor timeout when a signal is also passed#121
sensei-woo wants to merge 2 commits into
better-auth:mainfrom
sensei-woo:fix/timeout-with-signal

Conversation

@sensei-woo

Copy link
Copy Markdown

Closes #120

What

timeout is silently ignored whenever the caller also passes a signal, so betterFetch(url, { timeout: 5000, signal }) has no deadline at all and can hang forever on a dead socket.

Two things caused it:

  • getTimeout only armed the timer when there was no signal: if (!options?.signal && options?.timeout).
  • betterFetch used opts.signal ?? controller.signal, so even if the timer had fired, the internal controller was not the signal the request listened to.

Why it matters

better-auth's session atom always passes its own signal to /get-session, which makes fetchOptions: { timeout } on createAuthClient a no-op. A hung get-session (laptop sleep, network change) leaves useSession().isPending true forever, and downstream consumers never reach a signed-out state. retry does not help — retry policy is only evaluated after a Response arrives.

How

The request now always listens to its own controller.signal. A caller-supplied signal is forwarded onto that controller by a new forwardAbortSignal helper:

  • already aborted → abort the controller immediately with the caller's reason;
  • otherwise → addEventListener("abort", …, { once: true }) and abort the controller with the caller's reason.

The timeout is armed whenever options.timeout is set. Whichever fires first wins, and the caller's abort reason is preserved on the way through.

forwardAbortSignal returns a cleanup that betterFetch calls in a finally, so a long-lived signal reused across many requests (exactly the better-auth session-atom case) doesn't accumulate listeners. That finally is the only reason fetch.ts looks like a large diff — the body is otherwise untouched, so git diff -w is one line changed plus the import.

Deliberately not using AbortSignal.any: the package ships a react-native entry, and RN runtimes can't be relied on to have it.

Behavior preserved

  • Timeout-only still aborts with a bare controller.abort(), i.e. the default AbortError — this PR does not switch it to a TimeoutError DOMException, since that would be a separate behavior change.
  • The timeout is still cleared as soon as the response arrives, so it covers the request but not body consumption.
  • An already-aborted caller signal still rejects with its own reason (existing test unchanged).
  • Retry is unaffected: the recursive call builds a fresh controller and re-arms the timeout per attempt, same as before. An aborted caller signal now short-circuits the retry attempt immediately with the caller's reason.

Tests

Four tests added to src/test/fetch.test.ts, using a customFetchImpl that only ever settles by abort:

  • aborts on timeout when no signal is passed (timeout-only regression guard);
  • aborts on timeout when a signal is also passed — this is the bug; it hangs on main and fails with Test timed out in 5000ms;
  • aborts with the caller's reason when the signal fires before the timeout;
  • detaches the forwarded abort listener once the request settles.

pnpm build && pnpm typecheck && pnpm test — 105 passed (5 files), up from 101. biome check clean on the touched files.

Docs: added a short note plus example to doc/content/docs/timeout-and-retry.mdx, since the types advertise both options with no documented interaction.

`getTimeout` only armed the timer when no `signal` was present, and
`betterFetch` handed the caller's signal straight to fetch, so
`{ timeout, signal }` left the request with no deadline at all — it could
hang forever on a dead socket. better-auth's session atom always passes
its own signal, which made `fetchOptions.timeout` a no-op there.

The request now always listens to its own controller. A caller signal is
forwarded onto that controller (`forwardAbortSignal`), preserving its
abort reason, so whichever of the two fires first wins. The listener is
detached in a `finally` once the request settles, so a long-lived signal
reused across requests doesn't accumulate listeners.

Timeout-only behavior is unchanged: it still aborts with the default
`AbortError`, and it is still cleared once the response arrives.

Closes better-auth#120
@greptile-apps

greptile-apps Bot commented Aug 29, 2026

Copy link
Copy Markdown

Greptile Summary

The PR composes caller cancellation with an internal timeout controller and ensures timeout and signal listeners are released after settlement.

  • Forwards caller aborts, including their reasons, to the request controller.
  • Arms configured timeouts even when callers provide a signal.
  • Clears timeout resources on both resolved and rejected requests.
  • Adds regression tests and documentation for combined timeout and signal behavior.

Confidence Score: 4/5

The PR is not yet safe to merge because request hooks can still disconnect the active fetch from both configured timeout and caller cancellation.

An onRequest hook may return a context containing another signal; the merge replaces the internal controller signal used by fetch, while timeout and caller cancellation continue aborting only the internal controller, leaving a hanging request uncancelled.

Files Needing Attention: packages/better-fetch/src/fetch.ts

Reviews (2): Last reviewed commit: "fix(fetch): release the timeout timer wh..." | Re-trigger Greptile

Comment thread packages/better-fetch/src/fetch.ts Outdated
if (onRequest) {
const res = await onRequest(context);
if (typeof res === "object" && res !== null) {
Object.assign(context, res);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Hooks disconnect cancellation controller

When an onRequest hook returns its own signal, Object.assign replaces the internal controller signal before dispatch, but getTimeout still aborts only the internal controller. A hanging fetch then ignores both the configured timeout and the forwarded caller cancellation.

Knowledge Base Used:

Fix in Cursor Fix in Codex Fix in Claude Code

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, but pre-existing: on main the same Object.assign(context, res) already let an onRequest hook replace signal, detaching the timeout the same way. Leaving it out of this PR to keep the diff to the timeout/signal composition; happy to follow up separately if maintainers want hooks prevented from overriding signal.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That’s fair. This behavior predates the PR, and the change does not introduce a new way for hooks to replace context.signal; it only composes the caller signal with the internal controller before hooks run. Since preventing hooks from overriding cancellation is a separate API/behavior decision, I agree it should be handled separately rather than expanding this fix. Resolving this comment.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

A caller abort or network error rejected `fetch` before `clearTimeout()`
ran, leaving the armed deadline holding the event loop until it fired.
Clear it in the same `finally` that detaches the forwarded listener.
Surfaced by review on better-auth#121.

Generated by commit-msg hook
Agent: Claude Code
Workspace: /tmp/better-fetch-pr
Session: 632a37b4-2763-4fa0-b61a-23ea78e273d6
Resume: acr 632a37b4-2763-4fa0-b61a-23ea78e273d6
Claude App:
claude://resume?session=632a37b4-2763-4fa0-b61a-23ea78e273d6
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.

timeout is silently ignored when a signal is also passed

1 participant