feat: configurable per-request timeouts and AbortSignal for FamisClient#108
Conversation
…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>
alphahlee
left a comment
There was a problem hiding this comment.
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) andwithAccessToken(9, incl.falseautoRefresh /false,undefinedreauth). - ECONNABORTED retry cap = exactly 1 — confirmed against installed
axios-retry@3.9.1:namespace === 'axios-retry'(the key the condition reads) andgetCurrentState()initializesretryCountbeforeretryCondition(error)runs, so 1st timeout reads 0 (<1→retry), 2nd reads 1 (<1false→stop). Normal network retries preserved. - Cancellations aren't retried —
ECONNABORTED/ERR_CANCELEDare in axios-retry's exclude list, and an axios 0.21Cancelhas no.configso 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.
| * 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 { |
There was a problem hiding this comment.
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.
| once: true, | ||
| }); | ||
| } | ||
| config.cancelToken = source.token; |
There was a problem hiding this comment.
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.
| const url = context.buildUrl('attachmentstream'); | ||
| return await this.http.get(url, { | ||
| responseType: 'arraybuffer', | ||
| ...this.requestConfig(context), |
There was a problem hiding this comment.
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>
Fixes #107.
Problem
FamisClientcreated its axios instance with notimeout(axios default0= 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).static login()now set atimeout.withLoginCredential({ requestTimeoutMs?, loginTimeoutMs? }),withAccessToken({ requestTimeoutMs? }).QueryContext.setTimeout(ms)andsetSignal(AbortSignal), applied via a privaterequestConfig()helper ongetAllBatch/getAllPaged/getAttachmentStream. Neither is serialized into the OData URL.CancelToken(axios 0.21 has no nativesignalsupport); an already-aborted signal cancels before dispatch.autoRetrycaps retry-on-timeout (ECONNABORTED) at one, preserving normal network-error retries.Notes / deviations from the issue's reference patch
timeoutConfigwas broadened torequestConfigto also carry the signal, and applied togetAttachmentStreamtoo.getAllUsingLink(nextLink path) takes a raw URL string, not aQueryContext, so those requests get the client-default timeout only — no per-call override there.setupFilespolyfill suppliesAbortControllerin 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