Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 13 additions & 5 deletions scripts/generate-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -359,14 +359,18 @@ 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<F>(operation: F): F {
return ((options?: Record<string, unknown>) => {
const withClient = { ...options };
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;
}
Expand Down Expand Up @@ -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
Expand Down
18 changes: 13 additions & 5 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1049,14 +1049,18 @@ 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<F>(operation: F): F {
return ((options?: Record<string, unknown>) => {
const withClient = { ...options };
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;
}
Expand Down Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions tests/timeout.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
Loading