JavaScript SDK: add configurable request timeout (KSM-1209) - #1136
JavaScript SDK: add configurable request timeout (KSM-1209)#1136stas-schaller wants to merge 16 commits into
Conversation
mgallego-keeper
left a comment
There was a problem hiding this comment.
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
-
Node's
timeoutis an idle timer, not a deadline (medium).nodePlatform.ts(lines 213, 235, 272) passestimeouttohttps.request, which resets on every byte of socket activity.browserPlatform.ts(lines 335, 360, 386) usesAbortSignal.timeout(), a fixed wall clock deadline. Reproduced with a server trickling 1 byte every 150ms for 3s atrequestTimeoutMs=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. -
timeoutMs: 0means opposite things on each platform, and nothing validates it (medium).timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS(platform.ts:1defines the default) only substitutes onnull/undefined, so an explicit0passes 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 rawRangeErrorinstead ofKeeperErroron both platforms; values above2^32-1throw on browser but only warn and clamp on Node. Recommend validating/clampingtimeoutMsonce, inkeeper.ts, before it reaches either platform. -
downloadFile/downloadThumbnailcannot inheritSecretManagerOptions.requestTimeoutMs(medium).uploadFile(keeper.ts:1408) forwardsoptions.requestTimeoutMstoplatform.fileUpload, butdownloadFile/downloadThumbnail(keeper.ts:1398,1403) only accept an explicittimeoutMsargument, nooptionsparameter. A caller who setsrequestTimeoutMsonce, expecting it to bound downloads too, silently gets the 30s default instead. -
cachingPostFunction/createCachingFunctionsilently drop the new override (low). Both public exports (node/localConfigStorage.ts:54,browser/localConfigStorage.ts:151) declare only(url, transmissionKey, payload)and callplatform.postwith 3 args, sorequestTimeoutMsnever reachesplatform.postfor any consumer using the SDK's own offline cache helpers. -
Browser timeout errors are a raw
DOMException, neverKeeperError(low/medium). Node wraps its timeout innew KeeperError(...);browserPlatform.ts'sget/posthave no try/catch at all, andfileUpload's catch re-throws unchanged. Code written againstinstanceof KeeperError(the pattern the CHANGELOG implies) will silently miss timeouts on browser.
Breaking change risk
AbortSignal.timeout()is called unconditionally in every browser network call, with no feature detection (medium/high). Notypeofguard and no polyfill anywhere in the rollup browser build. On a runtime lacking this API (pre-2022 browsers, older embedded WebViews), everyget/post/fileUploadcall now fails immediately with aTypeError, 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
requestTimeoutMspropagation through the public API is completely untested (high). Zero occurrences ofrequestTimeoutMs/timeoutMsinkeeper.test.tsorthrottle.test.ts. Verified by mutation testing: droppingoptions.requestTimeoutMsfrom the call atkeeper.ts:800entirely still leaves the full suite at 73/73 passing.downloadFile/downloadThumbnail/uploadFileare never invoked by any test.- 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). nodePlatform.test.ts'shttpsmock never emits aresponseevent; a coverage run confirms the success path lines added by this PR (fetchDataand the three callback bodies) are never executed.DEFAULT_REQUEST_TIMEOUT_MS's value is only checked against itself; mutating30000to30left 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.
2d539c6 to
ae5bfce
Compare
ae5bfce to
1639412
Compare
…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.
cf85a37 to
a275e10
Compare
…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.
…-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>
…-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>
d41d497 to
36b73f1
Compare
mgallego-keeper
left a comment
There was a problem hiding this comment.
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
-
cachingPostFunctionandcreateCachingFunctionnow 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 anyupdateSecret/createSecret/deleteSecret/uploadFilecall 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. -
downloadFileis now called incorrectly at both of its only two real call sites (high).examples/javascript/hello-secret/hello.js:26andksm-azure-devops-secrets-task/index.ts:147both calldownloadFile(file, options), but the signature is(file, timeoutMs?, options?), so the options object binds totimeoutMsand every call throwsRequest 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 theundefinedplaceholder. 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. -
resolveTimeoutMsstill produces a near-instant timeout for one input range (medium).deadline.ts:39checks the raw value against<= 0before flooring, so a value strictly between 0 and 1 (e.g.0.5) passes validation, then floors to0, reproducing the exact "every request fails in a couple of milliseconds" failure this validation was added to prevent. Not covered bytest/deadline.test.ts. -
allowUnverifiedCertificatenow silently reachesplatform.postfromcachingPostFunction(medium).node/localConfigStorage.ts:62forwards 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. -
downloadFile/downloadThumbnail's newoptionsparameter silently ignoresallowUnverifiedCertificate(medium).keeper.ts:1404only readsoptions?.requestTimeoutMs;platform.gethas no TLS-bypass parameter at all, unlikeplatform.post. A caller who passes the same options object they use elsewhere reasonably expects the same TLS behavior and doesn't get it, silently. -
Node's armed deadline timer leaks on a synchronous
request()throw (low/medium).nodePlatform.ts:217(and thepost/fileUploadequivalents) armdeadlineSignal()before callingrequest(), with no try/finally. A malformed URL or invalid header throws synchronously; the promise still rejects correctly, butclear()is never reachable, so the timer and itsAbortControllerleak for the full deadline window.browserPlatform.tsavoids this with try/finally.
Comment accuracy
- The comment justifying
downloadFile's argument order cites the wrong ticket, inaccurately (medium).keeper.ts:1400saystimeoutMs"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" fortimeoutMsondownloadFile, 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
-
uploadFilehas no per-call timeout override (low/medium). UnlikedownloadFile/downloadThumbnail,keeper.ts:1418always uses the globalrequestTimeoutMs, 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. -
DEFAULT_REQUEST_TIMEOUT_MSis not exported from the browser entry point (low/medium).node/index.ts:9doesexport * from '../platform';browser/index.ts:9uses a narrow named list that omits it. A browser/bundler consumer importing the new constant gets nothing. The new regression test imports viamain, so this asymmetry isn't caught. -
The documented custom-
queryFunctionexample still drops the timeout silently (medium).examples/javascript/custom-caching-function-support/hello.js:18forwards only 4 of the 5 argumentspostQuerynow 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
-
validateTimeoutMsruns after persistent side effects inpostQuery(low).keeper.ts:801validates inside the retry loop, after storage writes and payload encryption already happened. A badrequestTimeoutMsstill mutates on-disk config before the validation error throws. -
The timeout value is resolved twice, and custom
queryFunctions get the unclamped raw value (low/medium).validateTimeoutMs(deadline.ts:82) callsresolveTimeoutMsonly to throw, then returns the raw input;deadlineSignalresolves it again to actually clamp it. A caller with a customqueryFunctionthat does its own naivesetTimeoutgets the raw, unclamped value, including values above the 32-bit ceiling thatMAX_REQUEST_TIMEOUT_MSexists to prevent, since that clamp only lives insidedeadlineSignal.
Code quality
- The abort/error-handler wiring is triplicated (low).
nodePlatform.ts'sget/post/fileUpload(andbrowserPlatform.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.
…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
left a comment
There was a problem hiding this comment.
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
-
The new
fileUploadresponse-drain fix has no test proving it (medium).nodePlatform.ts:333'sres.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 aresume = jest.fn()stub toMockResponseinnodePlatform.test.ts, purely so the existing tests do not crash on the new call. Nothing assertsresumewas actually invoked. Every other fix in this same commit got a precise behavioral test; this is the one exception. Suggest addingexpect(mockResponse.resume).toHaveBeenCalled()to the existing "fileUpload resolves as soon as the response headers arrive" test. -
resolveTimeoutMs's error message and doc comment no longer match its own boundary (low). The validation check moved fromtimeoutMs <= 0totimeoutMs < 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 aboveresolveTimeoutMs("must be a finite number above zero") still describe the old boundary. A caller passing0.5now 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.
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.
a87a56a to
f1a454c
Compare
mgallego-keeper
left a comment
There was a problem hiding this comment.
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
-
(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'sfetchDatastill reallocates and copies the entire accumulated buffer on everydataevent (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 withKeeperError: ...timed out after 30000ms. Replacing the accumulation with an array of chunks concatenated once atres.on('end', ...)(preserving the existingnullfor-empty-body behavior at line 212, sincedownloadFilepasses the value straight toplatform.decryptwith 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. -
(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) andbrowser/localConfigStorage.ts:224(createCachingFunction)'sif (e instanceof KeeperError) throw e(added to fix round 2's "timeout served as fake success" bug) doesn't coverresolveTimeoutMs's validation errors, whichdeadline.ts:37throws as a plainErrorby explicit design (see the comment atdeadline.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 invalidtimeoutMs(e.g.0) falls through theinstanceof KeeperErrorcheck, 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'tKeeperErroras worth falling back on, when it should default to re-throwing and allowlist only the specific failure modes that actually deserve a cache fallback. -
(browser-only) The same guard can now discard a good response.
browser/localConfigStorage.ts:211-224: the guard wraps both theplatform.postcall and the subsequentstorage.saveBytes('cache', ...)write (line 217). IndexedDB write failures (KSM-1332, already merged) are wrapped asKeeperError. 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 failedfswrite there throws a plainError. Worth special-casing "the fresh response itself already succeeded" so a same-call cache-write failure can't take down an otherwise-successful request. -
uploadFilevalidates its timeout after a real network side effect.keeper.ts:1504-1508:prepareFileUploadPayload(1505) andpostQuery(options, 'add_file', ...)(1506, which round-trips to the backend allocating an upload placeholder/URL) both run beforevalidateTimeoutMsis ever called, inline at theplatform.fileUploadcall on line 1508.uploadFile(options, record, file, 0)with an otherwise-validoptions.requestTimeoutMsthrows only after that allocation, and the record/payload already carry afileRefto content that was never uploaded.postQueryitself validates up front for exactly this reason (comment atkeeper.ts:798-800);uploadFile's own override wasn't brought in line with that same invariant. -
The shipped example still has the bug the SDK itself just fixed.
examples/javascript/custom-caching-function-support/hello.js:cachingPostFunction(line 16) forwardstimeoutMsintopostFunctionnow, but its catch block (line 31) never got theinstanceof KeeperErrorcarve-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 importKeeperErrorat 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
- Structural gap:
examples/andintegration/can never be exercised by this package's own suite, regardless of the diff.jest.config.js'srootsis["<rootDir>/test"], scoped insidesdk/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. AbortController-missing fallback path has zero coverage.deadline.ts:57-59'stypeof AbortController === 'undefined'branch: deleting that guard entirely (always constructing anAbortController) leaves the full suite at 228/228 green.- 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 theserverPublicKey/serverPublicKeyIdstorage writes (802-807) were actually skipped; the "no persistent side effect" half of that fix has no real coverage. Similarly, nothing asserts thatpostQuery's custom-queryFunctionpath receives the clamped (not raw) timeout for an over-MAX_REQUEST_TIMEOUT_MSinput; only the directdownloadFile/downloadThumbnailpaths are checked for that. - 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 oncachingPostFunction's hardcoded relative'cache.dat'path resolving to nothing, with no mock and no cleanup. Confirmed by planting a straycache.datin the working directory before running the suite: the test flips from an expected rejection to a resolved 200. The browser side of the samedescribe.each(line 26) is unaffected, since it usesinMemoryStorage({}); 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
downloadFile/downloadThumbnailplaceoptionsas the 3rd positional argument, aftertimeoutMs(keeper.ts:1494,1499), whileuploadFileand everything else in this file placesoptionsfirst. The comment atkeeper.ts:1485-1490explains 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.armRequest's manualsignal.addEventListener('abort', ...)(nodePlatform.ts:242) races Node's own internal signal handling on the sameAbortSignal. 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 (checkingsignal.abortedsynchronously inside the existing error handler, viaasTimeout) is spec-guaranteed and would remove the race rather than relying on today's timing.postQueryvalidates and resolvesrequestTimeoutMsonce 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 configuredrequestTimeoutMs. Not necessarily wrong, but worth a CHANGELOG line, since "bounded request timeout" currently reads as per-call rather than per-attempt.browserPlatform.ts'sfileUpload(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.- Minor duplication: the
KeeperErrorcarve-out and its comment are hand-copied into both platform files instead of factored into a shared helper (both already import fromdeadline.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 extractedarmRequestspecifically 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.
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
AbortController, not Node's sockettimeoutoption, 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 aKeeperErrornaming 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)0, negatives, fractional values below 1ms,NaN, andInfinityare rejected with a plainError(a caller-input mistake, not aKeeperError) rather than silently collapsing to a near-instant timeout that fails every request. Values abovesetTimeout's 32-bit ceiling are clamped rather than truncated to 1ms.SecretManagerOptions.requestTimeoutMs, which now also reachesdownloadFile,downloadThumbnail,uploadFile(each also gains its own additive, optionaltimeoutMsargument that wins over the configured default), and thecachingPostFunction/createCachingFunctionoffline-cache helpers.downloadFileanddownloadThumbnailkeeptimeoutMsas their second argument and gain the options object as an additive third argument, so an explicittimeoutMsstill wins over it.armRequest's own abort listener raced Node's internal handling of the same signal, sincerequest()/https.request()already destroys the request and emitserroron it when the passed-in signal aborts. Removed the redundant listener; the existing error handler now checkssignal.abortedto decide between the SDK's own timeout message and the raw error, mirroring the browser platform's existing pattern for the same problem.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 validatetimeoutMsbefore 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.allowUnverifiedCertificateis forwarded through that same cache path for consistency with the direct request path.uploadFilenow 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.getSecretsno longer persists a caller-suppliedserverPublicKey/serverPublicKeyIdto storage before validatingrequestTimeoutMs; an invalid value now produces no side effects at all.fileUploadnever 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.DEFAULT_REQUEST_TIMEOUT_MSis exported from both the Node and browser entry points. On a runtime with noAbortController, the SDK keeps working with no timeout enforced rather than failing every request.custom-caching-function-supportexample now carries the same deliberate-timeout-vs-transport-failure distinction as the real implementation it demonstrates.Maintenance
test/keeper.test.tsandtest/timeout.test.tshad grown near-identical describe blocks;test/timeout.test.tsis now the sole home for this coverage.AbortController-unavailable fallback path, previously untested.Testing
Full suite 220/220 passing;
tsc --noEmitclean. Notable coverage:test/deadline.test.ts(timeout resolution/validation/clamping,AbortController-unavailable fallback),test/timeout.test.ts(requestTimeoutMspropagation fromSecretManagerOptionsthrough every network call site, includinggetSecrets/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/requestTimeoutMsare optional and default to 30s; existing calls that omit them behave the same except for gaining the bound.Related Issues
examples//integration/are outside this package's jest scope), KSM-1365 (deferred cleanup: duplicated request wiring innodePlatform.ts, duplicated caching carve-out across platforms)