diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d50149..8575bc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,13 @@ existing `0.1.0` release; earlier development prereleases are not listed. ### Added +- Add `client.withOptions()` to derive a Forward or Managed client with selected configuration overrides while preserving its concrete type and unspecified options. Supplied headers, query defaults, and middleware replace their corresponding client-level option, matching Anthropic's TypeScript SDK. - Forward and Managed ordinary JSON object responses now expose a typed, non-enumerable `_request_id` for troubleshooting, matching Anthropic's TypeScript SDK. Existing `withResponse().request_id` access remains available for all response types. ### Changed +- Align `Retry-After` handling with Anthropic's TypeScript SDK: positive delays up to `2 ** 31 - 1` milliseconds are honored; zero, negative, invalid, and over-limit values use exponential backoff. A zero or unparseable `retry-after-ms` falls through to `retry-after`. Retry eligibility remains unchanged. +- **Breaking:** `APIConnectionError`, `APIConnectionTimeoutError`, and `APIUserAbortError` now inherit from `APIError`, matching Anthropic's TypeScript SDK. An `APIError` catch now includes network failures, timeouts, and cancellation; guard optional `status`/`headers` and handle `APIUserAbortError` first if cancellation needs separate treatment. Existing `(message, { cause })` constructors and HTTP error metadata are preserved. HTTP-specific subclasses retain typed status codes and headers, and connection retries and cancellation behavior remain unchanged. - **Breaking:** Forward and Managed `Page.getNextPage()` now throws `QoderError` at the last page and returns `Promise>` instead of a nullable page, matching Anthropic's TypeScript SDK. Check `hasNextPage()` before advancing manually, or use async iteration, which still stops normally. - Forward and Managed request timeouts now cover each underlying `fetch` call until a response arrives, matching Anthropic's TypeScript SDK. Credential resolution, middleware, and response-body/SSE reads no longer consume this timeout. Use an `AbortSignal` to enforce a total deadline or cancel a stream after it starts. diff --git a/README.md b/README.md index 93ad87d..c3b9983 100644 --- a/README.md +++ b/README.md @@ -184,18 +184,20 @@ const bytes = new Uint8Array(await download.arrayBuffer()); ## Handling errors -A non-2xx response throws a subclass of `APIError` carrying `status`, `code`, `type`, `request_id`, the parsed error payload, and the underlying `request` and `response`. +`APIError` covers HTTP failures, connection failures, exhausted timeouts, and caller cancellation. HTTP errors carry `status`, `headers`, `code`, `type`, `request_id`, the parsed error payload, and the underlying `request` and `response`. ```ts -import { APIError, NotFoundError } from 'qca-sdk'; +import { APIError, APIUserAbortError, NotFoundError } from 'qca-sdk'; try { await client.sessions.retrieve('sess_missing'); } catch (error) { - if (error instanceof NotFoundError) { + if (error instanceof APIUserAbortError) { + console.log('Request cancelled'); + } else if (error instanceof NotFoundError) { console.log(error.status, error.code, error.request_id); } else if (error instanceof APIError) { - console.log(error.status, error.message); + console.log(error.status ?? 'No HTTP response', error.message, error.headers?.get('x-request-id')); } else { throw error; } @@ -213,7 +215,9 @@ try { | 429 | `RateLimitError` | | >=500 | `InternalServerError` | -Connection failures throw `APIConnectionError`, an exhausted timeout throws `APIConnectionTimeoutError`, and aborting through your own signal throws `APIUserAbortError`. Client-side misconfiguration — an invalid `baseURL`, a negative `maxRetries` — throws `QoderError`, the base class of all of the above. +Connection failures throw `APIConnectionError`, an exhausted timeout throws `APIConnectionTimeoutError` (a subclass of `APIConnectionError`), and aborting through your own signal throws `APIUserAbortError`. All three inherit from `APIError`, matching Anthropic's TypeScript SDK. They have no HTTP response: `status`, `headers`, `error`, and `response` are `undefined`, and `request_id`/`requestID` are `null`. Guard these fields when handling a general `APIError`; HTTP-specific subclasses such as `NotFoundError` retain typed status codes and `Headers`. + +Error constructors continue to accept `(message, { cause })`. Client-side configuration errors such as an unsupported `baseURL` scheme or a negative `maxRetries` remain `QoderError` instances outside `APIError`. `QoderError` is the SDK error base class. ### Request IDs @@ -232,7 +236,9 @@ const { data, request_id } = await client.sessions.retrieve(session.id).withResp ## Retries -Connection errors, timeouts, 408, 429 and 5xx responses are retried twice by default with exponential backoff and jitter. Only replayable requests are eligible: `GET` and `HEAD`, plus any request sent with an idempotency key. Writes without an idempotency key are retried on 429 only, 409 is never retried, and a request whose body is a `ReadableStream` is never replayed because the body cannot be re-read. A `retry-after-ms`, `retry-after` or `x-should-retry` response header overrides the default decision. +Connection errors, timeouts, 408, 429 and 5xx responses are retried twice by default with exponential backoff and jitter. Only replayable requests are eligible: `GET` and `HEAD`, plus any request sent with an idempotency key. Writes without an idempotency key are retried on 429 only, 409 is never retried, and a request whose body is a `ReadableStream` is never replayed because the body cannot be re-read. Within these safety rules, `x-should-retry` controls whether to retry, and `retry-after-ms` or `retry-after` controls the delay. + +Server-requested delays must be positive and no greater than `2 ** 31 - 1` milliseconds (the single-timer limit). Zero, negative, invalid, and over-limit values fall back to exponential backoff. `retry-after-ms` takes precedence; if it is missing, unparseable, or zero, the SDK checks `retry-after`, which accepts seconds or an HTTP date. These rules match Anthropic's TypeScript SDK. ```ts const client = new ForwardClient({ maxRetries: 0 }); // disable retries @@ -241,6 +247,17 @@ await client.sessions.create(params, { maxRetries: 5, idempotencyKey: 'my-key' } Each attempt re-resolves the credential and sends an `X-Qoder-Retry-Count` header. +## Reusing client configuration + +`withOptions()` creates a new client of the same type and retains options you do not override, including authentication, custom `fetch`, middleware, and the resolved base URL. The original client keeps its configuration. + +```ts +const slowClient = client.withOptions({ timeout: 60_000, maxRetries: 1 }); +await slowClient.sessions.list({}); +``` + +Supplied `defaultHeaders`, `defaultQuery`, and `middleware` replace their entire corresponding option rather than merging with it, matching Anthropic. Per-request options still override the derived client's defaults. Authentication follows the constructor's precedence: to switch from an inherited credential provider to a PAT, pass `{ credential: undefined, pat: 'new-token' }`. + ## Timeouts Requests time out after 10 minutes by default and are then retried according to the rules above. Configure the client default or override per request: diff --git a/docs/api/index/classes/APIClient.md b/docs/api/index/classes/APIClient.md index 557721b..d37dea0 100644 --- a/docs/api/index/classes/APIClient.md +++ b/docs/api/index/classes/APIClient.md @@ -160,3 +160,21 @@ Resolve the API grant, then send a separate request without API credentials or h #### Returns `void` + +*** + +### withOptions() + +> **withOptions**(`options`): `this` + +Create a client of the same type, replacing supplied options and retaining the rest. + +#### Parameters + +##### options + +`Partial`\<[`ClientOptions`](../interfaces/ClientOptions.md)\> + +#### Returns + +`this` diff --git a/docs/api/index/classes/APIConnectionError.md b/docs/api/index/classes/APIConnectionError.md index 2d95950..81c7860 100644 --- a/docs/api/index/classes/APIConnectionError.md +++ b/docs/api/index/classes/APIConnectionError.md @@ -8,7 +8,7 @@ ## Extends -- [`QoderError`](QoderError.md) +- [`APIError`](APIError.md)\<`undefined`, `undefined`, `undefined`\> ## Extended by @@ -34,9 +34,9 @@ `APIConnectionError` -#### Inherited from +#### Overrides -[`QoderError`](QoderError.md).[`constructor`](QoderError.md#constructor) +[`APIError`](APIError.md).[`constructor`](APIError.md#constructor) ## Properties @@ -46,7 +46,37 @@ #### Inherited from -[`QoderError`](QoderError.md).[`cause`](QoderError.md#cause) +[`APIError`](APIError.md).[`cause`](APIError.md#cause) + +*** + +### code? + +> `readonly` `optional` **code?**: `string` + +#### Inherited from + +[`APIError`](APIError.md).[`code`](APIError.md#code) + +*** + +### error + +> `readonly` **error**: `undefined` + +#### Inherited from + +[`APIError`](APIError.md).[`error`](APIError.md#error) + +*** + +### headers + +> `readonly` **headers**: `undefined` + +#### Inherited from + +[`APIError`](APIError.md).[`headers`](APIError.md#headers) *** @@ -56,7 +86,7 @@ #### Inherited from -[`QoderError`](QoderError.md).[`message`](QoderError.md#message) +[`APIError`](APIError.md).[`message`](APIError.md#message) *** @@ -66,7 +96,47 @@ #### Inherited from -[`QoderError`](QoderError.md).[`name`](QoderError.md#name) +[`APIError`](APIError.md).[`name`](APIError.md#name) + +*** + +### request? + +> `optional` **request?**: `Request` + +#### Inherited from + +[`APIError`](APIError.md).[`request`](APIError.md#request) + +*** + +### request\_id + +> `readonly` **request\_id**: `string` \| `null` + +#### Inherited from + +[`APIError`](APIError.md).[`request_id`](APIError.md#request_id) + +*** + +### requestID + +> `readonly` **requestID**: `string` \| `null` + +#### Inherited from + +[`APIError`](APIError.md).[`requestID`](APIError.md#requestid) + +*** + +### response? + +> `readonly` `optional` **response?**: `Response` + +#### Inherited from + +[`APIError`](APIError.md).[`response`](APIError.md#response) *** @@ -76,4 +146,60 @@ #### Inherited from -[`QoderError`](QoderError.md).[`stack`](QoderError.md#stack) +[`APIError`](APIError.md).[`stack`](APIError.md#stack) + +*** + +### status + +> `readonly` **status**: `undefined` + +#### Inherited from + +[`APIError`](APIError.md).[`status`](APIError.md#status) + +*** + +### type? + +> `readonly` `optional` **type?**: `string` + +#### Inherited from + +[`APIError`](APIError.md).[`type`](APIError.md#type) + +## Methods + +### generate() + +> `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md)\<`number`, `Headers`\> + +#### Parameters + +##### status + +`number` + +##### error + +`unknown` + +##### message? + +`string` + +##### headers? + +`Headers` = `...` + +##### response? + +`Response` + +#### Returns + +[`APIError`](APIError.md)\<`number`, `Headers`\> + +#### Inherited from + +[`APIError`](APIError.md).[`generate`](APIError.md#generate) diff --git a/docs/api/index/classes/APIConnectionTimeoutError.md b/docs/api/index/classes/APIConnectionTimeoutError.md index c2e3b7c..5365f43 100644 --- a/docs/api/index/classes/APIConnectionTimeoutError.md +++ b/docs/api/index/classes/APIConnectionTimeoutError.md @@ -46,6 +46,36 @@ *** +### code? + +> `readonly` `optional` **code?**: `string` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`code`](APIConnectionError.md#code) + +*** + +### error + +> `readonly` **error**: `undefined` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`error`](APIConnectionError.md#error) + +*** + +### headers + +> `readonly` **headers**: `undefined` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`headers`](APIConnectionError.md#headers) + +*** + ### message > **message**: `string` @@ -66,6 +96,46 @@ *** +### request? + +> `optional` **request?**: `Request` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`request`](APIConnectionError.md#request) + +*** + +### request\_id + +> `readonly` **request\_id**: `string` \| `null` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`request_id`](APIConnectionError.md#request_id) + +*** + +### requestID + +> `readonly` **requestID**: `string` \| `null` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`requestID`](APIConnectionError.md#requestid) + +*** + +### response? + +> `readonly` `optional` **response?**: `Response` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`response`](APIConnectionError.md#response) + +*** + ### stack? > `optional` **stack?**: `string` @@ -73,3 +143,59 @@ #### Inherited from [`APIConnectionError`](APIConnectionError.md).[`stack`](APIConnectionError.md#stack) + +*** + +### status + +> `readonly` **status**: `undefined` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`status`](APIConnectionError.md#status) + +*** + +### type? + +> `readonly` `optional` **type?**: `string` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`type`](APIConnectionError.md#type) + +## Methods + +### generate() + +> `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md)\<`number`, `Headers`\> + +#### Parameters + +##### status + +`number` + +##### error + +`unknown` + +##### message? + +`string` + +##### headers? + +`Headers` = `...` + +##### response? + +`Response` + +#### Returns + +[`APIError`](APIError.md)\<`number`, `Headers`\> + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`generate`](APIConnectionError.md#generate) diff --git a/docs/api/index/classes/APIError.md b/docs/api/index/classes/APIError.md index c75fcd0..88e674f 100644 --- a/docs/api/index/classes/APIError.md +++ b/docs/api/index/classes/APIError.md @@ -4,7 +4,7 @@ [qca-sdk](../../README.md) / [index](../README.md) / APIError -# Class: APIError +# Class: APIError\ ## Extends @@ -20,22 +20,38 @@ - [`UnprocessableEntityError`](UnprocessableEntityError.md) - [`RateLimitError`](RateLimitError.md) - [`InternalServerError`](InternalServerError.md) +- [`APIConnectionError`](APIConnectionError.md) +- [`APIUserAbortError`](APIUserAbortError.md) + +## Type Parameters + +### TStatus + +`TStatus` *extends* `number` \| `undefined` = `number` \| `undefined` + +### THeaders + +`THeaders` *extends* `Headers` \| `undefined` = `Headers` \| `undefined` + +### TError + +`TError` = `unknown` ## Constructors ### Constructor -> **new APIError**(`status`, `error`, `message?`, `headers?`, `response?`): `APIError` +> **new APIError**\<`TStatus`, `THeaders`, `TError`\>(`status`, `error`, `message?`, `headers?`, `response?`, `options?`): `APIError`\<`TStatus`, `THeaders`, `TError`\> #### Parameters ##### status -`number` +`TStatus` ##### error -`unknown` +`TError` ##### message? @@ -43,15 +59,19 @@ ##### headers? -`Headers` = `...` +`THeaders` = `...` ##### response? `Response` +##### options? + +`ErrorOptions` + #### Returns -`APIError` +`APIError`\<`TStatus`, `THeaders`, `TError`\> #### Overrides @@ -77,13 +97,13 @@ ### error -> `readonly` **error**: `unknown` +> `readonly` **error**: `TError` *** ### headers -> `readonly` **headers**: `Headers` +> `readonly` **headers**: `THeaders` *** @@ -143,7 +163,7 @@ ### status -> `readonly` **status**: `number` +> `readonly` **status**: `TStatus` *** @@ -155,7 +175,7 @@ ### generate() -> `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): `APIError` +> `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): `APIError`\<`number`, `Headers`\> #### Parameters @@ -173,7 +193,7 @@ ##### headers? -`Headers` +`Headers` = `...` ##### response? @@ -181,4 +201,4 @@ #### Returns -`APIError` +`APIError`\<`number`, `Headers`\> diff --git a/docs/api/index/classes/APIUserAbortError.md b/docs/api/index/classes/APIUserAbortError.md index fb58e0a..ddf4a14 100644 --- a/docs/api/index/classes/APIUserAbortError.md +++ b/docs/api/index/classes/APIUserAbortError.md @@ -8,7 +8,7 @@ ## Extends -- [`QoderError`](QoderError.md) +- [`APIError`](APIError.md)\<`undefined`, `undefined`, `undefined`\> ## Constructors @@ -30,9 +30,9 @@ `APIUserAbortError` -#### Inherited from +#### Overrides -[`QoderError`](QoderError.md).[`constructor`](QoderError.md#constructor) +[`APIError`](APIError.md).[`constructor`](APIError.md#constructor) ## Properties @@ -42,7 +42,37 @@ #### Inherited from -[`QoderError`](QoderError.md).[`cause`](QoderError.md#cause) +[`APIError`](APIError.md).[`cause`](APIError.md#cause) + +*** + +### code? + +> `readonly` `optional` **code?**: `string` + +#### Inherited from + +[`APIError`](APIError.md).[`code`](APIError.md#code) + +*** + +### error + +> `readonly` **error**: `undefined` + +#### Inherited from + +[`APIError`](APIError.md).[`error`](APIError.md#error) + +*** + +### headers + +> `readonly` **headers**: `undefined` + +#### Inherited from + +[`APIError`](APIError.md).[`headers`](APIError.md#headers) *** @@ -52,7 +82,7 @@ #### Inherited from -[`QoderError`](QoderError.md).[`message`](QoderError.md#message) +[`APIError`](APIError.md).[`message`](APIError.md#message) *** @@ -62,7 +92,47 @@ #### Inherited from -[`QoderError`](QoderError.md).[`name`](QoderError.md#name) +[`APIError`](APIError.md).[`name`](APIError.md#name) + +*** + +### request? + +> `optional` **request?**: `Request` + +#### Inherited from + +[`APIError`](APIError.md).[`request`](APIError.md#request) + +*** + +### request\_id + +> `readonly` **request\_id**: `string` \| `null` + +#### Inherited from + +[`APIError`](APIError.md).[`request_id`](APIError.md#request_id) + +*** + +### requestID + +> `readonly` **requestID**: `string` \| `null` + +#### Inherited from + +[`APIError`](APIError.md).[`requestID`](APIError.md#requestid) + +*** + +### response? + +> `readonly` `optional` **response?**: `Response` + +#### Inherited from + +[`APIError`](APIError.md).[`response`](APIError.md#response) *** @@ -72,4 +142,60 @@ #### Inherited from -[`QoderError`](QoderError.md).[`stack`](QoderError.md#stack) +[`APIError`](APIError.md).[`stack`](APIError.md#stack) + +*** + +### status + +> `readonly` **status**: `undefined` + +#### Inherited from + +[`APIError`](APIError.md).[`status`](APIError.md#status) + +*** + +### type? + +> `readonly` `optional` **type?**: `string` + +#### Inherited from + +[`APIError`](APIError.md).[`type`](APIError.md#type) + +## Methods + +### generate() + +> `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md)\<`number`, `Headers`\> + +#### Parameters + +##### status + +`number` + +##### error + +`unknown` + +##### message? + +`string` + +##### headers? + +`Headers` = `...` + +##### response? + +`Response` + +#### Returns + +[`APIError`](APIError.md)\<`number`, `Headers`\> + +#### Inherited from + +[`APIError`](APIError.md).[`generate`](APIError.md#generate) diff --git a/docs/api/index/classes/AuthenticationError.md b/docs/api/index/classes/AuthenticationError.md index 6ec9ddc..41f47d5 100644 --- a/docs/api/index/classes/AuthenticationError.md +++ b/docs/api/index/classes/AuthenticationError.md @@ -8,19 +8,19 @@ ## Extends -- [`APIError`](APIError.md) +- [`APIError`](APIError.md)\<`401`, `Headers`\> ## Constructors ### Constructor -> **new AuthenticationError**(`status`, `error`, `message?`, `headers?`, `response?`): `AuthenticationError` +> **new AuthenticationError**(`status`, `error`, `message?`, `headers?`, `response?`, `options?`): `AuthenticationError` #### Parameters ##### status -`number` +`401` ##### error @@ -38,6 +38,10 @@ `Response` +##### options? + +`ErrorOptions` + #### Returns `AuthenticationError` @@ -160,7 +164,7 @@ ### status -> `readonly` **status**: `number` +> `readonly` **status**: `401` #### Inherited from @@ -180,7 +184,7 @@ ### generate() -> `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md) +> `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md)\<`number`, `Headers`\> #### Parameters @@ -198,7 +202,7 @@ ##### headers? -`Headers` +`Headers` = `...` ##### response? @@ -206,7 +210,7 @@ #### Returns -[`APIError`](APIError.md) +[`APIError`](APIError.md)\<`number`, `Headers`\> #### Inherited from diff --git a/docs/api/index/classes/BadRequestError.md b/docs/api/index/classes/BadRequestError.md index 6181207..f3d1981 100644 --- a/docs/api/index/classes/BadRequestError.md +++ b/docs/api/index/classes/BadRequestError.md @@ -8,19 +8,19 @@ ## Extends -- [`APIError`](APIError.md) +- [`APIError`](APIError.md)\<`400`, `Headers`\> ## Constructors ### Constructor -> **new BadRequestError**(`status`, `error`, `message?`, `headers?`, `response?`): `BadRequestError` +> **new BadRequestError**(`status`, `error`, `message?`, `headers?`, `response?`, `options?`): `BadRequestError` #### Parameters ##### status -`number` +`400` ##### error @@ -38,6 +38,10 @@ `Response` +##### options? + +`ErrorOptions` + #### Returns `BadRequestError` @@ -160,7 +164,7 @@ ### status -> `readonly` **status**: `number` +> `readonly` **status**: `400` #### Inherited from @@ -180,7 +184,7 @@ ### generate() -> `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md) +> `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md)\<`number`, `Headers`\> #### Parameters @@ -198,7 +202,7 @@ ##### headers? -`Headers` +`Headers` = `...` ##### response? @@ -206,7 +210,7 @@ #### Returns -[`APIError`](APIError.md) +[`APIError`](APIError.md)\<`number`, `Headers`\> #### Inherited from diff --git a/docs/api/index/classes/ConflictError.md b/docs/api/index/classes/ConflictError.md index 2ff82b1..870efe2 100644 --- a/docs/api/index/classes/ConflictError.md +++ b/docs/api/index/classes/ConflictError.md @@ -8,19 +8,19 @@ ## Extends -- [`APIError`](APIError.md) +- [`APIError`](APIError.md)\<`409`, `Headers`\> ## Constructors ### Constructor -> **new ConflictError**(`status`, `error`, `message?`, `headers?`, `response?`): `ConflictError` +> **new ConflictError**(`status`, `error`, `message?`, `headers?`, `response?`, `options?`): `ConflictError` #### Parameters ##### status -`number` +`409` ##### error @@ -38,6 +38,10 @@ `Response` +##### options? + +`ErrorOptions` + #### Returns `ConflictError` @@ -160,7 +164,7 @@ ### status -> `readonly` **status**: `number` +> `readonly` **status**: `409` #### Inherited from @@ -180,7 +184,7 @@ ### generate() -> `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md) +> `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md)\<`number`, `Headers`\> #### Parameters @@ -198,7 +202,7 @@ ##### headers? -`Headers` +`Headers` = `...` ##### response? @@ -206,7 +210,7 @@ #### Returns -[`APIError`](APIError.md) +[`APIError`](APIError.md)\<`number`, `Headers`\> #### Inherited from diff --git a/docs/api/index/classes/ForwardClient.md b/docs/api/index/classes/ForwardClient.md index 0c8ce5a..b53369d 100644 --- a/docs/api/index/classes/ForwardClient.md +++ b/docs/api/index/classes/ForwardClient.md @@ -283,3 +283,25 @@ Resolve the API grant, then send a separate request without API credentials or h #### Inherited from [`APIClient`](APIClient.md).[`validateHeaders`](APIClient.md#validateheaders) + +*** + +### withOptions() + +> **withOptions**(`options`): `this` + +Create a client of the same type, replacing supplied options and retaining the rest. + +#### Parameters + +##### options + +`Partial`\<[`ClientOptions`](../interfaces/ClientOptions.md)\> + +#### Returns + +`this` + +#### Inherited from + +[`APIClient`](APIClient.md).[`withOptions`](APIClient.md#withoptions) diff --git a/docs/api/index/classes/InternalServerError.md b/docs/api/index/classes/InternalServerError.md index 3be4ab3..4e09850 100644 --- a/docs/api/index/classes/InternalServerError.md +++ b/docs/api/index/classes/InternalServerError.md @@ -8,13 +8,13 @@ ## Extends -- [`APIError`](APIError.md) +- [`APIError`](APIError.md)\<`number`, `Headers`\> ## Constructors ### Constructor -> **new InternalServerError**(`status`, `error`, `message?`, `headers?`, `response?`): `InternalServerError` +> **new InternalServerError**(`status`, `error`, `message?`, `headers?`, `response?`, `options?`): `InternalServerError` #### Parameters @@ -38,6 +38,10 @@ `Response` +##### options? + +`ErrorOptions` + #### Returns `InternalServerError` @@ -180,7 +184,7 @@ ### generate() -> `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md) +> `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md)\<`number`, `Headers`\> #### Parameters @@ -198,7 +202,7 @@ ##### headers? -`Headers` +`Headers` = `...` ##### response? @@ -206,7 +210,7 @@ #### Returns -[`APIError`](APIError.md) +[`APIError`](APIError.md)\<`number`, `Headers`\> #### Inherited from diff --git a/docs/api/index/classes/ManagedClient.md b/docs/api/index/classes/ManagedClient.md index dfffb60..43e6e93 100644 --- a/docs/api/index/classes/ManagedClient.md +++ b/docs/api/index/classes/ManagedClient.md @@ -267,3 +267,25 @@ Resolve the API grant, then send a separate request without API credentials or h #### Inherited from [`APIClient`](APIClient.md).[`validateHeaders`](APIClient.md#validateheaders) + +*** + +### withOptions() + +> **withOptions**(`options`): `this` + +Create a client of the same type, replacing supplied options and retaining the rest. + +#### Parameters + +##### options + +`Partial`\<[`ClientOptions`](../interfaces/ClientOptions.md)\> + +#### Returns + +`this` + +#### Inherited from + +[`APIClient`](APIClient.md).[`withOptions`](APIClient.md#withoptions) diff --git a/docs/api/index/classes/NotFoundError.md b/docs/api/index/classes/NotFoundError.md index 16f13c8..73a2829 100644 --- a/docs/api/index/classes/NotFoundError.md +++ b/docs/api/index/classes/NotFoundError.md @@ -8,19 +8,19 @@ ## Extends -- [`APIError`](APIError.md) +- [`APIError`](APIError.md)\<`404`, `Headers`\> ## Constructors ### Constructor -> **new NotFoundError**(`status`, `error`, `message?`, `headers?`, `response?`): `NotFoundError` +> **new NotFoundError**(`status`, `error`, `message?`, `headers?`, `response?`, `options?`): `NotFoundError` #### Parameters ##### status -`number` +`404` ##### error @@ -38,6 +38,10 @@ `Response` +##### options? + +`ErrorOptions` + #### Returns `NotFoundError` @@ -160,7 +164,7 @@ ### status -> `readonly` **status**: `number` +> `readonly` **status**: `404` #### Inherited from @@ -180,7 +184,7 @@ ### generate() -> `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md) +> `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md)\<`number`, `Headers`\> #### Parameters @@ -198,7 +202,7 @@ ##### headers? -`Headers` +`Headers` = `...` ##### response? @@ -206,7 +210,7 @@ #### Returns -[`APIError`](APIError.md) +[`APIError`](APIError.md)\<`number`, `Headers`\> #### Inherited from diff --git a/docs/api/index/classes/PermissionDeniedError.md b/docs/api/index/classes/PermissionDeniedError.md index a401751..5a8c5fc 100644 --- a/docs/api/index/classes/PermissionDeniedError.md +++ b/docs/api/index/classes/PermissionDeniedError.md @@ -8,19 +8,19 @@ ## Extends -- [`APIError`](APIError.md) +- [`APIError`](APIError.md)\<`403`, `Headers`\> ## Constructors ### Constructor -> **new PermissionDeniedError**(`status`, `error`, `message?`, `headers?`, `response?`): `PermissionDeniedError` +> **new PermissionDeniedError**(`status`, `error`, `message?`, `headers?`, `response?`, `options?`): `PermissionDeniedError` #### Parameters ##### status -`number` +`403` ##### error @@ -38,6 +38,10 @@ `Response` +##### options? + +`ErrorOptions` + #### Returns `PermissionDeniedError` @@ -160,7 +164,7 @@ ### status -> `readonly` **status**: `number` +> `readonly` **status**: `403` #### Inherited from @@ -180,7 +184,7 @@ ### generate() -> `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md) +> `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md)\<`number`, `Headers`\> #### Parameters @@ -198,7 +202,7 @@ ##### headers? -`Headers` +`Headers` = `...` ##### response? @@ -206,7 +210,7 @@ #### Returns -[`APIError`](APIError.md) +[`APIError`](APIError.md)\<`number`, `Headers`\> #### Inherited from diff --git a/docs/api/index/classes/QoderError.md b/docs/api/index/classes/QoderError.md index 9ec36a3..69aa225 100644 --- a/docs/api/index/classes/QoderError.md +++ b/docs/api/index/classes/QoderError.md @@ -13,8 +13,6 @@ ## Extended by - [`APIError`](APIError.md) -- [`APIConnectionError`](APIConnectionError.md) -- [`APIUserAbortError`](APIUserAbortError.md) ## Constructors diff --git a/docs/api/index/classes/RateLimitError.md b/docs/api/index/classes/RateLimitError.md index 7ac8404..0b0bc86 100644 --- a/docs/api/index/classes/RateLimitError.md +++ b/docs/api/index/classes/RateLimitError.md @@ -8,19 +8,19 @@ ## Extends -- [`APIError`](APIError.md) +- [`APIError`](APIError.md)\<`429`, `Headers`\> ## Constructors ### Constructor -> **new RateLimitError**(`status`, `error`, `message?`, `headers?`, `response?`): `RateLimitError` +> **new RateLimitError**(`status`, `error`, `message?`, `headers?`, `response?`, `options?`): `RateLimitError` #### Parameters ##### status -`number` +`429` ##### error @@ -38,6 +38,10 @@ `Response` +##### options? + +`ErrorOptions` + #### Returns `RateLimitError` @@ -160,7 +164,7 @@ ### status -> `readonly` **status**: `number` +> `readonly` **status**: `429` #### Inherited from @@ -180,7 +184,7 @@ ### generate() -> `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md) +> `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md)\<`number`, `Headers`\> #### Parameters @@ -198,7 +202,7 @@ ##### headers? -`Headers` +`Headers` = `...` ##### response? @@ -206,7 +210,7 @@ #### Returns -[`APIError`](APIError.md) +[`APIError`](APIError.md)\<`number`, `Headers`\> #### Inherited from diff --git a/docs/api/index/classes/UnprocessableEntityError.md b/docs/api/index/classes/UnprocessableEntityError.md index 8c2059d..3652c2a 100644 --- a/docs/api/index/classes/UnprocessableEntityError.md +++ b/docs/api/index/classes/UnprocessableEntityError.md @@ -8,19 +8,19 @@ ## Extends -- [`APIError`](APIError.md) +- [`APIError`](APIError.md)\<`422`, `Headers`\> ## Constructors ### Constructor -> **new UnprocessableEntityError**(`status`, `error`, `message?`, `headers?`, `response?`): `UnprocessableEntityError` +> **new UnprocessableEntityError**(`status`, `error`, `message?`, `headers?`, `response?`, `options?`): `UnprocessableEntityError` #### Parameters ##### status -`number` +`422` ##### error @@ -38,6 +38,10 @@ `Response` +##### options? + +`ErrorOptions` + #### Returns `UnprocessableEntityError` @@ -160,7 +164,7 @@ ### status -> `readonly` **status**: `number` +> `readonly` **status**: `422` #### Inherited from @@ -180,7 +184,7 @@ ### generate() -> `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md) +> `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md)\<`number`, `Headers`\> #### Parameters @@ -198,7 +202,7 @@ ##### headers? -`Headers` +`Headers` = `...` ##### response? @@ -206,7 +210,7 @@ #### Returns -[`APIError`](APIError.md) +[`APIError`](APIError.md)\<`number`, `Headers`\> #### Inherited from diff --git a/src/core/client.ts b/src/core/client.ts index 156f21d..c9e8999 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -110,6 +110,19 @@ export class APIClient { if (!this.fetchImpl) throw new QoderError('A fetch implementation is required'); } + /** Create a client of the same type, replacing supplied options and retaining the rest. */ + withOptions(options: Partial): this { + const Client = this.constructor as new (options: ClientOptions, mode: 'forward' | 'managed') => this; + return new Client({ + ...this.options, + baseURL: this.baseURL, + maxRetries: this.maxRetries, + timeout: this.timeout, + fetch: this.fetchImpl, + ...options, + }, this.mode); + } + private validateOptions(retries: number, timeout: number): void { if (!Number.isInteger(retries) || retries < 0) throw new QoderError('maxRetries must be a non-negative integer'); if (!Number.isFinite(timeout) || timeout < 0) throw new QoderError('timeout must be a non-negative number'); @@ -176,16 +189,21 @@ export class APIClient { } private retryDelay(response: Response | undefined, attempt: number): number { + let delay: number | undefined; if (response) { const ms = response.headers.get('retry-after-ms'); - if (ms !== null && Number.isFinite(Number(ms)) && Number(ms) >= 0) return Number(ms); + if (ms) { + const parsed = parseFloat(ms); + if (!Number.isNaN(parsed)) delay = parsed; + } const after = response.headers.get('retry-after'); - if (after) { - const seconds = Number(after); - const delay = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(after) - Date.now(); - if (Number.isFinite(delay) && delay >= 0) return delay; + if (after && !delay) { + const seconds = parseFloat(after); + delay = Number.isNaN(seconds) ? Date.parse(after) - Date.now() : seconds * 1000; } } + // Values above the timer limit become a 1 ms delay in Node.js. + if (delay !== undefined && delay > 0 && delay <= 2 ** 31 - 1) return delay; return Math.min(500 * 2 ** attempt, 8000) * (1 - Math.random() * 0.25); } diff --git a/src/core/error.ts b/src/core/error.ts index 05b6e63..b23fe86 100644 --- a/src/core/error.ts +++ b/src/core/error.ts @@ -5,44 +5,64 @@ export class QoderError extends Error { } } -export class APIError extends QoderError { +export class APIError< + TStatus extends number | undefined = number | undefined, + THeaders extends Headers | undefined = Headers | undefined, + TError = unknown, +> extends QoderError { readonly request_id: string | null; readonly requestID: string | null; readonly code?: string; readonly type?: string; request?: Request; constructor( - readonly status: number, - readonly error: unknown, + readonly status: TStatus, + readonly error: TError, message?: string, - readonly headers = new Headers(), + readonly headers: THeaders = (status === undefined ? undefined : new Headers()) as THeaders, readonly response?: Response, + options?: ErrorOptions, ) { const envelope = error && typeof error === 'object' ? error as Record : {}; const detail = envelope.error && typeof envelope.error === 'object' ? envelope.error as Record : envelope; - super(message ?? (typeof detail.message === 'string' ? detail.message : `HTTP ${status}${typeof error === 'string' && error ? `: ${error}` : ''}`)); - this.request_id = typeof envelope.request_id === 'string' && envelope.request_id ? envelope.request_id : headers.get('x-request-id') ?? headers.get('request-id'); + super(message ?? (typeof detail.message === 'string' ? detail.message : `HTTP ${status}${typeof error === 'string' && error ? `: ${error}` : ''}`), options); + this.request_id = typeof envelope.request_id === 'string' && envelope.request_id ? envelope.request_id : headers?.get('x-request-id') ?? headers?.get('request-id') ?? null; this.requestID = this.request_id; this.code = typeof detail.code === 'string' ? detail.code : undefined; this.type = typeof detail.type === 'string' ? detail.type : undefined; } - static generate(status: number, error: unknown, message?: string, headers?: Headers, response?: Response): APIError { - const Type = ({ 400: BadRequestError, 401: AuthenticationError, 403: PermissionDeniedError, - 404: NotFoundError, 409: ConflictError, 422: UnprocessableEntityError, 429: RateLimitError } as Record)[status] - ?? (status >= 500 ? InternalServerError : APIError); - return new Type(status, error, message, headers, response); + static generate(status: number, error: unknown, message?: string, headers = new Headers(), response?: Response): APIError { + switch (status) { + case 400: return new BadRequestError(status, error, message, headers, response); + case 401: return new AuthenticationError(status, error, message, headers, response); + case 403: return new PermissionDeniedError(status, error, message, headers, response); + case 404: return new NotFoundError(status, error, message, headers, response); + case 409: return new ConflictError(status, error, message, headers, response); + case 422: return new UnprocessableEntityError(status, error, message, headers, response); + case 429: return new RateLimitError(status, error, message, headers, response); + default: return status >= 500 ? new InternalServerError(status, error, message, headers, response) + : new APIError(status, error, message, headers, response); + } + } +} +export class BadRequestError extends APIError<400, Headers> {} +export class AuthenticationError extends APIError<401, Headers> {} +export class PermissionDeniedError extends APIError<403, Headers> {} +export class NotFoundError extends APIError<404, Headers> {} +export class ConflictError extends APIError<409, Headers> {} +export class UnprocessableEntityError extends APIError<422, Headers> {} +export class RateLimitError extends APIError<429, Headers> {} +export class InternalServerError extends APIError {} +export class APIConnectionError extends APIError { + constructor(message: string, options?: ErrorOptions) { + super(undefined, undefined, message, undefined, undefined, options); } } -export class BadRequestError extends APIError {} -export class AuthenticationError extends APIError {} -export class PermissionDeniedError extends APIError {} -export class NotFoundError extends APIError {} -export class ConflictError extends APIError {} -export class UnprocessableEntityError extends APIError {} -export class RateLimitError extends APIError {} -export class InternalServerError extends APIError {} -export class APIConnectionError extends QoderError {} export class APIConnectionTimeoutError extends APIConnectionError {} -export class APIUserAbortError extends QoderError {} +export class APIUserAbortError extends APIError { + constructor(message: string, options?: ErrorOptions) { + super(undefined, undefined, message, undefined, undefined, options); + } +} diff --git a/src/core/resumable-session-event-stream.ts b/src/core/resumable-session-event-stream.ts index fa728fd..b4ced0d 100644 --- a/src/core/resumable-session-event-stream.ts +++ b/src/core/resumable-session-event-stream.ts @@ -35,13 +35,13 @@ export function resumableRequestHeaders(headers: HeadersLike | undefined, lastEv /** @internal Exported for deterministic retry-policy tests. */ export function isResumableStreamRetryable(error: unknown): boolean { if (error instanceof APIUserAbortError) return false; + if (error instanceof APIConnectionError) return true; if (error instanceof APIError) { if (error.status === 409) return false; - if (error.headers.get('x-should-retry') === 'false') return false; - if (error.headers.get('x-should-retry') === 'true') return true; - return error.status === 408 || error.status === 429 || error.status >= 500; + if (error.headers?.get('x-should-retry') === 'false') return false; + if (error.headers?.get('x-should-retry') === 'true') return true; + return error.status === 408 || error.status === 429 || (error.status !== undefined && error.status >= 500); } - if (error instanceof APIConnectionError) return true; // Response-body transport failures are surfaced by ReadableStream as their original error. return !(error instanceof QoderError) && !(error instanceof SyntaxError); } diff --git a/tests/anthropic-conformance.test.mjs b/tests/anthropic-conformance.test.mjs index 26fe3ca..66bc3c9 100644 --- a/tests/anthropic-conformance.test.mjs +++ b/tests/anthropic-conformance.test.mjs @@ -7,7 +7,9 @@ // adopted areas : request building & header merge, JSON/query encoding, // APIPromise response access and request IDs, terminal pagination, // fetch middleware, SSE framing, upload conversion, -// caller-signal listener cleanup, fetch-only timeout lifetime. +// caller-signal listener cleanup, fetch-only timeout lifetime, +// APIError inheritance for connection, timeout and abort errors, +// Retry-After delay bounds and header precedence. // // Scope: assert ONLY generic SDK semantics QCA and the pinned Anthropic baseline // ALREADY share. This is a regression floor, NOT an API-parity layer. @@ -16,7 +18,7 @@ // - ForwardClient/ManagedClient topology, QCA resources & URLs // - PAT + Qoder fingerprint headers, resumable stream, x-qoder-* wire headers // - qca-sdk package/module/version naming -// - QCA safe-retry policy, QoderError hierarchy +// - QCA safe-retry policy, QoderError naming and error constructor arguments // - Anthropic public APIs absent from QCA (each raised as its own task) // // No @anthropic-ai/sdk import; no network; no PAT; injected fetch + in-memory only. @@ -39,6 +41,90 @@ function byteChunks(text, size = 1) { } for (const mode of MODES) { + for (const [name, headers, expected] of [ + ['seconds above 60', { 'retry-after': '120' }, 120_000], + ['fractional seconds', { 'retry-after': '0.25' }, 250], + ['millisecond precedence', { 'retry-after-ms': '125', 'retry-after': '120' }, 125], + ['invalid milliseconds', { 'retry-after-ms': 'bad', 'retry-after': '120' }, 120_000], + ['zero milliseconds falls through', { 'retry-after-ms': '0', 'retry-after': '120' }, 120_000], + ['negative milliseconds uses backoff', { 'retry-after-ms': '-1', 'retry-after': '120' }, undefined], + ['HTTP date', { 'retry-after': new Date(1_700_000_120_000).toUTCString() }, 120_000], + ['timer limit in milliseconds', { 'retry-after-ms': String(2 ** 31 - 1) }, 2 ** 31 - 1], + ['timer limit in seconds', { 'retry-after': String((2 ** 31 - 1) / 1000) }, 2 ** 31 - 1], + ['milliseconds over timer limit', { 'retry-after-ms': String(2 ** 31), 'retry-after': '1' }, undefined], + ['seconds over timer limit', { 'retry-after': String(2 ** 31 / 1000) }, undefined], + ['zero', { 'retry-after': '0' }, undefined], + ['negative', { 'retry-after': '-1' }, undefined], + ['zero milliseconds', { 'retry-after-ms': '0' }, undefined], + ['empty milliseconds', { 'retry-after-ms': '' }, undefined], + ['infinity', { 'retry-after': 'Infinity' }, undefined], + ['NaN', { 'retry-after': 'NaN' }, undefined], + ['past date', { 'retry-after': new Date(1_699_999_999_000).toUTCString() }, undefined], + ['invalid', { 'retry-after': 'invalid' }, undefined], + ['missing', {}, undefined], + ]) test(`[shared] ${mode}: Retry-After ${name}`, async t => { + const delays = []; + const setTimer = globalThis.setTimeout; + t.mock.method(globalThis, 'setTimeout', (callback, delay, ...args) => { + delays.push(delay); + return setTimer(callback, 0, ...args); + }); + t.mock.method(Math, 'random', () => 0); + t.mock.method(Date, 'now', () => 1_700_000_000_000); + let calls = 0; + const c = testClient(mode, () => ++calls <= 2 + ? response({}, 429, headers) : response({ data: [] }), { timeout: 0, maxRetries: 2 }); + await writeResource(c, mode).list({}); + assert.equal(calls, 3); + assert.deepEqual(delays, expected === undefined ? [500, 1000] : [expected, expected]); + }); + + for (const kind of ['connection', 'timeout', 'abort']) { + test(`[shared] ${mode}: ${kind} failures are APIErrors without HTTP metadata`, async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const caller = new AbortController(); + let cause = new Error('transport failed'); + let calls = 0; + const c = testClient(mode, req => { + calls++; + if (kind === 'connection') throw cause; + return new Promise((resolve, reject) => { + req.signal.addEventListener('abort', () => { + cause = req.signal.reason; + reject(cause); + }, { once: true }); + if (kind === 'timeout') t.mock.timers.tick(11); + else caller.abort(cause); + }); + }, { timeout: 10, maxRetries: kind === 'abort' ? 2 : 0 }); + const ErrorClass = { connection: sdk.APIConnectionError, timeout: sdk.APIConnectionTimeoutError, abort: sdk.APIUserAbortError }[kind]; + await assert.rejects(() => writeResource(c, mode).list({}, { signal: caller.signal }), error => { + assert.ok(error instanceof sdk.APIError); + assert.ok(error instanceof ErrorClass); + assert.equal(error.cause, cause); + assert.equal(error.status, undefined); + assert.equal(error.headers, undefined); + assert.equal(error.error, undefined); + assert.equal(error.response, undefined); + assert.equal(error.request_id, null); + assert.equal(error.requestID, null); + return true; + }); + assert.equal(calls, 1); + assert.equal(getEventListeners(caller.signal, 'abort').length, 0); + }); + } + + test(`[shared] ${mode}: configuration errors remain outside APIError`, () => { + for (const options of [{ baseURL: 'ftp://qoder.test' }, { maxRetries: -1 }]) { + assert.throws(() => testClient(mode, () => response({}), options), error => { + assert.ok(error instanceof sdk.QoderError); + assert.equal(error instanceof sdk.APIError, false); + return true; + }); + } + }); + // (1) method normalization + baseURL/path join; default header kept; // null header deletes; undefined header preserves default; request header adds. test(`[shared] ${mode}: request building normalizes method/path and merges headers`, async () => { @@ -153,7 +239,7 @@ for (const mode of MODES) { test(`[shared] ${mode}: request ID belongs to the final successful retry`, async () => { let calls = 0; const c = testClient(mode, () => ++calls === 1 - ? response({ error: { message: 'retry' } }, 429, { 'x-request-id': 'failed-id', 'retry-after-ms': '0' }) + ? response({ error: { message: 'retry' } }, 429, { 'x-request-id': 'failed-id', 'retry-after-ms': '1' }) : response({ id: 'one' }, 200, { 'x-request-id': 'success-id' }), { maxRetries: 1 }); const { data, request_id } = await c.request({ method: 'GET', path: '/resource' }).withResponse(); assert.equal(data._request_id, 'success-id'); @@ -298,6 +384,50 @@ for (const mode of MODES) { }); } +test('request error constructors preserve messages, names and causes', () => { + for (const ErrorClass of [sdk.APIConnectionError, sdk.APIConnectionTimeoutError, sdk.APIUserAbortError]) { + for (const message of ['custom message', '']) { + const cause = new Error('original failure'); + const error = new ErrorClass(message, { cause }); + assert.ok(error instanceof sdk.APIError); + assert.ok(error instanceof sdk.QoderError); + assert.equal(error.message, message); + assert.equal(error.name, ErrorClass.name); + assert.equal(error.cause, cause); + assert.equal(Object.getOwnPropertyDescriptor(error, 'cause').enumerable, false); + } + assert.equal(Object.hasOwn(new ErrorClass('no cause'), 'cause'), false); + } + assert.ok(new sdk.APIConnectionTimeoutError('timeout') instanceof sdk.APIConnectionError); +}); + +test('[shared] HTTP APIErrors preserve status, headers and response metadata', () => { + const subclasses = { + 400: sdk.BadRequestError, 401: sdk.AuthenticationError, 403: sdk.PermissionDeniedError, + 404: sdk.NotFoundError, 409: sdk.ConflictError, 422: sdk.UnprocessableEntityError, + 429: sdk.RateLimitError, 500: sdk.InternalServerError, 418: sdk.APIError, + }; + for (const [code, ErrorClass] of Object.entries(subclasses)) { + const status = Number(code); + const body = { error: { message: 'HTTP failure', code: 'failure_code', type: 'api_error' } }; + const raw = response(body, status); + const error = sdk.APIError.generate(status, body, undefined, raw.headers, raw); + assert.ok(error instanceof ErrorClass); + assert.ok(error instanceof sdk.APIError); + assert.equal(error.status, status); + assert.equal(error.error, body); + assert.equal(error.headers, raw.headers); + assert.equal(error.response, raw); + assert.equal(error.message, 'HTTP failure'); + assert.equal(error.code, 'failure_code'); + assert.equal(error.type, 'api_error'); + assert.equal(error.request_id, 'req_contract'); + assert.equal(error.requestID, error.request_id); + } + assert.ok(new sdk.NotFoundError(404, {}).headers instanceof Headers); + assert.ok(sdk.APIError.generate(418, {}).headers instanceof Headers); +}); + // (2b) query encoding is transport-shared; assert scalar + undefined omission once per // mode using each mode's documented list params. test('[shared] managed: query encodes scalars (0/false) and omits undefined', async () => { diff --git a/tests/client-options.test.mjs b/tests/client-options.test.mjs new file mode 100644 index 0000000..8381eb0 --- /dev/null +++ b/tests/client-options.test.mjs @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { sdk, response, testClient } from './helpers.mjs'; + +// withOptions follows the fixed Anthropic sdk-v0.127.0 baseline: derive the same +// client type and replace supplied top-level options, keeping unspecified options. +for (const mode of ['forward', 'managed']) { + const Client = mode === 'forward' ? sdk.ForwardClient : sdk.ManagedClient; + const resource = client => mode === 'forward' ? client.templates : client.agents; + + test(`${mode}: withOptions retains configuration and binds resources to the derived client`, async () => { + const requests = []; + let tokenCalls = 0; + const original = testClient(mode, req => { requests.push(req); return response({ data: [] }); }, { + pat: async () => `rotating-${++tokenCalls}`, + timeout: 10_000, + maxRetries: 1, + defaultHeaders: { 'x-default': 'kept' }, + defaultQuery: { user_filter: 'kept' }, + middleware: [async (req, next) => { req.headers.set('x-middleware', 'kept'); return next(req); }], + }); + const derived = original.withOptions({ baseURL: 'https://derived.test/prefix', timeout: 30_000, maxRetries: 0 }); + assert.ok(derived instanceof Client); + assert.notEqual(derived, original); + assert.notEqual(resource(derived), resource(original)); + assert.equal(tokenCalls, 0); + await resource(derived).list({}); + await resource(original).list({}); + assert.equal(new URL(requests[0].url).origin, 'https://derived.test'); + assert.equal(new URL(requests[1].url).origin, 'https://qoder.test'); + assert.equal(requests[0].headers.get('x-qoder-timeout'), '30'); + assert.equal(requests[1].headers.get('x-qoder-timeout'), '10'); + assert.equal(requests[0].headers.get('authorization'), 'Bearer rotating-1'); + assert.equal(requests[1].headers.get('authorization'), 'Bearer rotating-2'); + for (const req of requests) { + assert.equal(req.headers.get('x-default'), 'kept'); + assert.equal(req.headers.get('x-middleware'), 'kept'); + assert.equal(new URL(req.url).searchParams.get('user_filter'), 'kept'); + } + assert.equal(original.maxRetries, 1); + assert.equal(original.timeout, 10_000); + assert.equal(derived.maxRetries, 0); + }); + + test(`${mode}: withOptions replaces headers, query, middleware and fetch without changing the parent`, async () => { + const parentRequests = [], derivedRequests = []; + const original = testClient(mode, req => { parentRequests.push(req); return response({ data: [] }); }, { + defaultHeaders: { 'x-parent': 'parent' }, + defaultQuery: { parent_filter: 'parent' }, + middleware: [async (req, next) => { req.headers.set('x-parent-middleware', 'parent'); return next(req); }], + }); + const derived = original.withOptions({ + pat: 'derived-token', + defaultHeaders: new Headers({ 'x-derived': 'derived' }), + defaultQuery: { derived_filter: 'derived' }, + middleware: [], + fetch: async (input, init) => { derivedRequests.push(new Request(input, init)); return response({ data: [] }); }, + }); + await resource(derived).list({}, { headers: { 'x-derived': 'request' }, query: { derived_filter: 'request' } }); + await resource(original).list({}); + assert.equal(derivedRequests.length, 1); + assert.equal(parentRequests.length, 1); + const req = derivedRequests[0]; + assert.equal(req.headers.get('authorization'), 'Bearer derived-token'); + assert.equal(req.headers.get('x-parent'), null); + assert.equal(req.headers.get('x-derived'), 'request'); + assert.equal(req.headers.get('x-parent-middleware'), null); + assert.equal(new URL(req.url).searchParams.has('parent_filter'), false); + assert.equal(new URL(req.url).searchParams.get('derived_filter'), 'request'); + assert.equal(parentRequests[0].headers.get('x-parent'), 'parent'); + assert.equal(parentRequests[0].headers.get('x-parent-middleware'), 'parent'); + assert.equal(parentRequests[0].headers.get('authorization'), 'Bearer secret-pat'); + assert.equal(new URL(parentRequests[0].url).searchParams.get('parent_filter'), 'parent'); + }); + + test(`${mode}: withOptions retains and can replace dynamic credentials`, async () => { + const tokens = []; + let calls = 0; + const original = testClient(mode, req => { tokens.push(req.headers.get('authorization')); return response({ data: [] }); }, { + pat: undefined, + credential: { getToken: async () => `credential-${++calls}` }, + }); + const derived = original.withOptions({ timeout: 5000 }).withOptions({ maxRetries: 0 }); + await resource(derived).list({}); + await resource(original).list({}); + await resource(derived.withOptions({ credential: { getToken: () => 'replacement' } })).list({}); + await resource(derived.withOptions({ credential: undefined, pat: 'explicit-pat' })).list({}); + assert.deepEqual(tokens, ['Bearer credential-1', 'Bearer credential-2', 'Bearer replacement', 'Bearer explicit-pat']); + }); + + test(`${mode}: withOptions preserves resolved URL and fetch when environment defaults change`, async () => { + const baseVariable = mode === 'forward' ? 'QODER_FORWARD_BASE_URL' : 'QODER_BASE_URL'; + const previous = process.env[baseVariable]; + // Node 20.12 exposes fetch through a lazy accessor that mock.method cannot replace. + const previousFetch = globalThis.fetch; + const requests = []; + try { + process.env[baseVariable] = 'https://initial.test/api'; + globalThis.fetch = async (input, init) => { + requests.push(new Request(input, init)); + return response({ data: [] }); + }; + const original = new Client({ pat: 'test-token' }); + process.env[baseVariable] = 'https://changed.test/api'; + globalThis.fetch = async () => { throw new Error('unexpected replacement fetch'); }; + const derived = original.withOptions({}); + await resource(derived).list({}); + assert.equal(derived.baseURL, original.baseURL); + assert.equal(new URL(requests[0].url).origin, 'https://initial.test'); + } finally { + globalThis.fetch = previousFetch; + if (previous === undefined) delete process.env[baseVariable]; else process.env[baseVariable] = previous; + } + }); + + test(`${mode}: withOptions validates overrides and retains subclasses`, () => { + class CustomClient extends Client { label = 'custom'; } + const original = new CustomClient({ pat: 'test-token' }); + const derived = original.withOptions({ timeout: 1234 }); + assert.ok(derived instanceof CustomClient); + assert.equal(derived.label, 'custom'); + assert.equal(original.timeout, 600_000); + for (const options of [{ timeout: -1 }, { maxRetries: -1 }, { baseURL: 'ftp://invalid.test' }]) { + assert.throws(() => original.withOptions(options), sdk.QoderError); + } + }); +} diff --git a/tests/protocol.test.mjs b/tests/protocol.test.mjs index a5cef9b..0c40b76 100644 --- a/tests/protocol.test.mjs +++ b/tests/protocol.test.mjs @@ -55,7 +55,7 @@ for (const mode of ['forward', 'managed']) { assert.equal(req.headers.get('x-qoder-retry-count'), String(calls)); calls++; if (tc.write) sent.push(await req.text()); - return response({ error: { type: 'api_error', message: 'temporary' } }, tc.status, { 'retry-after-ms': '0', ...(tc.shouldRetry ? { 'x-should-retry': tc.shouldRetry } : {}) }); + return response({ error: { type: 'api_error', message: 'temporary' } }, tc.status, { 'retry-after-ms': '1', ...(tc.shouldRetry ? { 'x-should-retry': tc.shouldRetry } : {}) }); }, { maxRetries: 2 }); const resource = mode === 'forward' ? c.templates : c.agents; const params = mode === 'forward' ? { name: 'test', environment_id: 'env', model: 'model' } : { name: 'test', model: 'model' }; diff --git a/tests/resumable-streaming.test.mjs b/tests/resumable-streaming.test.mjs index 19626e8..cbbea77 100644 --- a/tests/resumable-streaming.test.mjs +++ b/tests/resumable-streaming.test.mjs @@ -422,12 +422,12 @@ test('low-level Stream remains one-shot and discards an incomplete EOF frame', a assert.equal(stream.lastEventID, 'cursor-complete'); }); -test('timeout reconnects with the existing cursor', async t => { +for (const ErrorClass of [sdk.APIConnectionError, sdk.APIConnectionTimeoutError]) test(`${ErrorClass.name} reconnects with the existing cursor`, async t => { t.mock.timers.enable({ apis: ['setTimeout'] }); const cursors = []; const stream = new sdk.ResumableSessionEventStream(async cursor => { cursors.push(cursor); - if (cursors.length === 1) throw new sdk.APIConnectionTimeoutError('timed out'); + if (cursors.length === 1) throw new ErrorClass('connection failed'); return sdk.Stream.fromSSEResponse(new Response(openBody(frame('after-timeout', { id: 'after-timeout' })))); }, 'cursor-before-timeout'); const next = stream[Symbol.asyncIterator]().next(); @@ -447,6 +447,7 @@ test('resumable retry classification matches the QCA transport policy', () => { assert.equal(isResumableStreamRetryable(new sdk.APIConnectionError('transport')), true); assert.equal(isResumableStreamRetryable(new sdk.APIConnectionTimeoutError('timeout')), true); assert.equal(isResumableStreamRetryable(new sdk.APIUserAbortError('abort')), false); + assert.equal(isResumableStreamRetryable(new sdk.APIError(undefined, undefined, 'no response')), false); assert.equal(resumableStreamRetryDelay(0, () => 0), 250); assert.equal(resumableStreamRetryDelay(20, () => 1), 10_000); }); diff --git a/tests/types-smoke.ts b/tests/types-smoke.ts index 5c1e3e4..4571849 100644 --- a/tests/types-smoke.ts +++ b/tests/types-smoke.ts @@ -1,4 +1,5 @@ -import { ForwardClient, ManagedClient, APIPromise, Stream, ResumableSessionEventStream, toFile } from 'qca-sdk'; +import { ForwardClient, ManagedClient, APIPromise, Stream, ResumableSessionEventStream, toFile, + APIError, APIConnectionError, APIConnectionTimeoutError, APIUserAbortError, NotFoundError } from 'qca-sdk'; import Forward from 'qca-sdk/forward'; import Managed from 'qca-sdk/managed'; import type { TemplateUpdateParams, SessionEvent } from 'qca-sdk/forward'; @@ -6,6 +7,19 @@ import type { AgentUpdateParams, ManagedAgentsStreamSessionEventsUnion, SessionE const forward: ForwardClient = new Forward({ pat: async () => 'token', timeout: 5000 }); const managed: ManagedClient = new Managed(); +const derivedForward: ForwardClient = forward.withOptions({ timeout: 30_000 }); +const derivedManaged: ManagedClient = managed.withOptions({ maxRetries: 0 }); +void derivedForward.templates.list({}); +void derivedManaged.agents.list({}); +// @ts-expect-error withOptions preserves Forward resources and does not add Managed resources. +derivedForward.agents; +// @ts-expect-error withOptions preserves Managed resources and does not add Forward resources. +derivedManaged.templates; +// @ts-expect-error overrides must use ClientOptions. +forward.withOptions({ unknownOption: true }); +class CustomForward extends ForwardClient { customMethod(): string { return 'custom'; } } +const customResult: string = new CustomForward().withOptions({ timeout: 1000 }).customMethod(); +void customResult; const forwardPatch: TemplateUpdateParams = { name: '', system: null, tools: [], multiagent: null, metadata: null, model: { id: 'ultimate', effort: 'high', context_window: 400000 }, @@ -72,6 +86,36 @@ async function responseRequestIDs() { } } void responseRequestIDs; + +function requestErrorTypes(error: unknown) { + if (error instanceof APIConnectionError || error instanceof APIUserAbortError) { + const requestError: APIError = error; + const status: undefined = error.status; + const headers: undefined = error.headers; + const body: undefined = error.error; + void [requestError, status, headers, body]; + } + if (error instanceof NotFoundError) { + const status: 404 = error.status; + const headers: Headers = error.headers; + void [status, headers.get('request-id')]; + } + if (error instanceof APIError) { + const apiError: APIError = error; + const status: number | undefined = apiError.status; + const headers: Headers | undefined = apiError.headers; + // @ts-expect-error connection, timeout and abort errors have no HTTP status. + const httpStatus: number = apiError.status; + // @ts-expect-error not every APIError has response headers. + apiError.headers.get('request-id'); + void [status, headers?.get('request-id'), httpStatus]; + } +} +void requestErrorTypes; +const timeoutError: APIConnectionError = new APIConnectionTimeoutError('timeout', { cause: new Error('cause') }); +const generatedStatus: number = APIError.generate(418, {}).status; +const generatedHeaders: Headers = APIError.generate(418, {}).headers; +void [timeoutError, generatedStatus, generatedHeaders]; // @ts-expect-error creating a Template requires environment_id. void forward.templates.create({ name: 'missing environment', model: 'ultimate' }); // @ts-expect-error there is no Service Account Token resource.