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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Page<T>>` 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.

Expand Down
29 changes: 23 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions docs/api/index/classes/APIClient.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
140 changes: 133 additions & 7 deletions docs/api/index/classes/APIConnectionError.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

## Extends

- [`QoderError`](QoderError.md)
- [`APIError`](APIError.md)\<`undefined`, `undefined`, `undefined`\>

## Extended by

Expand All @@ -34,9 +34,9 @@

`APIConnectionError`

#### Inherited from
#### Overrides

[`QoderError`](QoderError.md).[`constructor`](QoderError.md#constructor)
[`APIError`](APIError.md).[`constructor`](APIError.md#constructor)

## Properties

Expand All @@ -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)

***

Expand All @@ -56,7 +86,7 @@

#### Inherited from

[`QoderError`](QoderError.md).[`message`](QoderError.md#message)
[`APIError`](APIError.md).[`message`](APIError.md#message)

***

Expand All @@ -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)

***

Expand All @@ -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)
Loading
Loading