diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a65e383..92ab07bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to the Late Node.js SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed +- The `timeout` client option is now applied to every request (it was previously ignored). The default is 300000 ms, and a timed-out call rejects with a `TimeoutError` DOMException. A per-call `signal` replaces the timeout for that call. If you set `timeout` by hand and use `publishNow`, allow for publishes that take minutes. A timed-out create can still complete on the server, so retry it with the same `Idempotency-Key`. + ## [0.2.519] - 2026-08-07 ### Fixed diff --git a/README.md b/README.md index 7fd05360..b7936a75 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,12 @@ console.log(`Published to ${post.platforms.length} platforms!`); const zernio = new Zernio({ apiKey: 'your-api-key', // Defaults to process.env['ZERNIO_API_KEY'] baseURL: 'https://zernio.com/api', - timeout: 60000, + timeout: 300000, }); ``` +A per-call `signal` overrides `timeout`, and a timed-out call rejects with a `TimeoutError`. + ## Examples ### Schedule a Post diff --git a/scripts/generate-client.ts b/scripts/generate-client.ts index 35b2ed4c..fe261d50 100644 --- a/scripts/generate-client.ts +++ b/scripts/generate-client.ts @@ -213,8 +213,8 @@ export interface ClientOptions { baseURL?: string | null | undefined; /** - * The maximum amount of time (in milliseconds) that the client should wait for a response. - * @default 60000 + * Maximum time in milliseconds for each request, response body included. A timed-out call rejects with a \`TimeoutError\` DOMException. \`0\` or \`Infinity\` disables it, and a \`signal\` passed to a call replaces it for that call. Creates with \`publishNow\` publish inside the request and can take minutes. + * @default 300000 */ timeout?: number; @@ -329,7 +329,7 @@ export interface ClientOptions { * \`\`\` */ export class Zernio { - private _options: ClientOptions; + private _timeout: number | undefined; /** * HTTP client owned by this instance. Every namespace method below is bound @@ -359,7 +359,7 @@ export class Zernio { * Routes a generated operation through this instance's client, preserving the * operation's own signature. Reads \`_client\` when the method is called, not * when it is bound, because class fields initialize before the constructor - * body runs. + * body runs. Each call gets its own timeout signal. */ private _bind(operation: F): F { return ((options?: Record) => { @@ -367,6 +367,10 @@ export class Zernio { if (withClient['client'] === undefined) { withClient['client'] = this._dispatchClient; } + // A caller signal replaces the timeout instead of joining it: AbortSignal.any needs Node 18.17 and engines allows 18.0. + if (withClient['signal'] === undefined && this._timeout !== undefined) { + withClient['signal'] = AbortSignal.timeout(this._timeout); + } return (operation as (opts: unknown) => unknown)(withClient); }) as F; } @@ -436,7 +440,11 @@ ${namespaceProps.join('\n\n')} this.apiKey = apiKey; this.baseURL = options.baseURL ?? 'https://zernio.com/api'; - this._options = options; + // AbortSignal.timeout throws a RangeError outside uint32 integers, and \`timeout\` was + // ignored by every earlier release, so no value an integrator already passes may start throwing. + const timeout = options.timeout ?? 300000; + this._timeout = + timeout > 0 && Number.isFinite(timeout) ? Math.min(Math.ceil(timeout), 2 ** 32 - 1) : undefined; // One client per instance. Interceptors used to be registered on the // module-global client, so every \`new Zernio()\` stacked another auth diff --git a/src/client.ts b/src/client.ts index 8620f3f0..cdd7328e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -983,8 +983,8 @@ export interface ClientOptions { baseURL?: string | null | undefined; /** - * The maximum amount of time (in milliseconds) that the client should wait for a response. - * @default 60000 + * Maximum time in milliseconds for each request, response body included. A timed-out call rejects with a `TimeoutError` DOMException. `0` or `Infinity` disables it, and a `signal` passed to a call replaces it for that call. Creates with `publishNow` publish inside the request and can take minutes. + * @default 300000 */ timeout?: number; @@ -1019,7 +1019,7 @@ export interface ClientOptions { * ``` */ export class Zernio { - private _options: ClientOptions; + private _timeout: number | undefined; /** * HTTP client owned by this instance. Every namespace method below is bound @@ -1049,7 +1049,7 @@ export class Zernio { * Routes a generated operation through this instance's client, preserving the * operation's own signature. Reads `_client` when the method is called, not * when it is bound, because class fields initialize before the constructor - * body runs. + * body runs. Each call gets its own timeout signal. */ private _bind(operation: F): F { return ((options?: Record) => { @@ -1057,6 +1057,10 @@ export class Zernio { if (withClient['client'] === undefined) { withClient['client'] = this._dispatchClient; } + // A caller signal replaces the timeout instead of joining it: AbortSignal.any needs Node 18.17 and engines allows 18.0. + if (withClient['signal'] === undefined && this._timeout !== undefined) { + withClient['signal'] = AbortSignal.timeout(this._timeout); + } return (operation as (opts: unknown) => unknown)(withClient); }) as F; } @@ -3016,7 +3020,11 @@ export class Zernio { this.apiKey = apiKey; this.baseURL = options.baseURL ?? 'https://zernio.com/api'; - this._options = options; + // AbortSignal.timeout throws a RangeError outside uint32 integers, and `timeout` was + // ignored by every earlier release, so no value an integrator already passes may start throwing. + const timeout = options.timeout ?? 300000; + this._timeout = + timeout > 0 && Number.isFinite(timeout) ? Math.min(Math.ceil(timeout), 2 ** 32 - 1) : undefined; // One client per instance. Interceptors used to be registered on the // module-global client, so every `new Zernio()` stacked another auth diff --git a/tests/timeout.test.ts b/tests/timeout.test.ts new file mode 100644 index 00000000..658ecd0c --- /dev/null +++ b/tests/timeout.test.ts @@ -0,0 +1,62 @@ +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { describe, it, expect } from 'vitest'; +import Zernio from '../src'; + +function startServer(respondAfterMs: number | null): Promise<{ baseURL: string; close: () => void }> { + const server = createServer((_req, res) => { + if (respondAfterMs === null) return; + setTimeout(() => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"accounts":[]}'); + }, respondAfterMs); + }); + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() as AddressInfo; + resolve({ + baseURL: `http://127.0.0.1:${port}/api`, + close: () => { + server.closeAllConnections(); + server.close(); + }, + }); + }); + }); +} + +describe('Client timeout', () => { + it('rejects with a TimeoutError when the API does not answer within timeout', async () => { + const server = await startServer(null); + try { + const client = new Zernio({ apiKey: 'test', baseURL: server.baseURL, timeout: 200 }); + const started = Date.now(); + await expect(client.accounts.listAccounts({})).rejects.toMatchObject({ name: 'TimeoutError' }); + expect(Date.now() - started).toBeLessThan(2000); + } finally { + server.close(); + } + }); + + it('a per-call signal replaces the client timeout', async () => { + const server = await startServer(400); + try { + const client = new Zernio({ apiKey: 'test', baseURL: server.baseURL, timeout: 100 }); + const { response } = await client.accounts.listAccounts({ signal: new AbortController().signal }); + expect(response.status).toBe(200); + } finally { + server.close(); + } + }); + + it.each([0, Infinity])('timeout %s disables the timeout', async (timeout) => { + const server = await startServer(300); + try { + const client = new Zernio({ apiKey: 'test', baseURL: server.baseURL, timeout }); + const { response } = await client.accounts.listAccounts({}); + expect(response.status).toBe(200); + } finally { + server.close(); + } + }); +});