Skip to content

JavaScript SDK: add configurable request timeout (KSM-1209) - #1136

Open
stas-schaller wants to merge 16 commits into
release/sdk/javascript/core/v17.6.0from
feature/KSM-1209-js-request-timeout
Open

JavaScript SDK: add configurable request timeout (KSM-1209)#1136
stas-schaller wants to merge 16 commits into
release/sdk/javascript/core/v17.6.0from
feature/KSM-1209-js-request-timeout

Conversation

@stas-schaller

@stas-schaller stas-schaller commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Summary

JavaScript SDK: adds a bounded, configurable request timeout to all network calls (main API requests, file upload, file download, and the offline-cache fallback), closing a hang where a stalled or hostile server could block the caller indefinitely (CWE-400).

Changes

Fixed

  • Both platforms enforce a fixed deadline built on AbortController, not Node's socket timeout option, which only resets on inactivity and can be held open indefinitely by a slow trickle of data. The deadline stays armed across the whole exchange, response body included, so a server that sends headers immediately and then stalls or trickles is bounded the same as one that never responds at all. Both platforms reject with a KeeperError naming the timeout that was actually applied, and a mid-body connection failure now rejects instead of leaving the caller waiting forever. No cryptographic material or transmission logic is affected. (KSM-1209)
  • Timeout values are validated once, before any request is attempted: 0, negatives, fractional values below 1ms, NaN, and Infinity are rejected with a plain Error (a caller-input mistake, not a KeeperError) rather than silently collapsing to a near-instant timeout that fails every request. Values above setTimeout's 32-bit ceiling are clamped rather than truncated to 1ms.
  • Defaults to 30 seconds; override via SecretManagerOptions.requestTimeoutMs, which now also reaches downloadFile, downloadThumbnail, uploadFile (each also gains its own additive, optional timeoutMs argument that wins over the configured default), and the cachingPostFunction / createCachingFunction offline-cache helpers.
  • downloadFile and downloadThumbnail keep timeoutMs as their second argument and gain the options object as an additive third argument, so an explicit timeoutMs still wins over it.
  • Node's response buffering re-copied the whole accumulated buffer on every network chunk (O(n^2) in body size), which could turn a large-but-healthy download into a spurious timeout purely from its own buffering cost. Chunks are now concatenated once when the response ends.
  • armRequest's own abort listener raced Node's internal handling of the same signal, since request()/https.request() already destroys the request and emits error on it when the passed-in signal aborts. Removed the redundant listener; the existing error handler now checks signal.aborted to decide between the SDK's own timeout message and the raw error, mirroring the browser platform's existing pattern for the same problem.
  • The offline-cache fallback (cachingPostFunction / createCachingFunction) no longer treats a deliberate client-side timeout the same as a real network failure: a timeout now propagates to the caller instead of returning a synthetic success built from stale cache. Both now validate timeoutMs before attempting a request rather than inside the same try/catch as the request, so an invalid value is rejected outright instead of being caught and mistaken for a transport failure. allowUnverifiedCertificate is forwarded through that same cache path for consistency with the direct request path.
  • A cache-write failure on either platform (disk full, IndexedDB quota, private browsing) no longer discards or misrepresents an already-successful fresh response; only the next call's offline fallback is affected.
  • uploadFile now validates its timeout before allocating an upload placeholder on the backend, instead of after, so an invalid value can no longer leave a file record pointing at content that was never uploaded.
  • getSecrets no longer persists a caller-supplied serverPublicKey/serverPublicKeyId to storage before validating requestTimeoutMs; an invalid value now produces no side effects at all.
  • fileUpload never reads the response body; it's now drained on both platforms (res.resume() on Node, res.body.cancel() in the browser) instead of left unconsumed, which previously kept the socket (and the event loop) alive after a successful upload on Node.
  • The timeout error message no longer includes the request URL's query string, since file download, thumbnail, and upload URLs from the storage backend carry a time-limited access token there.
  • DEFAULT_REQUEST_TIMEOUT_MS is exported from both the Node and browser entry points. On a runtime with no AbortController, the SDK keeps working with no timeout enforced rather than failing every request.
  • The custom-caching-function-support example now carries the same deliberate-timeout-vs-transport-failure distinction as the real implementation it demonstrates.

Maintenance

  • Consolidated duplicate timeout-propagation test coverage: test/keeper.test.ts and test/timeout.test.ts had grown near-identical describe blocks; test/timeout.test.ts is now the sole home for this coverage.
  • De-flaked a cache-fallback test that depended on the working directory not already containing a stray cache file.
  • Added coverage for the AbortController-unavailable fallback path, previously untested.

Testing

cd sdk/javascript/packages/core
npm test

Full suite 220/220 passing; tsc --noEmit clean. Notable coverage: test/deadline.test.ts (timeout resolution/validation/clamping, AbortController-unavailable fallback), test/timeout.test.ts (requestTimeoutMs propagation from SecretManagerOptions through every network call site, including getSecrets/downloadFile/downloadThumbnail/uploadFile), test/nodePlatform.test.ts/test/browserPlatform.test.ts (deadline enforcement, mid-body failures, fileUpload response draining), test/cachingFunctions.test.ts (timeout-vs-network-failure distinction and cache-write-failure isolation in the offline-cache fallback).

Breaking Changes

None. timeoutMs/requestTimeoutMs are optional and default to 30s; existing calls that omit them behave the same except for gaining the bound.

Related Issues

  • Jira: KSM-1209
  • Follow-ups filed during review, not fixed here: KSM-1364 (examples//integration/ are outside this package's jest scope), KSM-1365 (deferred cleanup: duplicated request wiring in nodePlatform.ts, duplicated caching carve-out across platforms)

@stas-schaller stas-schaller changed the title fix(javascript): add configurable request timeout (KSM-1209) JavaScript SDK: add configurable request timeout (KSM-1209) Aug 26, 2026

@mgallego-keeper mgallego-keeper 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.

Summary

Reviewed the request-timeout implementation in detail, including empirical reproduction of the Node vs. browser timeout semantics against live servers. The core mechanism (bounding a previously-unbounded wait) works, but the two platform implementations are not equivalent, and that gap undercuts this PR's own claims of parity ("Node ... rejects with a KeeperError; browser uses AbortSignal.timeout") and "Breaking Changes: None."

Correctness / security

  1. Node's timeout is an idle timer, not a deadline (medium). nodePlatform.ts (lines 213, 235, 272) passes timeout to https.request, which resets on every byte of socket activity. browserPlatform.ts (lines 335, 360, 386) uses AbortSignal.timeout(), a fixed wall clock deadline. Reproduced with a server trickling 1 byte every 150ms for 3s at requestTimeoutMs=300: the Node request resolved successfully; the identical scenario under browser semantics rejected in ~300ms. A hostile server that stays just under the idle window can still hang a Node caller indefinitely, which is the exact scenario the changelog entry for this PR says it closes, on the SDK's primary server side target.

  2. timeoutMs: 0 means opposite things on each platform, and nothing validates it (medium). timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS (platform.ts:1 defines the default) only substitutes on null/undefined, so an explicit 0 passes through unchanged. On Node this fully disables the timeout (reopens the DoS this PR fixes); on browser, AbortSignal.timeout(0) aborts nearly every request instantly. Both reproduced live. Related, lower severity: negative values throw a raw RangeError instead of KeeperError on both platforms; values above 2^32-1 throw on browser but only warn and clamp on Node. Recommend validating/clamping timeoutMs once, in keeper.ts, before it reaches either platform.

  3. downloadFile/downloadThumbnail cannot inherit SecretManagerOptions.requestTimeoutMs (medium). uploadFile (keeper.ts:1408) forwards options.requestTimeoutMs to platform.fileUpload, but downloadFile/downloadThumbnail (keeper.ts:1398,1403) only accept an explicit timeoutMs argument, no options parameter. A caller who sets requestTimeoutMs once, expecting it to bound downloads too, silently gets the 30s default instead.

  4. cachingPostFunction/createCachingFunction silently drop the new override (low). Both public exports (node/localConfigStorage.ts:54, browser/localConfigStorage.ts:151) declare only (url, transmissionKey, payload) and call platform.post with 3 args, so requestTimeoutMs never reaches platform.post for any consumer using the SDK's own offline cache helpers.

  5. Browser timeout errors are a raw DOMException, never KeeperError (low/medium). Node wraps its timeout in new KeeperError(...); browserPlatform.ts's get/post have no try/catch at all, and fileUpload's catch re-throws unchanged. Code written against instanceof KeeperError (the pattern the CHANGELOG implies) will silently miss timeouts on browser.

Breaking change risk

  1. AbortSignal.timeout() is called unconditionally in every browser network call, with no feature detection (medium/high). No typeof guard and no polyfill anywhere in the rollup browser build. On a runtime lacking this API (pre-2022 browsers, older embedded WebViews), every get/post/fileUpload call now fails immediately with a TypeError, a full break, not limited to calls that would have timed out. This contradicts "Breaking Changes: None": a runtime that previously worked fine with no timeout enforcement now hard fails on every call.

Test coverage

  1. requestTimeoutMs propagation through the public API is completely untested (high). Zero occurrences of requestTimeoutMs/timeoutMs in keeper.test.ts or throttle.test.ts. Verified by mutation testing: dropping options.requestTimeoutMs from the call at keeper.ts:800 entirely still leaves the full suite at 73/73 passing.
  2. downloadFile/downloadThumbnail/uploadFile are never invoked by any test.
  3. No test proves a timeout rejection from the new code isn't retried forever by postQuery's retry loop (current behavior is correct, verified by repro, but it's unguarded and untested).
  4. nodePlatform.test.ts's https mock never emits a response event; a coverage run confirms the success path lines added by this PR (fetchData and the three callback bodies) are never executed.
  5. DEFAULT_REQUEST_TIMEOUT_MS's value is only checked against itself; mutating 30000 to 30 left the full suite green.

Process note

This PR (currently based on feature/KSM-1254-js-node-hash-tag) does not trigger test.js.yml, which only runs on PRs into master. The only passing checks are Socket Security scans; no test execution ran in CI. Ran the suite locally against this exact branch to confirm health: 8 suites, 73 tests, all passing.

Recommendation

Requesting changes on items 1, 2, 3, and 6: the Node/browser timeout semantics gap and the unvalidated 0 value both undercut the security rationale for this fix, and the browser hard dependency contradicts the stated "no breaking changes." Items 7-11 would meaningfully reduce the chance of a silent regression in this exact area and are worth adding here or in a fast follow-up.

@stas-schaller
stas-schaller force-pushed the feature/KSM-1209-js-request-timeout branch from 2d539c6 to ae5bfce Compare August 26, 2026 20:49
Base automatically changed from feature/KSM-1254-js-node-hash-tag to release/sdk/javascript/core/v17.6.0 August 27, 2026 21:56
@mgallego-keeper
mgallego-keeper force-pushed the feature/KSM-1209-js-request-timeout branch from ae5bfce to 1639412 Compare August 27, 2026 21:56
stas-schaller pushed a commit that referenced this pull request Aug 28, 2026
…209)

Follow-up to the review on PR #1136.

The AbortController deadline was cleared in the response callback, before
fetchData read a byte, so nothing bounded the response body. A server that
sent headers and then stalled hung the caller forever, which the previous
socket-timeout version had caught. Hold the deadline until the body ends,
and wire the response stream's error event so a mid-body socket failure
rejects rather than leaving the promise pending.

Validate the timeout in one place. 0, negatives, NaN and Infinity all
collapse to a near-instant setTimeout, so they now raise a KeeperError
instead of silently killing every request under a message naming a value
that was never applied; values past setTimeout's 32-bit ceiling clamp
rather than truncating to 1ms.

Also from the review:
- downloadFile/downloadThumbnail take options and inherit requestTimeoutMs
- cachingPostFunction/createCachingFunction forward the trailing arguments
- browser timeouts raise KeeperError instead of a raw DOMException
- deadlineSignal moved to an internal module, off the public node surface
- CHANGELOG corrected: it claimed the trickle case was closed when it was not

Tests: 73 to 131. The https mock now emits real response events, so the
body path is executed; every wiring point from SecretManagerOptions to the
platform call is asserted. Verified against a local HTTPS server: stall,
trickle, mid-body reset and premature close are all bounded on both
platforms.
@stas-schaller
stas-schaller force-pushed the feature/KSM-1209-js-request-timeout branch 2 times, most recently from cf85a37 to a275e10 Compare August 28, 2026 17:53
mgallego-keeper added a commit that referenced this pull request Aug 28, 2026
…209)

Follow-up to the review on PR #1136.

The AbortController deadline was cleared in the response callback, before
fetchData read a byte, so nothing bounded the response body. A server that
sent headers and then stalled hung the caller forever, which the previous
socket-timeout version had caught. Hold the deadline until the body ends,
and wire the response stream's error event so a mid-body socket failure
rejects rather than leaving the promise pending.

Validate the timeout in one place. 0, negatives, NaN and Infinity all
collapse to a near-instant setTimeout, so they now raise a KeeperError
instead of silently killing every request under a message naming a value
that was never applied; values past setTimeout's 32-bit ceiling clamp
rather than truncating to 1ms.

Also from the review:
- downloadFile/downloadThumbnail take options and inherit requestTimeoutMs
- cachingPostFunction/createCachingFunction forward the trailing arguments
- browser timeouts raise KeeperError instead of a raw DOMException
- deadlineSignal moved to an internal module, off the public node surface
- CHANGELOG corrected: it claimed the trickle case was closed when it was not

Tests: 73 to 131. The https mock now emits real response events, so the
body path is executed; every wiring point from SecretManagerOptions to the
platform call is asserted. Verified against a local HTTPS server: stall,
trickle, mid-body reset and premature close are all bounded on both
platforms.
mgallego-keeper added a commit that referenced this pull request Aug 28, 2026
…-1209 review fixes) (#1139)

* fix(javascript): bound the whole request, validate the timeout (KSM-1209)

Follow-up to the review on PR #1136.

The AbortController deadline was cleared in the response callback, before
fetchData read a byte, so nothing bounded the response body. A server that
sent headers and then stalled hung the caller forever, which the previous
socket-timeout version had caught. Hold the deadline until the body ends,
and wire the response stream's error event so a mid-body socket failure
rejects rather than leaving the promise pending.

Validate the timeout in one place. 0, negatives, NaN and Infinity all
collapse to a near-instant setTimeout, so they now raise a KeeperError
instead of silently killing every request under a message naming a value
that was never applied; values past setTimeout's 32-bit ceiling clamp
rather than truncating to 1ms.

Also from the review:
- downloadFile/downloadThumbnail take options and inherit requestTimeoutMs
- cachingPostFunction/createCachingFunction forward the trailing arguments
- browser timeouts raise KeeperError instead of a raw DOMException
- deadlineSignal moved to an internal module, off the public node surface
- CHANGELOG corrected: it claimed the trickle case was closed when it was not

Tests: 73 to 131. The https mock now emits real response events, so the
body path is executed; every wiring point from SecretManagerOptions to the
platform call is asserted. Verified against a local HTTPS server: stall,
trickle, mid-body reset and premature close are all bounded on both
platforms.

* fix(javascript): fileUpload response object has no error listener (KSM-1209)

fetchData (get/post) now rejects on a mid-body response stream error,
but fileUpload's response handler resolves off headers alone with
nothing attached to the response object. A socket failure after that
point emits 'error' with zero listeners, which Node throws instead of
swallowing.

---------

Co-authored-by: Stas Schaller <sschaller@keepersecurity.com>
stas-schaller added a commit that referenced this pull request Aug 28, 2026
…-1209 review fixes)

Follow-up to the review on PR #1136.

The AbortController deadline was cleared in the response callback, before
fetchData read a byte, so nothing bounded the response body. A server that
sent headers and then stalled hung the caller forever, which the previous
socket-timeout version had caught. Hold the deadline until the body ends,
and wire the response stream's error event so a mid-body socket failure
rejects rather than leaving the promise pending. fileUpload had the same
gap on its own response object; fixed the same way.

Validate the timeout in one place. 0, negatives, NaN and Infinity all
collapse to a near-instant setTimeout, so they now raise an Error instead
of silently killing every request under a message naming a value that was
never applied; values past setTimeout's 32-bit ceiling clamp rather than
truncating to 1ms. Plain Error, not KeeperError, matching this file's
existing convention for caller-input/config problems.

Also from the review:
- downloadFile/downloadThumbnail take options and inherit requestTimeoutMs,
  keeping timeoutMs as the second argument to avoid stacking a second
  breaking change onto KSM-1265's in the same minor
- cachingPostFunction/createCachingFunction forward the trailing arguments
- browser timeouts raise KeeperError instead of a raw DOMException
- deadlineSignal moved to an internal module, off the public node surface
- CHANGELOG corrected: it claimed the trickle case was closed when it was not

truncateUrlForError (the CWE-532 query-string redaction on timeout error
messages) is preserved and now covers both platforms uniformly via the
shared timeoutError() helper.

Tests: 73 to 164. The https mock now emits real response events, so the
body path is executed; every wiring point from SecretManagerOptions to the
platform call is asserted. Verified against a local HTTPS server: stall,
trickle, mid-body reset and premature close are all bounded on both
platforms.

Co-authored-by: Stas Schaller <sschaller@keepersecurity.com>
@stas-schaller
stas-schaller force-pushed the feature/KSM-1209-js-request-timeout branch from d41d497 to 36b73f1 Compare August 28, 2026 20:28

@mgallego-keeper mgallego-keeper 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.

Summary

Re-reviewed the follow-up commit (36b73f12, "review fixes") against the items requested in the previous review. Most of the 11 original points are addressed, and two (items 5 and 6 below, browser error type and feature-detection fallback) are genuinely fixed with nothing further to flag. But the fixes for items 3 and 4 introduce new regressions, including two crash sites in shipped code and a security-relevant interaction with an existing, unresolved ticket. Requesting changes again.

Correctness / security

  1. cachingPostFunction and createCachingFunction now turn a timeout into a fake success (high). Both (node/localConfigStorage.ts:73, browser/localConfigStorage.ts:220) still catch every exception unconditionally and return a synthetic {statusCode: 200, ...} built from the last cached response. Before this PR that only fired on a genuine network failure; now it also fires on the new, deterministic timeout (default 30s), so any updateSecret/createSecret/deleteSecret/uploadFile call that merely runs long silently reports success from stale bytes. This is the exact catch-all that KSM-1265 (an unresolved OWASP audit finding from the same audit epic as this PR's own KSM-1209) already flags as too broad; this PR makes it substantially easier to trigger rather than narrowing it the way KSM-1265's acceptance criteria require.

  2. downloadFile is now called incorrectly at both of its only two real call sites (high). examples/javascript/hello-secret/hello.js:26 and ksm-azure-devops-secrets-task/index.ts:147 both call downloadFile(file, options), but the signature is (file, timeoutMs?, options?), so the options object binds to timeoutMs and every call throws Request timeout must be a finite number... got [object Object] before any network request. The previous commit had this correct (downloadFile(file, undefined, {storage})); this fix commit deleted the undefined placeholder. The Azure DevOps package also pins core ^16.6.3, so it fails to type-check against this signature today, separate from the runtime crash.

  3. resolveTimeoutMs still produces a near-instant timeout for one input range (medium). deadline.ts:39 checks the raw value against <= 0 before flooring, so a value strictly between 0 and 1 (e.g. 0.5) passes validation, then floors to 0, reproducing the exact "every request fails in a couple of milliseconds" failure this validation was added to prevent. Not covered by test/deadline.test.ts.

  4. allowUnverifiedCertificate now silently reaches platform.post from cachingPostFunction (medium). node/localConfigStorage.ts:62 forwards it where before it was always dropped (verification was always on regardless of caller config). This is a real TLS-verification behavior change for cached-mode consumers, not mentioned in the CHANGELOG.

  5. downloadFile/downloadThumbnail's new options parameter silently ignores allowUnverifiedCertificate (medium). keeper.ts:1404 only reads options?.requestTimeoutMs; platform.get has no TLS-bypass parameter at all, unlike platform.post. A caller who passes the same options object they use elsewhere reasonably expects the same TLS behavior and doesn't get it, silently.

  6. Node's armed deadline timer leaks on a synchronous request() throw (low/medium). nodePlatform.ts:217 (and the post/fileUpload equivalents) arm deadlineSignal() before calling request(), with no try/finally. A malformed URL or invalid header throws synchronously; the promise still rejects correctly, but clear() is never reachable, so the timer and its AbortController leak for the full deadline window. browserPlatform.ts avoids this with try/finally.

Comment accuracy

  1. The comment justifying downloadFile's argument order cites the wrong ticket, inaccurately (medium). keeper.ts:1400 says timeoutMs "stays the 2nd argument (its original, pre-options position)" to avoid compounding "KSM-1265's already-shipped breaking change." KSM-1265 is not shipped (status: In Development) and is not a breaking change; it is the cache-integrity finding referenced in item 1 above. There is also no "original position" for timeoutMs on downloadFile, it took exactly one argument before this PR. Please also drop the ticket number from the comment regardless of the above; ticket references belong in the PR description and CHANGELOG, not source comments.

API surface / consistency

  1. uploadFile has no per-call timeout override (low/medium). Unlike downloadFile/downloadThumbnail, keeper.ts:1418 always uses the global requestTimeoutMs, so a large upload that needs more than the 30s default has no escape short of loosening the timeout for every other call on that options object.

  2. DEFAULT_REQUEST_TIMEOUT_MS is not exported from the browser entry point (low/medium). node/index.ts:9 does export * from '../platform'; browser/index.ts:9 uses a narrow named list that omits it. A browser/bundler consumer importing the new constant gets nothing. The new regression test imports via main, so this asymmetry isn't caught.

  3. The documented custom-queryFunction example still drops the timeout silently (medium). examples/javascript/custom-caching-function-support/hello.js:18 forwards only 4 of the 5 arguments postQuery now passes. Anyone copying this canonical example gets no timeout enforcement, with no error, exactly the hazard items 1 and 4 above were supposed to close, just left open here.

Validation ordering

  1. validateTimeoutMs runs after persistent side effects in postQuery (low). keeper.ts:801 validates inside the retry loop, after storage writes and payload encryption already happened. A bad requestTimeoutMs still mutates on-disk config before the validation error throws.

  2. The timeout value is resolved twice, and custom queryFunctions get the unclamped raw value (low/medium). validateTimeoutMs (deadline.ts:82) calls resolveTimeoutMs only to throw, then returns the raw input; deadlineSignal resolves it again to actually clamp it. A caller with a custom queryFunction that does its own naive setTimeout gets the raw, unclamped value, including values above the 32-bit ceiling that MAX_REQUEST_TIMEOUT_MS exists to prevent, since that clamp only lives inside deadlineSignal.

Code quality

  1. The abort/error-handler wiring is triplicated (low). nodePlatform.ts's get/post/fileUpload (and browserPlatform.ts's equivalents) each repeat the same block verbatim; a future fix has to be applied identically in six places across two files.

Recommendation

Requesting changes on items 1 through 4: the cache-masking regression, the two crash sites, and the fractional-timeout gap. These are either shipped-code-breaking or security-relevant. Items 5 through 13 would meaningfully reduce the chance of a follow-up incident and are worth closing out here rather than in a fast follow-up, given how many follow-ups this ticket has already needed.

stas-schaller added a commit that referenced this pull request Sep 1, 2026
…ew round's gaps (KSM-1209)

Discovered while verifying the new file-upload example (KSM-1328):
fileUpload() resolves off headers alone and never reads the response
body. The comment already on this line (from the KSM-1209 review-fix
round) correctly identifies that fact for the unhandled-'error' case,
but the same unconsumed body also leaves the socket open, which keeps
the event loop alive - a script with no other pending work never
exits on its own after a successful upload. res.resume() discards the
body without buffering it, since nothing here reads it anyway.

Verified against Dev-CA: same script hangs (exit code 124) without
this fix, exits cleanly (code 0) with it, no process.exit() needed on
the caller's end.

Second round of fixes to PR #1136's own review (the 36b73f1 commit
above), addressing the follow-up CHANGES_REQUESTED pass plus the
non-blocking items from that same review:

- cachingPostFunction/createCachingFunction no longer treat a
  deliberate client-side timeout the same as a real network failure;
  a KeeperError from timeoutError() now propagates instead of
  returning a synthetic success built from stale cache
- downloadFile's call sites in the Azure DevOps task and the
  hello-secret example were passing an options object into the
  timeoutMs slot, throwing on every call; restored the missing
  `undefined` placeholder
- resolveTimeoutMs now rejects any value below 1, not just <= 0, so a
  fractional timeout like 0.5 can no longer floor to an instant abort
- validateTimeoutMs now returns the resolved, clamped value instead of
  the raw input, so a custom queryFunction or the offline-cache
  helpers never see an over-max or fractional timeout unclamped
- postQuery validates requestTimeoutMs once, up front, before any
  storage write or payload encryption, and reuses the resolved value
  across retries instead of re-validating it every iteration
- extracted armRequest() in nodePlatform.ts so get/post/fileUpload
  share one abort/error wiring implementation instead of three copies;
  each now wraps its request()/https.request() call in try/catch so a
  synchronous throw clears the deadline timer instead of leaking it
- uploadFile gains its own optional timeoutMs argument, matching
  downloadFile/downloadThumbnail
- DEFAULT_REQUEST_TIMEOUT_MS is now exported from the browser entry
  point, not just the Node one
- the custom-caching-function-support example now forwards timeoutMs
  to postFunction instead of dropping it
- reworded the downloadFile comment: it cited KSM-1265 as
  "already-shipped" (it is not, that PR is still under review) and
  named a ticket in a source comment; also notes that
  allowUnverifiedCertificate isn't honored here since platform.get has
  no such parameter
- CHANGELOG amended in place on the existing KSM-1209 entry to cover
  the behavior changes above

Also fixed: the fileUpload tests' MockResponse had no resume() method,
so they broke as soon as the drain fix above added that call; added a
jest.fn() stub.

Tests: 164 to 181.

@mgallego-keeper mgallego-keeper 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.

Summary

Follow-up to the two rounds of review already on this PR. The blocking items from the last round (36b73f12 to e1575ae5) are resolved and verified against a full local test run (181/181 passing). Two smaller things from that same pass are worth closing out before merge.

Findings

  1. The new fileUpload response-drain fix has no test proving it (medium). nodePlatform.ts:333's res.resume() correctly fixes the event-loop-hang bug described in this commit's message (an unconsumed response body keeps the socket, and the event loop, alive after a successful upload). But the only related test change is adding a resume = jest.fn() stub to MockResponse in nodePlatform.test.ts, purely so the existing tests do not crash on the new call. Nothing asserts resume was actually invoked. Every other fix in this same commit got a precise behavioral test; this is the one exception. Suggest adding expect(mockResponse.resume).toHaveBeenCalled() to the existing "fileUpload resolves as soon as the response headers arrive" test.

  2. resolveTimeoutMs's error message and doc comment no longer match its own boundary (low). The validation check moved from timeoutMs <= 0 to timeoutMs < 1 (deadline.ts:36), correctly closing the fractional-timeout gap from the previous review. But the thrown message ("Request timeout must be a finite number of milliseconds greater than 0") and the doc comment directly above resolveTimeoutMs ("must be a finite number above zero") still describe the old boundary. A caller passing 0.5 now sees "greater than 0, got 0.5", which reads as self-contradictory since 0.5 is in fact greater than 0. Worth updating both to state the real constraint (at least 1).

Recommendation

Neither item blocks merge on its own, and both are quick. Worth closing out here rather than in a follow-up, given how many rounds this ticket has already needed.

stas-schaller and others added 4 commits September 2, 2026 14:42
Both platforms enforce the deadline via AbortController rather than
Node's socket timeout option, which is an idle timer that resets on
socket activity and can be held open indefinitely by a slow trickle of
data - not a fixed deadline. Node requests reject with a KeeperError
when the deadline fires. Browser requests use the same
AbortController-driven deadline, falling back to a plain setTimeout on
runtimes that lack the AbortSignal.timeout() shorthand instead of
failing every request outright. Defaults to 30s, overridable via
SecretManagerOptions.requestTimeoutMs or a direct timeoutMs argument on
downloadFile/downloadThumbnail. Previously a stalled or hostile server
could hang the caller indefinitely (CWE-400), and on Node a slow
trickle of bytes could keep resetting the old idle timer so it never
fired at all.

An invalid requestTimeoutMs/timeoutMs (zero, negative, or non-finite)
now throws immediately instead of silently disabling the timeout on
Node or firing almost instantly on both platforms. downloadFile and
downloadThumbnail take options as an optional 3rd argument so they can
inherit SecretManagerOptions.requestTimeoutMs instead of only accepting
an explicit override; the options-first reorder consistent with the
rest of this file is deferred to the next major version, logged in
SDK-V18-BREAKING-CHANGES.html alongside KSM-1265's cachingPostFunction
removal.

Node's timeout error message no longer includes the request URL's
query string. File download/thumbnail/upload URLs from the storage
backend carry an AWS SigV4 signature there (an 8-hour bearer
credential, confirmed against the backend's DownloadRequestFactory),
which a timeout message would otherwise leak into whatever logs the
caller's error handler writes to.
…-1209 review fixes)

Follow-up to the review on PR #1136.

The AbortController deadline was cleared in the response callback, before
fetchData read a byte, so nothing bounded the response body. A server that
sent headers and then stalled hung the caller forever, which the previous
socket-timeout version had caught. Hold the deadline until the body ends,
and wire the response stream's error event so a mid-body socket failure
rejects rather than leaving the promise pending. fileUpload had the same
gap on its own response object; fixed the same way.

Validate the timeout in one place. 0, negatives, NaN and Infinity all
collapse to a near-instant setTimeout, so they now raise an Error instead
of silently killing every request under a message naming a value that was
never applied; values past setTimeout's 32-bit ceiling clamp rather than
truncating to 1ms. Plain Error, not KeeperError, matching this file's
existing convention for caller-input/config problems.

Also from the review:
- downloadFile/downloadThumbnail take options and inherit requestTimeoutMs,
  keeping timeoutMs as the second argument to avoid stacking a second
  breaking change onto KSM-1265's in the same minor
- cachingPostFunction/createCachingFunction forward the trailing arguments
- browser timeouts raise KeeperError instead of a raw DOMException
- deadlineSignal moved to an internal module, off the public node surface
- CHANGELOG corrected: it claimed the trickle case was closed when it was not

truncateUrlForError (the CWE-532 query-string redaction on timeout error
messages) is preserved and now covers both platforms uniformly via the
shared timeoutError() helper.

Tests: 73 to 164. The https mock now emits real response events, so the
body path is executed; every wiring point from SecretManagerOptions to the
platform call is asserted. Verified against a local HTTPS server: stall,
trickle, mid-body reset and premature close are all bounded on both
platforms.

Co-authored-by: Stas Schaller <sschaller@keepersecurity.com>
…ew round's gaps (KSM-1209)

Discovered while verifying the new file-upload example (KSM-1328):
fileUpload() resolves off headers alone and never reads the response
body. The comment already on this line (from the KSM-1209 review-fix
round) correctly identifies that fact for the unhandled-'error' case,
but the same unconsumed body also leaves the socket open, which keeps
the event loop alive - a script with no other pending work never
exits on its own after a successful upload. res.resume() discards the
body without buffering it, since nothing here reads it anyway.

Verified against Dev-CA: same script hangs (exit code 124) without
this fix, exits cleanly (code 0) with it, no process.exit() needed on
the caller's end.

Second round of fixes to PR #1136's own review (the 36b73f1 commit
above), addressing the follow-up CHANGES_REQUESTED pass plus the
non-blocking items from that same review:

- cachingPostFunction/createCachingFunction no longer treat a
  deliberate client-side timeout the same as a real network failure;
  a KeeperError from timeoutError() now propagates instead of
  returning a synthetic success built from stale cache
- downloadFile's call sites in the Azure DevOps task and the
  hello-secret example were passing an options object into the
  timeoutMs slot, throwing on every call; restored the missing
  `undefined` placeholder
- resolveTimeoutMs now rejects any value below 1, not just <= 0, so a
  fractional timeout like 0.5 can no longer floor to an instant abort
- validateTimeoutMs now returns the resolved, clamped value instead of
  the raw input, so a custom queryFunction or the offline-cache
  helpers never see an over-max or fractional timeout unclamped
- postQuery validates requestTimeoutMs once, up front, before any
  storage write or payload encryption, and reuses the resolved value
  across retries instead of re-validating it every iteration
- extracted armRequest() in nodePlatform.ts so get/post/fileUpload
  share one abort/error wiring implementation instead of three copies;
  each now wraps its request()/https.request() call in try/catch so a
  synchronous throw clears the deadline timer instead of leaking it
- uploadFile gains its own optional timeoutMs argument, matching
  downloadFile/downloadThumbnail
- DEFAULT_REQUEST_TIMEOUT_MS is now exported from the browser entry
  point, not just the Node one
- the custom-caching-function-support example now forwards timeoutMs
  to postFunction instead of dropping it
- reworded the downloadFile comment: it cited KSM-1265 as
  "already-shipped" (it is not, that PR is still under review) and
  named a ticket in a source comment; also notes that
  allowUnverifiedCertificate isn't honored here since platform.get has
  no such parameter
- CHANGELOG amended in place on the existing KSM-1209 entry to cover
  the behavior changes above

Also fixed: the fileUpload tests' MockResponse had no resume() method,
so they broke as soon as the drain fix above added that call; added a
jest.fn() stub.

Tests: 164 to 181.
…exit (KSM-1209) (#1148)

Discovered while verifying the new file-upload example (KSM-1328):
fileUpload() resolves off headers alone and never reads the response
body. The comment already on this line (from the KSM-1209 review-fix
round) correctly identifies that fact for the unhandled-'error' case,
but the same unconsumed body also leaves the socket open, which keeps
the event loop alive - a script with no other pending work never
exits on its own after a successful upload. res.resume() discards the
body without buffering it, since nothing here reads it anyway.

Verified against Dev-CA: same script hangs (exit code 124) without
this fix, exits cleanly (code 0) with it, no process.exit() needed on
the caller's end.
@stas-schaller
stas-schaller force-pushed the feature/KSM-1209-js-request-timeout branch from a87a56a to f1a454c Compare September 2, 2026 18:56

@mgallego-keeper mgallego-keeper 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.

Summary

Follow-up to the three rounds already on this PR. This pass empirically reproduced the core timing mechanism against live servers, ran mutation tests against the existing suite, and cross-checked two Jira tickets (KSM-1342, KSM-1343) filed during an earlier pass on this PR that never made it into this review thread. KSM-1343 (the Azure DevOps extension) is being handled separately via its own release and isn't covered below. The rest are new correctness findings plus real test-coverage gaps in what's already on this branch.

Correctness / security

  1. (HIGH) Quadratic response-body accumulation turns this PR's own deadline into a spurious failure on large downloads (KSM-1342). src/node/nodePlatform.ts:214-218's fetchData still reallocates and copies the entire accumulated buffer on every data event (Buffer.concat([retVal.data, data])), O(n^2) in body size. Pre-existing on the release branch, but this PR is what turns it from "slow" into "fails": there was no deadline before, so a large download just took longer and still completed; now the deadline covers the whole response body, so the O(n^2) CPU cost alone can exceed it. Measured at the shipped 30s default: a 100MB body (delivered over loopback in ~100ms) times out at ~30.05s with KeeperError: ...timed out after 30000ms. Replacing the accumulation with an array of chunks concatenated once at res.on('end', ...) (preserving the existing null for-empty-body behavior at line 212, since downloadFile passes the value straight to platform.decrypt with no null check) resolves it cleanly: confirmed linear scaling afterward with the full suite still green. The fix is small, isolated to this one function, and untangled from the timeout plumbing itself; recommend folding it in here rather than shipping the regression for a fast-follow.

  2. (HIGH) The new timeout/network-failure carve-out only recognizes KeeperError, so a plain validation error gets silently swallowed into a fake cache hit. node/localConfigStorage.ts:82 (cachingPostFunction) and browser/localConfigStorage.ts:224 (createCachingFunction)'s if (e instanceof KeeperError) throw e (added to fix round 2's "timeout served as fake success" bug) doesn't cover resolveTimeoutMs's validation errors, which deadline.ts:37 throws as a plain Error by explicit design (see the comment at deadline.ts:28-30: "matching this SDK's convention that KeeperError signals a failed interaction with Keeper's backend, not a caller-input/config mistake"). Calling either caching helper with an invalid timeoutMs (e.g. 0) falls through the instanceof KeeperError check, and with a cache present silently returns stale data as a 200; without one, throws the unrelated-sounding 'Cached value does not exist' instead of the real validation message. The guard's default is backwards for safety: it currently treats anything that isn't KeeperError as worth falling back on, when it should default to re-throwing and allowlist only the specific failure modes that actually deserve a cache fallback.

  3. (browser-only) The same guard can now discard a good response. browser/localConfigStorage.ts:211-224: the guard wraps both the platform.post call and the subsequent storage.saveBytes('cache', ...) write (line 217). IndexedDB write failures (KSM-1332, already merged) are wrapped as KeeperError. So a fresh response can succeed, then the cache write fails (quota exceeded, private browsing, blocked upgrade); the guard re-throws immediately, discarding the already-obtained fresh secrets instead of returning them or falling back to stale cache. Node's equivalent doesn't have this problem, since a failed fs write there throws a plain Error. Worth special-casing "the fresh response itself already succeeded" so a same-call cache-write failure can't take down an otherwise-successful request.

  4. uploadFile validates its timeout after a real network side effect. keeper.ts:1504-1508: prepareFileUploadPayload (1505) and postQuery(options, 'add_file', ...) (1506, which round-trips to the backend allocating an upload placeholder/URL) both run before validateTimeoutMs is ever called, inline at the platform.fileUpload call on line 1508. uploadFile(options, record, file, 0) with an otherwise-valid options.requestTimeoutMs throws only after that allocation, and the record/payload already carry a fileRef to content that was never uploaded. postQuery itself validates up front for exactly this reason (comment at keeper.ts:798-800); uploadFile's own override wasn't brought in line with that same invariant.

  5. The shipped example still has the bug the SDK itself just fixed. examples/javascript/custom-caching-function-support/hello.js: cachingPostFunction (line 16) forwards timeoutMs into postFunction now, but its catch block (line 31) never got the instanceof KeeperError carve-out added to the real implementations; it unconditionally logs and falls through to the cache-read fallback (lines 32-48) regardless of error type. This file also doesn't import KeeperError at all today, so the fix needs that added too. Anyone copying this example, its entire stated purpose, gets the "deliberate timeout served as fake cache success" bug reintroduced in their own code.

Test coverage

  1. Structural gap: examples/ and integration/ can never be exercised by this package's own suite, regardless of the diff. jest.config.js's roots is ["<rootDir>/test"], scoped inside sdk/javascript/packages/core; the example and integration directories are outside that package entirely. This is part of why round 2's two crash sites and item 5 above can all exist without any automated check ever running against them.
  2. AbortController-missing fallback path has zero coverage. deadline.ts:57-59's typeof AbortController === 'undefined' branch: deleting that guard entirely (always constructing an AbortController) leaves the full suite at 228/228 green.
  3. Two round-2 fixes are only partially tested. keeper.ts:801's validate-before-side-effect ordering is correct, but the existing test only asserts the query function was never called, not that the serverPublicKey/serverPublicKeyId storage writes (802-807) were actually skipped; the "no persistent side effect" half of that fix has no real coverage. Similarly, nothing asserts that postQuery's custom-queryFunction path receives the clamped (not raw) timeout for an over-MAX_REQUEST_TIMEOUT_MS input; only the direct downloadFile/downloadThumbnail paths are checked for that.
  4. New test is real-filesystem-dependent and reproducibly flaky. test/cachingFunctions.test.ts:61-64 ("a non-timeout failure still falls back to cache") relies on cachingPostFunction's hardcoded relative 'cache.dat' path resolving to nothing, with no mock and no cleanup. Confirmed by planting a stray cache.dat in the working directory before running the suite: the test flips from an expected rejection to a resolved 200. The browser side of the same describe.each (line 26) is unaffected, since it uses inMemoryStorage({}); only the node case shares real, un-isolated state with anything else that runs from this directory, including item 5's example.

API surface / robustness

  1. downloadFile/downloadThumbnail place options as the 3rd positional argument, after timeoutMs (keeper.ts:1494,1499), while uploadFile and everything else in this file places options first. The comment at keeper.ts:1485-1490 explains this was deliberate, to avoid stacking a second breaking change onto KSM-1265's in the same minor; that's a reasonable call, and worth keeping as-is for that reason. Flagging only because the inconsistency itself is what produced round 2's two crash sites, and remains a live footgun for any future caller who doesn't happen to read that specific comment.
  2. armRequest's manual signal.addEventListener('abort', ...) (nodePlatform.ts:242) races Node's own internal signal handling on the same AbortSignal. Confirmed the SDK's handler wins today on Node 22, but that's current event-emission ordering, not a documented contract. browserPlatform.ts's pattern (checking signal.aborted synchronously inside the existing error handler, via asTimeout) is spec-guaranteed and would remove the race rather than relying on today's timing.
  3. postQuery validates and resolves requestTimeoutMs once up front (keeper.ts:801), but each throttle/key-rotation retry gets its own fresh full timeout budget, and the sleep between retries is itself unbounded. Under sustained throttling a call can legitimately run for many minutes even with a small configured requestTimeoutMs. Not necessarily wrong, but worth a CHANGELOG line, since "bounded request timeout" currently reads as per-call rather than per-attempt.
  4. browserPlatform.ts's fileUpload (line 444) never reads or cancels its response body, unlike the Node counterpart this same PR just fixed for exactly that reason (res.resume(), to stop the process staying alive). Lower severity here, since there's no demonstrated hang, but it's the same class of gap, left open on the other platform.
  5. Minor duplication: the KeeperError carve-out and its comment are hand-copied into both platform files instead of factored into a shared helper (both already import from deadline.ts); item 3 above is a direct consequence of that duplication drifting once already. Separately, get/post/fileUpload (nodePlatform.ts) each still repeat an identical try/catch around synchronous request construction, in the same commit that extracted armRequest specifically to stop duplicating the adjacent abort/error wiring.

Recommendation

Requesting changes on items 1 through 5: item 1 is a direct, measured regression in this PR's own core mechanism; items 2, 3, and 5 all reintroduce some form of "the offline-cache fallback masks a failure as success," which is exactly what round 2 flagged and this round's fix only partially closed; item 4 risks a record left with a fileRef pointing at content that was never uploaded. Items 6 through 14 are worth closing out here given how many rounds this ticket has already needed, but don't need to block on their own.

Out of scope for a JS-core release: the extension has its own
independent release track (currently mid-review as PR #983, v1.2.0)
and its package.json still pins core ^16.6.3, whose published
downloadFile only takes one argument, so this edit would break that
extension's own build regardless of argument slot. Already tracked
by KSM-1343; moved Triage to Backlog to pick up at the extension's
next release.

Reverts the downloadFile(file, undefined, options) call site,
downloadSecretFile's options param, and the SecretManagerOptions
import back to their pre-KSM-1209 state.
…1209)

Round-4 review, blocking items 1 and 11.

fetchData re-copied the whole accumulated response buffer on every
'data' event via Buffer.concat([retVal.data, data]), O(n^2) in body
size. This PR's own deadline turns that from "slow" into "fails": a
large-but-healthy download can now time out purely on the CPU cost
of its own buffering. Chunks are collected in an array and
concatenated once at 'end' instead; retVal.data still stays null for
a zero-length body, matching downloadFile's no-null-check assumption.

armRequest's own signal.addEventListener('abort', ...) was racing
Node's internal handling of the same signal (request()/https.request()
already destroys the request and emits 'error' on it when the passed-in
signal aborts). Confirmed against real Node (not mocked) that this
internal behavior fires with no application-level listener needed.
Removed the redundant listener; the existing req.on('error', ...)
handler now checks signal?.aborted to decide between our own
timeoutError and the raw error, mirroring browserPlatform.ts's
spec-guaranteed asTimeout pattern for the identical problem.

nodePlatform.test.ts's https.request mock never simulated this real
Node behavior (its MockRequest is a bare EventEmitter with no signal
wiring), so the fix left every deadline-firing test hanging until
Jest's own timeout. Fixed the mock to wire signal abort -> destroy +
error, matching verified real Node behavior. Reverting just the
armRequest change against the corrected mock reproduces the exact
race Mateo described (rejects with a plain Error instead of
KeeperError) before confirming the fix. Full suite 228/228, tsc clean.
…lures (KSM-1209)

Round-4 review, blocking items 2 and 3.

Both cachingPostFunction (node) and createCachingFunction (browser)
caught everything platform.post could throw and rethrew only
KeeperError, falling back to stale cache for anything else.
resolveTimeoutMs throws a plain Error, not a KeeperError, for an
invalid timeoutMs, by design (deadline.ts) - so a caller-input
mistake was falling through the same carve-out meant only for
transport failures, getting misread as "the request failed, use
stale cache" instead of surfacing the validation error. Both
functions now resolve/validate the timeout eagerly, before the
try/catch, via validateTimeoutMs (already exported, same pattern
postQuery and downloadFile/downloadThumbnail already use).

Separately, both functions wrapped the cache write for a *successful*
response inside the same try as the request itself. A write failure
(disk full on node; IndexedDB quota/private-browsing/blocked-upgrade
on browser, wrapped as KeeperError per KSM-1332) fell into the outer
catch and discarded the already-obtained fresh response, either
silently downgrading it to stale cache or throwing "Cached value does
not exist" - worse than just returning what was already fetched. The
cache write is now isolated in its own try/catch on both platforms;
a write failure no longer affects the response returned to the caller.

New tests in cachingFunctions.test.ts, each confirmed failing against
the pre-fix code first: an unusable timeoutMs is now rejected before
platform.post is ever called (previously silently accepted, since the
mocked platform.post in this test file bypasses the real internal
validation entirely); a cache-write failure on either platform no
longer discards a successful response. Full suite 232/228, tsc clean.
…arve-out (KSM-1209)

Round-4 review, blocking items 4 and 5.

uploadFile validated its own upload timeoutMs only at the
platform.fileUpload call, after prepareFileUploadPayload and
postQuery('add_file', ...) had already run - the latter allocates an
upload placeholder URL on the backend. An invalid value failed only
after that allocation, leaving a fileRef pointing at content that was
never uploaded. Now validated up front, before either side effect,
independent of postQuery's own internal validation of
options.requestTimeoutMs for the add_file call itself (a different
timeout budget). Regression coverage for this ordering is added in
the upcoming test-consolidation pass (test/timeout.test.ts already
has a "rejects invalid timeoutMs before platform.fileUpload" case
that needs strengthening to also prove add_file was never called).

The shipped custom-caching-function-support example still had the
exact bug this PR fixed in the real cachingPostFunction: its catch
block never checked for KeeperError, so a deliberate timeout fell
through to the stale-cache fallback like any other failure. Added the
same carve-out, mirroring the real implementation 1:1.

tsc --noEmit clean, full suite 232/232 (this example has no jest
coverage - jest.config.js's roots excludes examples/ entirely, tracked
separately since building test infra for one demo file is out of
scope here).
Round-4 review, non-blocking item 9.

'a non-timeout failure still falls back to cache' relied on
fs.readFileSync('cache.dat') failing because that file happened not
to exist in the working directory - no mock, no cleanup. Confirmed
flaky: planting a stray cache.dat before running the suite flips the
test from an expected rejection to a resolved 200. Mocked
fs.readFileSync to throw deterministically instead; no-op for the
browser variant of the same describe.each, which never touches fs.
Re-verified with a stray cache.dat planted - test now passes either
way. No assertion changed, full suite 232/232.
…-1209)

Round-4 review, non-blocking item 7.

deadline.ts's typeof AbortController === 'undefined' branch had zero
references anywhere in test/ - confirmed deleting it entirely still
left the full suite green (it crashes instead now: "AbortController
is not defined"). Two tests: deadlineSignal returns {signal:
undefined, timeoutMs: <resolved>, clear: <noop>} with
AbortController stubbed out; get still resolves normally end-to-end
through that same scenario, proving armRequest's signal?.aborted
check (post KSM-1209's earlier round-4 fix) tolerates a genuinely
undefined signal, not just one that hasn't aborted yet. Both
confirmed failing (a ReferenceError, not a normal test failure) with
the fallback branch temporarily removed, then restored. Full suite
234/234, tsc clean.
…ng bug found along the way (KSM-1209)

Round-4 review, non-blocking item 8.

Extended the existing requestTimeoutMs:0 rejection test to also set
options.serverPublicKey/serverPublicKeyId and assert storage stays
untouched for both after rejection - the existing test only proved
the network call was skipped, not the storage writes postQuery's
comment claims are also guarded.

That extension caught a real bug: fetchAndDecryptSecrets (getSecrets's
own call path) writes serverPublicKey/serverPublicKeyId to storage
unconditionally, before ever calling postQuery, so postQuery's own
validate-before-write ordering (added by this same PR) never got a
chance to guard this earlier, separate write. Confirmed via a
pre-existing test ("IL5 dynamic key - Layer 3") that this early write
is deliberate for a different reason - an IL5 dynamic key discovered
via a one-time token has to persist even if the call later fails for
an unrelated reason (that test's own scenario: missing clientId) - so
removing the write outright broke that intentional behavior (caught
immediately by the existing test failing). Fixed narrowly instead:
validateTimeoutMs(options.requestTimeoutMs) now runs immediately
before that write, so a caller-input mistake produces no side effects
at all, while a valid-but-later-failing call still gets the early
persist.

Also added a case proving getSecrets forwards the clamped (not raw
oversized) requestTimeoutMs to a custom queryFunction - previously
only downloadFile/downloadThumbnail's direct platform.get path was
proven clamped.

Full suite 235/235 (dist rebuilt before this run - keeper.test.ts
imports via '../', which resolves to dist, not src, so edits to
keeper.ts need a rebuild to be reflected there).
Round-4 review, non-blocking item 13.

fileUpload resolves off headers alone and never reads the body, same
gap the Node platform had (fixed earlier in this PR via res.resume()).
Lower severity here - no demonstrated hang in a browser context - but
the same class of leaving an unconsumed response stream dangling.
res.body?.cancel() drains it, swallowing any cancellation error since
nothing here needs the body anyway.

New regression test confirmed failing against the unfixed code first:
mocks a response whose body.cancel is a spy, asserts it was called.
The existing tests' default fetch mock has no body property at all,
so the fix's optional-chained call safely no-ops for them - full
suite 236/236, tsc clean.
Adds the O(n^2) buffering fix, the caching-fallback validation-ordering
and cache-write-isolation fixes, uploadFile's validate-before-side-effect
fix, the getSecrets write-ordering fix, the browser fileUpload body
drain, and the example fix to the existing entry rather than replacing
it.
…on block (KSM-1209)

Test-consolidation pass, found via an anti-pattern audit requested
separately from Mateo's review: this "request timeout propagation"
describe block (added within this same PR) almost entirely duplicated
test/timeout.test.ts (also added within this same PR) - same layer,
same import surface, same assertions differing only in magic numbers.

Removing it here, first, as a pure deletion; the few cases it had that
timeout.test.ts lacks (the MAX_REQUEST_TIMEOUT_MS-clamped case for
downloadFile/downloadThumbnail, uploadFile's explicit-timeout-wins
case, and the two round-8 gap-closing cases just added) get merged
into timeout.test.ts next, strengthened where they didn't actually
prove what their name claimed.

Removed now-unused imports (downloadFile, downloadThumbnail,
uploadFile, KeeperFile, KeeperRecord, MAX_REQUEST_TIMEOUT_MS) -
DEFAULT_REQUEST_TIMEOUT_MS stays, its own standalone test is
unrelated to the deleted block. tsc --noEmit clean, full suite
221/236 (15 tests removed, none of them irreplaceable - see the
merge that follows).
Test-consolidation pass, completing the split started in the previous
commit. timeout.test.ts is now the sole home for request-timeout
propagation tests, matching the deadline.test.ts precedent of one
dedicated file per concern.

Merged in, from the block deleted in the previous commit:
- the two round-8 gap-closing cases (no side effects from an invalid
  requestTimeoutMs, clamped forwarding through a custom queryFunction)
- downloadFile's MAX_REQUEST_TIMEOUT_MS-clamped case (not duplicated
  for downloadThumbnail, which already has its own single case proving
  it shares the same plumbing - re-testing every case on both would be
  the same anti-pattern this consolidation exists to fix)
- uploadFile's "explicit timeoutMs wins" case

Trimmed the invalid-timeout sweep from 5 values to 1 representative
value (0) - the other 4 are already unit-tested at the resolveTimeoutMs
level in deadline.test.ts and don't differentiate fixed/unfixed code
at this integration layer.

Strengthened uploadFile's invalid-timeout test, which didn't actually
prove what it claimed: it only asserted platform.fileUpload wasn't
called, true regardless of validation ordering since fileUpload is
the last call in the function either way. Confirmed by reverting the
ordering fix and finding the test still passed. Rewritten to use a
valid options.requestTimeoutMs with an invalid explicit timeoutMs
argument, isolating uploadFile's own validation from postQuery's
separate, pre-existing validation of options.requestTimeoutMs, and to
also assert the add_file network call was never made. Confirmed
failing against the unfixed ordering, then restored.

tsc --noEmit clean, full suite 220/220.
…-1209)

Round-4 review, non-blocking item 12. requestTimeoutMs bounds each
individual attempt inside postQuery's throttle/key-rotation retry
loop, not the call as a whole, and the sleep between retries is
itself unbounded - a call under sustained throttling can run longer
in total than the configured value. Not a code change (reviewer
flagged it as "worth a CHANGELOG line", not a defect), filed as
follow-up tickets KSM-1364 (item 6, examples/integration outside
jest's roots) and KSM-1365 (item 14, deferred dedup cleanup) rather
than fixed inline.
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.

2 participants