Skip to content

feat: configurable per-request timeouts and AbortSignal for FamisClient#108

Merged
alexisandreason merged 2 commits into
masterfrom
feat/request-timeouts
Jul 6, 2026
Merged

feat: configurable per-request timeouts and AbortSignal for FamisClient#108
alexisandreason merged 2 commits into
masterfrom
feat/request-timeouts

Conversation

@alexisandreason

Copy link
Copy Markdown
Contributor

Fixes #107.

Problem

FamisClient created its axios instance with no timeout (axios default 0 = wait forever). A single HTTP request that connects but stalls — queued server-side under load, or hitting an unhealthy endpoint — hung indefinitely, holding the caller's slot until something external killed it. This affected both the nightly cache run and the live interactive (mobile-user) path (withAccessToken). Login had the same exposure.

Fix

A per-request timeout (bounds one HTTP round-trip, not a whole paginated operation — the client pages at $top=1000, so a multi-hour fetch is many short requests). Generous by default, fully configurable, backwards compatible.

  • DEFAULT_REQUEST_TIMEOUT_MS = 120_000, DEFAULT_LOGIN_TIMEOUT_MS = 30_000 (exported).
  • Client axios instance and static login() now set a timeout.
  • Factory options: withLoginCredential({ requestTimeoutMs?, loginTimeoutMs? }), withAccessToken({ requestTimeoutMs? }).
  • Per-call overrides: QueryContext.setTimeout(ms) and setSignal(AbortSignal), applied via a private requestConfig() helper on getAllBatch / getAllPaged / getAttachmentStream. Neither is serialized into the OData URL.
  • AbortSignal is bridged to an axios CancelToken (axios 0.21 has no native signal support); an already-aborted signal cancels before dispatch.
  • autoRetry caps retry-on-timeout (ECONNABORTED) at one, preserving normal network-error retries.

Notes / deviations from the issue's reference patch

  • The patch's timeoutConfig was broadened to requestConfig to also carry the signal, and applied to getAttachmentStream too.
  • getAllUsingLink (nextLink path) takes a raw URL string, not a QueryContext, so those requests get the client-default timeout only — no per-call override there.
  • A jest setupFiles polyfill supplies AbortController in the jest 24 sandbox (Node 20 provides it natively at runtime).

Verification

  • yarn test: 93/93 pass (15 new tests: defaults, factory overrides, per-call timeout threading, CancelToken bridge + real cancellation, retry-timeout cap vs. normal retries).
  • yarn build (tsc): clean.

🤖 Generated with Claude Code

…lient

Fixes the no-timeout hang (#107): the axios instance used timeout 0
(wait forever), so a single stalled HTTP request held the caller's slot
indefinitely. Add a generous per-REQUEST timeout (bounds one round-trip,
not a paginated operation), a tighter login timeout, and per-call
overrides.

- DEFAULT_REQUEST_TIMEOUT_MS (120s) / DEFAULT_LOGIN_TIMEOUT_MS (30s)
- requestTimeoutMs / loginTimeoutMs factory options; client + login
  axios instances now set timeout
- QueryContext.setTimeout(ms) / setSignal(AbortSignal) per-call overrides
  via a requestConfig() helper on getAllBatch/getAllPaged/getAttachmentStream
- AbortSignal bridged to an axios CancelToken (axios 0.21 lacks native
  signal support); already-aborted signals cancel before dispatch
- autoRetry caps retry-on-timeout (ECONNABORTED) at one, preserving
  normal network-error retries
- jest setupFiles AbortController polyfill for the jest 24 sandbox

93/93 tests pass; tsc build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alexisandreason
alexisandreason requested a review from alphahlee July 6, 2026 21:39

@alphahlee alphahlee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approve — solid, well-tested, backwards-compatible fix for the no-request-timeout hang (#107).

Verified the fiddly parts against the actual deps rather than assuming:

  • Constructor positional args line up in both withLoginCredential (9) and withAccessToken (9, incl. false autoRefresh / false,undefined reauth).
  • ECONNABORTED retry cap = exactly 1 — confirmed against installed axios-retry@3.9.1: namespace === 'axios-retry' (the key the condition reads) and getCurrentState() initializes retryCount before retryCondition(error) runs, so 1st timeout reads 0 (<1→retry), 2nd reads 1 (<1 false→stop). Normal network retries preserved.
  • Cancellations aren't retriedECONNABORTED/ERR_CANCELED are in axios-retry's exclude list, and an axios 0.21 Cancel has no .config so axios-retry bails.

One recommended follow-up before consumers rely on setSignal (inline) — plus two minor notes. None block merge.

Finding #1 (medium): QueryContext.setSignal() / setTimeout() are silently inert for most read entities. getAll<T> routes any type where supportsNextLink() is true to getAllUsingLink(), which threads no requestConfig — and supportsNextLink defaults to true (only workorders, assets, propertyregionassociations, propertyrequesttypeassociations, useractivitygroupassociations take the context-threaded getAllPaged). So a caller aborting the signal on e.g. getUsers/getProperties gets no cancellation; the request runs to the 120s default. The PR body notes the timeout side but not that AbortSignal cancellation is inert there. Fix: thread this.requestConfig(context) into getAllUsingLink (initial + nextLink gets), or at least caveat the setSignal/setTimeout JSDoc.

Comment thread famis_client.ts
* to an axios CancelToken: aborting the signal cancels the in-flight request (and an
* already-aborted signal cancels immediately, before the request is dispatched).
*/
private requestConfig(context?: QueryContext): AxiosRequestConfig {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Finding #1 (medium) — requestConfig is not applied on the getAllUsingLink path. getAll<T> sends any supportsNextLink()==true type through getAllUsingLink(context.buildUrl(type)), which calls this.http.get(startPath) / this.http.get(nextLink) with no config. Since supportsNextLink defaults to true, that's the majority of the ~100 getAll-backed reads — so setSignal()/setTimeout() do nothing for them (only the 5 explicitly-false types hit getAllPaged). AbortSignal cancellation in particular is silently a no-op. Suggest passing the QueryContext into getAllUsingLink and applying this.requestConfig(context) to both gets, or caveating the setSignal/setTimeout JSDoc.

Comment thread famis_client.ts
once: true,
});
}
config.cancelToken = source.token;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Finding #3 (low, note only) — abort listener retained until signal GC. Each request builds a fresh CancelToken and registers a once:'abort' listener; on normal completion the listener isn't removed. For a signal reused across a multi-page getAllBatch (one context+signal, N pages) they accumulate until the signal is collected. Not a correctness bug (AbortSignal is EventTarget, no max-listeners warning) and per-operation signals are short-lived — just flagging.

Comment thread famis_client.ts
const url = context.buildUrl('attachmentstream');
return await this.http.get(url, {
responseType: 'arraybuffer',
...this.requestConfig(context),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Finding #2 (low/medium) — the new 120s default now bounds attachment downloads. getAttachmentStream uses the client instance (timeout: 120_000) with responseType: 'arraybuffer'; axios 0.21 applies timeout across the full buffered-body download. A large attachment over a slow mobile link that previously completed (no timeout existed before) can now abort at 120s with ECONNABORTED. Consider a larger/disabled default for the attachment path, or document that callers should raise it via context.setTimeout().

Minor bump for the new per-request timeout + AbortSignal API (#107).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alexisandreason
alexisandreason merged commit 2bee067 into master Jul 6, 2026
2 checks passed
@alexisandreason
alexisandreason deleted the feat/request-timeouts branch July 6, 2026 22:55
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.

Add configurable per-request timeouts to FamisClient (no-timeout hang on stalled requests)

2 participants