fix(puter-js): time out requests that make no progress

- buildXhr starts an idle clock on send, reset by state changes and body
  progress (upload progress only on requests already carrying a bearer)
- read-safe requests get 60 s while progress is observable, everything
  else 15 min; timeout: 0 on fetchUrl/driverCall/sendWithRetry turns it off
- a timed-out read replays once; writes and AI calls never replay
- timeouts reject with code request_timeout on fetchUrl (TypeError),
  driverCall, streams and the legacy XHR path
- document the limits in rate-limits-and-quotas and the chat stream note
This commit is contained in:
Daniel Salazar committed 2026-10-08 18:34:22 -07:00
1 parent 6fb8329359
commit 7d1e0074ca
7 files changed
+497 -39

No files matched your search

+6 -2
View File
@@ -123,8 +123,12 @@ if the provider stops sending the response before it is complete. Check for
error chunks in your `for await...of` loop before treating the response as complete.
If the network connection fails after streaming starts, the loop throws
`{ message, code: "network_error" }`. Use `try...catch` around the call and loop
to handle these failures.
`{ message, code: "network_error" }`. If the stream sends nothing for 15
minutes, it throws `{ message, code: "request_timeout" }`; a call that gets no
response at all for 15 minutes rejects the same way. Neither is retried, since
the model may already have run (see
[Request timeouts](/rate-limits-and-quotas/#request-timeouts)). Use
`try...catch` around the call and loop to handle these failures.
### Stopping a stream
+14
View File
@@ -512,6 +512,19 @@ Every account has a filesystem quota (100 MiB free; paid plans add more). It cou
An upload reserves its declared size, minus the size of any file it replaces, from the moment it starts. The reservation is released when the upload completes (the real size counts instead), is cancelled, or expires (15 minutes after starting by default, at most 1 hour, plus 5 minutes' grace). An abandoned upload holds its space until it expires. A `startBatchWrite` that doesn't fit fails as a whole, up front. `space()` counts stored bytes only, not reservations.
## Request timeouts
Puter.js stops a request that makes no progress for too long and rejects it with `code: "request_timeout"`. The clock restarts whenever the request moves: a state change, response bytes arriving, or upload progress where the runtime reports it. A download or stream that keeps moving is never cut off, however long it takes. The file contents `puter.fs.upload()` and `puter.fs.write()` send are not subject to this limit.
| Request | Stopped after this long without progress |
| ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
| Reads: read-only calls such as `puter.kv.get()`, `puter.kv.list()`, `puter.apps.get()` and `puter.hosting.list()` | 60 s |
| Everything else, including writes, AI calls, streamed responses and every `puter.fs` call | 15 min |
A read is held to 60 s only while progress can be seen: before the response headers arrive, and after them once the body has started arriving. Node.js, service workers and Puter Workers report no progress for a non-streamed body, so there a read moves to the 15 min limit once its headers are in.
A read that times out is retried once. A write or AI call that times out is never retried, because the server may already have done the work: check before repeating it. A stream that stalls throws `{ message, code: "request_timeout" }` from its loop.
## What happens when you hit a limit
| Status | `code` | Meaning | What to do |
@@ -520,6 +533,7 @@ An upload reserves its declared size, minus the size of any file it replaces, fr
| `402` | `insufficient_funds` | Monthly credit spent | The user buys credit or upgrades; the allowance resets next month. |
| `402` | `subscription_required` | The endpoint requires a paid plan | The user upgrades. Retrying or waiting doesn't help. |
| `413` | `storage_limit_reached` | Storage quota reached | The user deletes files or upgrades. |
| none | `request_timeout` | No progress within the [request timeout](#request-timeouts) | Retry if the call is safe to repeat. A write or AI call may already have run. |
Errors are JSON: `{ "error": …, "message": …, "code": … }`.
+139 -16
View File
@@ -158,12 +158,85 @@ async function resolveReauth(resp, { interactive = true, sentToken } = {}) {
return null;
}
// -- Idle timeout --
// A request that makes no progress for its limit is aborted and fails with
// `request_timeout`; any progress restarts the clock. Read-safe requests get
// the short limit only while progress is observable: before headers, or once
// body progress has been seen (the XHR shim reports none for a buffered body).
const READ_IDLE_TIMEOUT_MS = 60_000;
const IDLE_TIMEOUT_MS = 15 * 60_000;
const requestTimeoutError = () => ({
message: 'Request timed out.',
code: 'request_timeout',
});
/**
* Starts the idle clock for a request about to be sent. On expiry the XHR is
* flagged `_puterTimedOut` and aborted, so its `abort` listeners can tell a
* timeout from a cancellation.
*
* @param {XMLHttpRequest} xhr
* @param {{ timeout?: number }} spec - `timeout` replaces both limits; `0`
* turns the clock off.
* @param {boolean} readSafe - Eligible for the short limit.
* @param {boolean} watchUpload - Count upload progress too.
* @returns {() => void} Stops the clock.
*/
function watchIdle(xhr, spec, readSafe, watchUpload) {
if (spec.timeout === 0) return () => {};
let timer;
let stopped = false;
let bodyProgress = false;
const limit = () => {
if (spec.timeout !== undefined) return spec.timeout;
if (!readSafe) return IDLE_TIMEOUT_MS;
if (xhr.readyState < 2) return READ_IDLE_TIMEOUT_MS;
if (isNdjson(xhr.getResponseHeader('content-type'))) {
return IDLE_TIMEOUT_MS;
}
return bodyProgress ? READ_IDLE_TIMEOUT_MS : IDLE_TIMEOUT_MS;
};
const stop = () => {
stopped = true;
clearTimeout(timer);
};
const arm = () => {
if (stopped) return;
clearTimeout(timer);
timer = setTimeout(() => {
stop();
xhr._puterTimedOut = true;
xhr.abort();
}, limit());
};
const onBody = () => {
bodyProgress = true;
arm();
};
xhr.addEventListener('readystatechange', () => {
// DONE comes before load/error/abort; the XHR shim reaches it on a
// failed fetch, where its headers can't be read.
if (xhr.readyState === 4) return stop();
if (xhr.readyState === 3) bodyProgress = true;
arm();
});
xhr.addEventListener('progress', onBody);
if (watchUpload) xhr.upload?.addEventListener('progress', arm);
for (const type of ['load', 'error', 'abort', 'timeout']) {
xhr.addEventListener(type, stop);
}
arm();
return stop;
}
/**
* The one XHR builder both `initXhr` (utils.js) and `fetchUrl` wrap. Opens the
* request, applies headers/credentials/responseType, and stashes the whole
* `spec` on `xhr._puterReq` as the single replay representation — any attempt
* (reauth, transient) rebuilds it by calling `buildXhr(spec)` again, which
* re-reads the live token when `includePuterAuth`.
* re-reads the live token when `includePuterAuth`. Sending it starts the idle
* clock (see `watchIdle`).
*
* @param {Object} spec
* @param {string} spec.url - Full request URL.
@@ -177,9 +250,13 @@ async function resolveReauth(resp, { interactive = true, sentToken } = {}) {
* @param {boolean} [spec.withCredentials=true] Default is `true`
* @param {string} [spec.responseType=''] Default is `''`
* @param {Object} [spec.logId] - Pre-built apiCallLogger request id.
* @param {number} [spec.timeout] - Idle limit in ms, replacing the defaults;
* `0` turns it off.
* @param {{ readSafe?: boolean }} [opts] - `readSafe` selects the short idle
* limit.
* @returns {XMLHttpRequest}
*/
function buildXhr(spec) {
function buildXhr(spec, { readSafe = false } = {}) {
const {
url,
method = 'GET',
@@ -215,7 +292,16 @@ function buildXhr(spec) {
const origSend = xhr.send.bind(xhr);
xhr.send = function (body) {
spec.body = body;
return origSend(body);
// An upload listener makes the browser preflight a cross-origin
// request. One carrying a bearer is preflighted already; driver calls
// go out as simple requests and must stay that way.
const stopIdle = watchIdle(xhr, spec, readSafe, !!bearer);
try {
return origSend(body);
} catch (e) {
stopIdle();
throw e;
}
};
if (globalThis.puter?.apiCallLogger?.isEnabled()) {
@@ -523,13 +609,16 @@ async function resolveVerificationGate(code, factors) {
/**
* Send one attempt. Resolves with a terminal outcome: { streamed: true, xhr,
* lineStream } — NDJSON, resolved at HEADERS_RECEIVED { xhr, status } —
* buffered response (any HTTP status) { networkError: true, xhr } — transport
* error Rejects only on abort. Per-line semantics (usage/email prompts,
* `toString`) belong to the caller's `shapeStream`.
* buffered response (any HTTP status) { networkError: true, timedOut?, xhr } —
* transport error or idle timeout. Rejects only on abort. Per-line semantics
* (usage/email prompts, `toString`) belong to the caller's `shapeStream`.
*
* @param {Object} spec
* @param {boolean} [readSafe=false] - Selects the short idle limit.
*/
function sendOnce(spec) {
function sendOnce(spec, readSafe = false) {
return new Promise((resolve, reject) => {
const xhr = buildXhr(spec);
const xhr = buildXhr(spec, { readSafe });
let streamed = false;
let responseComplete = false;
@@ -612,16 +701,17 @@ function sendOnce(spec) {
responseComplete = true;
signalStreamUpdate?.();
});
xhr.addEventListener('timeout', () => {
const error = { message: 'Network request timed out.', code: 'network_error' };
failStream(error);
resolve({ networkError: true, xhr });
});
const timedOut = () => {
failStream(requestTimeoutError());
resolve({ networkError: true, timedOut: true, xhr });
};
xhr.addEventListener('timeout', timedOut);
xhr.addEventListener('error', () => {
failStream({ message: 'Network request failed.', code: 'network_error' });
resolve({ networkError: true, xhr });
});
xhr.addEventListener('abort', () => {
if (xhr._puterTimedOut) return timedOut();
const error = spec.signal?.reason ??
new DOMException('Aborted', 'AbortError');
failStream(error);
@@ -657,6 +747,14 @@ function sendOnce(spec) {
async function classifyRetry(outcome, ctx) {
if (outcome.streamed) return null; // committed stream — never retried
// An idle timeout replays once, on the read-safe rule: a fresh connection
// is the cure for a dead one, and a stall that repeats isn't transient.
if (outcome.timedOut) {
if (ctx.done.has('timeout')) return null;
const decision = transientRetry(ctx);
if (decision) ctx.done.add('timeout');
return decision;
}
if (outcome.networkError) return transientRetry(ctx);
const { xhr, status } = outcome;
@@ -722,10 +820,11 @@ async function classifyRetry(outcome, ctx) {
* outcome, and retries on reauth / transient causes; otherwise hands the
* outcome to `shape`.
*
* @param {Object} spec - BuildXhr spec (+ optional buildBody, signal).
* @param {Object} spec - BuildXhr spec (+ optional buildBody, signal,
* timeout).
* @param {Object} opts
* @param {boolean} [opts.retrySafe=false] - Eligible for transient backoff
* retry. Default is `false`
* retry and the short idle limit. Default is `false`
* @param {boolean} [opts.retryGated=true] - Eligible for 429 backoff retry,
* regardless of method (the gate rejects before the handler runs). Default is
* `true`
@@ -749,7 +848,7 @@ async function sendWithRetry(
};
while (true) {
ctx.attempt++;
const outcome = await sendOnce(spec);
const outcome = await sendOnce(spec, retrySafe);
if (outcome.streamed)
return shapeStream(outcome.lineStream, outcome.xhr);
const decision = await classifyRetry(outcome, ctx);
@@ -836,6 +935,8 @@ function dedupe(key, factory, { windowMs = 2000 } = {}) {
* 401 may raise sign-in UI. Pass `false` for requests the user didn't ask for
* (boot telemetry, cache warmers): the stale token is dropped silently and
* the 401 surfaces to the caller instead. Default is `true`
* @param {number} [opts.timeout] - Idle limit in ms, replacing the defaults
* (see `watchIdle`); `0` turns it off.
* @param {Object} [opts.paginate] - Reserved for a later sprint step (ignored).
* @returns {Promise<PuterResponse>}
*/
@@ -853,6 +954,7 @@ function fetchUrl(url, opts = {}) {
retry,
dedupe: dedupeOpt,
interactiveReauth = true,
timeout,
} = opts;
const logId = logContext ?? {
@@ -872,6 +974,7 @@ function fetchUrl(url, opts = {}) {
signal,
logId,
interactiveReauth,
timeout,
};
// Read-safety: idempotent methods auto-retry; a POST read opts in with
@@ -892,6 +995,16 @@ function fetchUrl(url, opts = {}) {
return makeResponse(xhr, lineStream);
},
shape: async (outcome) => {
if (outcome.timedOut) {
if (loggingOn())
logRequest(logId, { error: requestTimeoutError() });
// A TypeError, as `fetch` rejects with, carrying the code.
const error = new TypeError(
`Network request to ${url} timed out`,
);
error.code = 'request_timeout';
throw error;
}
if (outcome.networkError) {
if (loggingOn())
logRequest(logId, {
@@ -1071,9 +1184,11 @@ function driverLineStream(lineStream, puter, upgradePrompt) {
* transform?: (result: unknown) => unknown;
* onError?: (error: unknown) => void;
* upgradePrompt?: UpgradePromptContext;
* timeout?: number;
* }} [opts]
* `readonly` marks the method retry-safe on transient failures (a
* rate/concurrency 429 replays either way — see GATE_REJECT_STATUS),
* `timeout` replaces the idle limits in ms (`0` turns it off),
* `transform` post-processes a successful result, `onError` is the legacy
* error callback the module APIs accept alongside the promise, and
* `upgradePrompt` is how the upgrade prompt names this method (defaulting to
@@ -1087,6 +1202,7 @@ async function driverCall(call, opts = {}) {
transform,
onError,
upgradePrompt,
timeout,
} = opts;
const puter = callInstance(call);
const promptContext = {
@@ -1121,6 +1237,7 @@ async function driverCall(call, opts = {}) {
responseType,
// Rebuilt per attempt, so a reauth replay carries the fresh token.
buildBody: () => callBody(call, puter),
timeout,
};
return await sendWithRetry(spec, {
@@ -1130,6 +1247,11 @@ async function driverCall(call, opts = {}) {
// Reauth and transient retries are already spent by the time the
// engine hands the outcome over, so this is terminal.
shape: async (outcome) => {
if (outcome.timedOut) {
const error = requestTimeoutError();
logCall(call, { error });
return fail(error);
}
if (outcome.networkError) {
logCall(call, { error: { message: 'Network error occurred' } });
return fail(outcome.xhr);
@@ -1228,6 +1350,7 @@ export {
fetchUrl,
isVerificationGateCode,
parseResponse,
requestTimeoutError,
resolveReauth,
resolveVerificationGate,
sendWithRetry,
+254 -6
View File
@@ -29,6 +29,7 @@ function installFakeXHR(program) {
this._respHeaders = {};
this.onreadystatechange = null;
this.onprogress = null;
this.upload = { addEventListener: vi.fn() };
instances.push(this);
}
open(method, url) {
@@ -55,18 +56,23 @@ function installFakeXHR(program) {
for (const [k, v] of Object.entries(headers))
this._respHeaders[k.toLowerCase()] = v;
}
_headersReceived() {
this.readyState = 2;
_setReadyState(state) {
if (this.readyState === state) return;
this.readyState = state;
this.onreadystatechange?.();
this.dispatchEvent(new Event('readystatechange'));
}
_headersReceived() {
this._setReadyState(2);
}
_progress(chunk) {
this.responseText += chunk;
this.readyState = 3;
this._setReadyState(3);
this.onprogress?.();
this.dispatchEvent(new Event('progress'));
}
_done() {
this.readyState = 4;
this.onreadystatechange?.();
this._setReadyState(4);
this.dispatchEvent(new Event('load'));
}
_networkError() {
@@ -685,6 +691,248 @@ describe('transient retry', () => {
});
});
describe('idle timeout', () => {
const READ_MS = 60_000;
const LONG_MS = 15 * 60_000;
const silent = () => {}; // never answers
const settledWith = async (promise) => {
try {
return { value: await promise };
} catch (error) {
return { error };
}
};
const PENDING = Symbol('pending');
const peek = (promise) => Promise.race([promise, Promise.resolve(PENDING)]);
beforeEach(() => {
globalThis.puter = {
authToken: 'tok',
APIOrigin: 'https://api.example',
env: 'nodejs',
};
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('times out a silent read after 60 s and replays it once', async () => {
const xhrs = installFakeXHR(silent);
const result = settledWith(fetchUrl('https://api.example/x'));
await vi.advanceTimersByTimeAsync(READ_MS - 1);
expect(await peek(result)).toBe(PENDING);
await vi.advanceTimersByTimeAsync(1);
await vi.advanceTimersByTimeAsync(250); // backoff before the replay
expect(xhrs.length).toBe(2);
await vi.advanceTimersByTimeAsync(READ_MS + 10_000);
const { error } = await result;
expect(error).toBeInstanceOf(TypeError);
expect(error.code).toBe('request_timeout');
expect(xhrs.length).toBe(2);
expect(vi.getTimerCount()).toBe(0);
});
it('gives a write 15 min and never replays it', async () => {
const xhrs = installFakeXHR(silent);
const result = settledWith(
fetchUrl('https://api.example/x', { method: 'POST', body: '{}' }),
);
await vi.advanceTimersByTimeAsync(LONG_MS - 1);
expect(await peek(result)).toBe(PENDING);
await vi.advanceTimersByTimeAsync(1);
const { error } = await result;
expect(error).toMatchObject({ code: 'request_timeout' });
await vi.advanceTimersByTimeAsync(LONG_MS);
expect(xhrs.length).toBe(1);
});
it('rejects a driver call with request_timeout and never replays it', async () => {
const xhrs = installFakeXHR(silent);
const result = settledWith(
driverCall({ iface: 'puter-chat-completion', method: 'complete', args: {} }),
);
await vi.advanceTimersByTimeAsync(LONG_MS - 1);
expect(await peek(result)).toBe(PENDING);
await vi.advanceTimersByTimeAsync(1);
expect((await result).error).toEqual({
message: 'Request timed out.',
code: 'request_timeout',
});
await vi.advanceTimersByTimeAsync(LONG_MS);
expect(xhrs.length).toBe(1);
});
it('replays a timed-out readonly driver call once', async () => {
const xhrs = installFakeXHR(silent);
const result = settledWith(
driverCall(
{ iface: 'puter-kvstore', method: 'get', args: { key: 'k' } },
{ readonly: true },
),
);
await vi.advanceTimersByTimeAsync(2 * READ_MS + 10_000);
expect((await result).error).toMatchObject({ code: 'request_timeout' });
expect(xhrs.length).toBe(2);
});
it('keeps a read alive while its body makes progress', async () => {
const xhrs = installFakeXHR(silent);
const result = settledWith(fetchUrl('https://api.example/x'));
const [xhr] = xhrs;
await vi.advanceTimersByTimeAsync(READ_MS - 10_000);
xhr._setHeaders(200, { 'content-type': 'application/json' });
xhr._headersReceived();
for (let i = 0; i < 5; i++) {
xhr._progress(' ');
await vi.advanceTimersByTimeAsync(READ_MS - 10_000);
}
xhr.responseText = '{"ok":true}';
xhr._done();
expect((await result).value.status).toBe(200);
expect(xhrs.length).toBe(1);
});
it('times out a read whose body stalls once progress was seen', async () => {
const xhrs = installFakeXHR((xhr) => {
xhr._setHeaders(200, { 'content-type': 'application/json' });
xhr._headersReceived();
xhr._progress('{');
});
const result = settledWith(fetchUrl('https://api.example/x'));
await vi.advanceTimersByTimeAsync(READ_MS);
expect(xhrs[0]._puterTimedOut).toBe(true);
await vi.advanceTimersByTimeAsync(250 + READ_MS);
expect((await result).error.code).toBe('request_timeout');
});
// The node/workerd shim reports no progress for a buffered body, so a
// read can't be held to 60 s once its headers are in.
it('moves a read to 15 min after headers until body progress is seen', async () => {
const xhrs = installFakeXHR((xhr) => {
xhr._setHeaders(200, { 'content-type': 'application/octet-stream' });
xhr._headersReceived();
});
const result = settledWith(fetchUrl('https://api.example/big'));
await vi.advanceTimersByTimeAsync(LONG_MS - 1);
expect(await peek(result)).toBe(PENDING);
expect(xhrs.length).toBe(1);
await vi.advanceTimersByTimeAsync(1);
expect(xhrs[0]._puterTimedOut).toBe(true);
// The replay finishes this time.
await vi.advanceTimersByTimeAsync(250);
xhrs[1].responseText = 'bytes';
xhrs[1]._done();
expect((await result).value.status).toBe(200);
});
it('gives a stream 15 min of silence before failing it', async () => {
installFakeXHR((xhr) => {
xhr._setHeaders(200, { 'content-type': 'application/x-ndjson' });
xhr._headersReceived();
xhr._progress('{"text":"a"}\n');
});
const response = await fetchUrl('https://api.example/stream');
const stream = response.stream();
expect((await stream.next()).value.text).toBe('a');
const read = settledWith(stream.next());
await vi.advanceTimersByTimeAsync(LONG_MS - 1);
expect(await peek(read)).toBe(PENDING);
await vi.advanceTimersByTimeAsync(1);
expect((await read).error).toEqual({
message: 'Request timed out.',
code: 'request_timeout',
});
});
it('lets upload progress reset the clock on a request with a bearer', async () => {
const xhrs = installFakeXHR(silent);
const result = settledWith(
fetchUrl('https://api.example/upload', {
method: 'POST',
includePuterAuth: true,
body: 'payload',
timeout: 1000,
}),
);
const [, onUpload] = xhrs[0].upload.addEventListener.mock.calls[0];
await vi.advanceTimersByTimeAsync(900);
onUpload();
await vi.advanceTimersByTimeAsync(900);
expect(await peek(result)).toBe(PENDING);
await vi.advanceTimersByTimeAsync(100);
expect((await result).error.code).toBe('request_timeout');
});
// A listener on `xhr.upload` would turn the driver call's simple CORS
// request into a preflighted one.
it('leaves upload progress alone on a driver call', async () => {
const xhrs = installFakeXHR(
respond({ body: { success: true, result: 1 } }),
);
await driverCall({ iface: 'puter-kvstore', method: 'get', args: {} });
expect(xhrs[0].upload.addEventListener).not.toHaveBeenCalled();
});
it('turns the clock off with timeout: 0', async () => {
installFakeXHR(silent);
const result = settledWith(
fetchUrl('https://api.example/x', { timeout: 0 }),
);
await vi.advanceTimersByTimeAsync(2 * LONG_MS);
expect(await peek(result)).toBe(PENDING);
expect(vi.getTimerCount()).toBe(0);
});
// The XHR shim marks a failed fetch DONE before dispatching `error`, and
// has no headers to read then.
it('settles a transport failure that reaches DONE without headers', async () => {
globalThis.puter.config = { autoRetry: false };
const xhrs = installFakeXHR((xhr) => {
xhr.getResponseHeader = () => {
throw new TypeError('no headers');
};
xhr._setReadyState(4);
xhr._networkError();
});
const result = settledWith(fetchUrl('https://api.example/x'));
expect((await result).error).toBeInstanceOf(TypeError);
expect(xhrs.length).toBe(1);
expect(vi.getTimerCount()).toBe(0);
});
it('clears the clock when a request settles', async () => {
installFakeXHR(respond({ body: { ok: true } }));
await fetchUrl('https://api.example/x');
expect(vi.getTimerCount()).toBe(0);
});
it('clears the clock when a request is cancelled', async () => {
installFakeXHR(silent);
const controller = new AbortController();
const result = settledWith(
fetchUrl('https://api.example/x', { signal: controller.signal }),
);
controller.abort();
expect((await result).error).toMatchObject({ name: 'AbortError' });
expect(vi.getTimerCount()).toBe(0);
});
});
describe('abort listeners on a reused signal', () => {
/** An AbortSignal that counts its live `abort` listeners. */
const trackedSignal = () => {
@@ -1263,7 +1511,7 @@ describe('NDJSON stream termination', () => {
const { xhr, stream } = await start();
const read = stream.next();
xhr.dispatchEvent(new Event('timeout'));
await expect(read).rejects.toMatchObject({ code: 'network_error' });
await expect(read).rejects.toMatchObject({ code: 'request_timeout' });
});
});
+17 -2
View File
@@ -1,7 +1,7 @@
import { FileReaderPoly } from './polyfills/fileReaderPoly.js';
import {
buildXhr, driverCall, isVerificationGateCode, parseResponse, resolveReauth,
resolveVerificationGate,
buildXhr, driverCall, isVerificationGateCode, parseResponse, requestTimeoutError,
resolveReauth, resolveVerificationGate,
} from './networkUtils.js';
/**
@@ -213,6 +213,21 @@ function setupXhrEventHandlers (xhr, success_cb, error_cb, resolve_func, reject_
}
return handle_error(error_cb, reject_func, this);
});
// The idle timeout ends a request by aborting it (see `watchIdle`).
xhr.addEventListener('abort', function () {
if ( ! xhr._puterTimedOut ) return;
const error = requestTimeoutError();
if ( globalThis.puter?.apiCallLogger?.isEnabled() && xhr._puterRequestId ) {
globalThis.puter.apiCallLogger.logRequest({
service: xhr._puterRequestId.service,
operation: xhr._puterRequestId.operation,
params: xhr._puterRequestId.params,
error,
});
}
return handle_error(error_cb, reject_func, error);
});
}
/**
+49 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { makeDriverMethod, setupXhrEventHandlers } from './utils.js';
import { initXhr, makeDriverMethod, setupXhrEventHandlers } from './utils.js';
/**
* Pins the callback contract of `makeDriverMethod`: driver methods are
@@ -133,3 +133,51 @@ describe('setupXhrEventHandlers', () => {
expect(await Promise.race([result, pending])).toBe(xhr.response);
});
});
describe('legacy request idle timeout', () => {
// An XHR that is sent but never answered.
class SilentXHR extends EventTarget {
readyState = 0;
upload = { addEventListener () {} };
open () { this.readyState = 1; }
setRequestHeader () {}
getResponseHeader () { return null; }
send () {}
abort () { this.dispatchEvent(new Event('abort')); }
}
beforeEach(() => {
vi.useFakeTimers();
globalThis.XMLHttpRequest = SilentXHR;
});
afterEach(() => {
vi.useRealTimers();
});
it.each(['post', 'get'])('rejects a silent %s with request_timeout after 15 min', async (method) => {
const onError = vi.fn();
let outcome;
const xhr = initXhr('/stat', 'https://api.test', 'tok', method);
const request = new Promise((resolve, reject) => {
setupXhrEventHandlers(xhr, undefined, onError, resolve, reject);
});
(async () => {
try {
outcome = { value: await request };
} catch (error) {
outcome = { error };
}
})();
xhr.send('{}');
await vi.advanceTimersByTimeAsync(15 * 60_000 - 1);
expect(outcome).toBeUndefined();
await vi.advanceTimersByTimeAsync(1);
const error = { message: 'Request timed out.', code: 'request_timeout' };
expect(outcome).toEqual({ error });
expect(onError).toHaveBeenCalledOnce();
expect(onError).toHaveBeenCalledWith(error);
expect(vi.getTimerCount()).toBe(0);
});
});
@@ -59,21 +59,27 @@ afterEach(() => {
describe('cache update timer', () => {
it('keeps a single interval across repeated auth-state changes', () => {
const fs = makeModule();
// `vi.getTimerCount()` is global and constructing the module schedules
// a timer of its own, so the cache interval is counted as a delta from
// construction rather than as an absolute.
const baseline = vi.getTimerCount();
// Each auth change also sends the cache timestamp request, whose idle
// clock is a timer too, so intervals are counted on their own.
const started = vi.spyOn(globalThis, 'setInterval');
const cleared = vi.spyOn(globalThis, 'clearInterval');
const running = () => started.mock.calls.length - cleared.mock.calls.length;
fs.onAuthStateChanged();
const timer = fs.cacheUpdateTimer;
fs.onAuthStateChanged();
fs.onAuthStateChanged();
try {
fs.onAuthStateChanged();
const timer = fs.cacheUpdateTimer;
fs.onAuthStateChanged();
fs.onAuthStateChanged();
expect(vi.getTimerCount()).toBe(baseline + 1);
expect(fs.cacheUpdateTimer).not.toBe(timer);
expect(running()).toBe(1);
expect(fs.cacheUpdateTimer).not.toBe(timer);
fs.stopCacheUpdateTimer();
expect(vi.getTimerCount()).toBe(baseline);
fs.stopCacheUpdateTimer();
expect(running()).toBe(0);
} finally {
started.mockRestore();
cleared.mockRestore();
}
});
it('refreshes the cache timestamp while running', () => {