Skip to content

Commit 0cc4828

Browse files
feat(client): add support for passing a signal request option (#55)
closes #43
1 parent 174d3db commit 0cc4828

File tree

4 files changed

+54
-5
lines changed

4 files changed

+54
-5
lines changed

src/core.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as qs from 'qs';
22
import { VERSION } from './version';
33
import { Stream } from './streaming';
4-
import { APIError, APIConnectionError, APIConnectionTimeoutError } from './error';
4+
import { APIError, APIConnectionError, APIConnectionTimeoutError, APIUserAbortError } from './error';
55
import type { Readable } from '@anthropic-ai/sdk/_shims/node-readable';
66
import { getDefaultAgent, type Agent } from '@anthropic-ai/sdk/_shims/agent';
77
import {
@@ -183,6 +183,9 @@ export abstract class APIClient {
183183
...(body && { body: body as any }),
184184
headers: reqHeaders,
185185
...(httpAgent && { agent: httpAgent }),
186+
// @ts-ignore node-fetch uses a custom AbortSignal type that is
187+
// not compatible with standard web types
188+
signal: options.signal ?? null,
186189
};
187190

188191
this.validateHeaders(reqHeaders, headers);
@@ -220,8 +223,15 @@ export abstract class APIClient {
220223
const response = await this.fetchWithTimeout(url, req, timeout, controller).catch(castToError);
221224

222225
if (response instanceof Error) {
223-
if (retriesRemaining) return this.retryRequest(options, retriesRemaining);
224-
if (response.name === 'AbortError') throw new APIConnectionTimeoutError();
226+
if (options.signal?.aborted) {
227+
throw new APIUserAbortError();
228+
}
229+
if (retriesRemaining) {
230+
return this.retryRequest(options, retriesRemaining);
231+
}
232+
if (response.name === 'AbortError') {
233+
throw new APIConnectionTimeoutError();
234+
}
225235
throw new APIConnectionError({ cause: response });
226236
}
227237

@@ -561,6 +571,7 @@ export type RequestOptions<Req extends {} = Record<string, unknown> | Readable>
561571
stream?: boolean | undefined;
562572
timeout?: number;
563573
httpAgent?: Agent;
574+
signal?: AbortSignal | undefined | null;
564575
idempotencyKey?: string;
565576
};
566577

@@ -578,6 +589,7 @@ const requestOptionsKeys: KeysEnum<RequestOptions> = {
578589
stream: true,
579590
timeout: true,
580591
httpAgent: true,
592+
signal: true,
581593
idempotencyKey: true,
582594
};
583595

src/error.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,14 @@ export class APIError extends Error {
6767
}
6868
}
6969

70+
export class APIUserAbortError extends APIError {
71+
override readonly status: undefined = undefined;
72+
73+
constructor({ message }: { message?: string } = {}) {
74+
super(undefined, undefined, message || 'Request was aborted.', undefined);
75+
}
76+
}
77+
7078
export class APIConnectionError extends APIError {
7179
override readonly status: undefined = undefined;
7280

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ export class Anthropic extends Core.APIClient {
167167
static APIError = Errors.APIError;
168168
static APIConnectionError = Errors.APIConnectionError;
169169
static APIConnectionTimeoutError = Errors.APIConnectionTimeoutError;
170+
static APIUserAbortError = Errors.APIUserAbortError;
170171
static NotFoundError = Errors.NotFoundError;
171172
static ConflictError = Errors.ConflictError;
172173
static RateLimitError = Errors.RateLimitError;
@@ -183,6 +184,7 @@ export const {
183184
APIError,
184185
APIConnectionError,
185186
APIConnectionTimeoutError,
187+
APIUserAbortError,
186188
NotFoundError,
187189
ConflictError,
188190
RateLimitError,

tests/index.test.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
// File generated from our OpenAPI spec by Stainless.
22

3-
import { Headers } from '@anthropic-ai/sdk/core';
43
import Anthropic from '@anthropic-ai/sdk';
5-
import { Response } from '@anthropic-ai/sdk/_shims/fetch';
4+
import { APIUserAbortError } from '@anthropic-ai/sdk';
5+
import { Headers } from '@anthropic-ai/sdk/core';
6+
import { Response, fetch as defaultFetch } from '@anthropic-ai/sdk/_shims/fetch';
67

78
describe('instantiate client', () => {
89
const env = process.env;
@@ -95,6 +96,32 @@ describe('instantiate client', () => {
9596
expect(response).toEqual({ url: 'http://localhost:5000/foo', custom: true });
9697
});
9798

99+
test('custom signal', async () => {
100+
const client = new Anthropic({
101+
baseURL: 'http://127.0.0.1:4010',
102+
apiKey: 'my api key',
103+
fetch: (...args) => {
104+
return new Promise((resolve, reject) =>
105+
setTimeout(
106+
() =>
107+
defaultFetch(...args)
108+
.then(resolve)
109+
.catch(reject),
110+
300,
111+
),
112+
);
113+
},
114+
});
115+
116+
const controller = new AbortController();
117+
setTimeout(() => controller.abort(), 200);
118+
119+
const spy = jest.spyOn(client, 'request');
120+
121+
await expect(client.get('/foo', { signal: controller.signal })).rejects.toThrowError(APIUserAbortError);
122+
expect(spy).toHaveBeenCalledTimes(1);
123+
});
124+
98125
describe('baseUrl', () => {
99126
test('trailing slash', () => {
100127
const client = new Anthropic({ baseURL: 'http://localhost:5000/custom/path/', apiKey: 'my api key' });

0 commit comments

Comments
 (0)