Skip to content

Commit

Permalink
chore(internal): debug logging for retries; speculative retry-after-m…
Browse files Browse the repository at this point in the history
…s support (#256)
  • Loading branch information
stainless-bot committed Jan 17, 2024
1 parent 553fb37 commit b4b70fd
Show file tree
Hide file tree
Showing 2 changed files with 77 additions and 14 deletions.
28 changes: 18 additions & 10 deletions src/core.ts
Expand Up @@ -417,14 +417,17 @@ export abstract class APIClient {

if (!response.ok) {
if (retriesRemaining && this.shouldRetry(response)) {
const retryMessage = `retrying, ${retriesRemaining} attempts remaining`;
debug(`response (error; ${retryMessage})`, response.status, url, responseHeaders);
return this.retryRequest(options, retriesRemaining, responseHeaders);
}

const errText = await response.text().catch((e) => castToError(e).message);
const errJSON = safeJSON(errText);
const errMessage = errJSON ? undefined : errText;
const retryMessage = retriesRemaining ? `(error; no more retries left)` : `(error; not retryable)`;

debug('response', response.status, url, responseHeaders, errMessage);
debug(`response (error; ${retryMessage})`, response.status, url, responseHeaders, errMessage);

const err = this.makeStatusError(response.status, errJSON, errMessage, responseHeaders);
throw err;
Expand Down Expand Up @@ -529,11 +532,21 @@ export abstract class APIClient {
retriesRemaining: number,
responseHeaders?: Headers | undefined,
): Promise<APIResponseProps> {
// About the Retry-After header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
let timeoutMillis: number | undefined;

// Note the `retry-after-ms` header may not be standard, but is a good idea and we'd like proactive support for it.
const retryAfterMillisHeader = responseHeaders?.['retry-after-ms'];
if (retryAfterMillisHeader) {
const timeoutMs = parseFloat(retryAfterMillisHeader);
if (!Number.isNaN(timeoutMs)) {
timeoutMillis = timeoutMs;
}
}

// About the Retry-After header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
const retryAfterHeader = responseHeaders?.['retry-after'];
if (retryAfterHeader) {
const timeoutSeconds = parseInt(retryAfterHeader);
if (retryAfterHeader && !timeoutMillis) {
const timeoutSeconds = parseFloat(retryAfterHeader);
if (!Number.isNaN(timeoutSeconds)) {
timeoutMillis = timeoutSeconds * 1000;
} else {
Expand All @@ -543,12 +556,7 @@ export abstract class APIClient {

// If the API asks us to wait a certain amount of time (and it's a reasonable amount),
// just do what it says, but otherwise calculate a default
if (
!timeoutMillis ||
!Number.isInteger(timeoutMillis) ||
timeoutMillis <= 0 ||
timeoutMillis > 60 * 1000
) {
if (!(timeoutMillis && 0 <= timeoutMillis && timeoutMillis < 60 * 1000)) {
const maxRetries = options.maxRetries ?? this.maxRetries;
timeoutMillis = this.calculateDefaultRetryTimeoutMillis(retriesRemaining, maxRetries);
}
Expand Down
63 changes: 59 additions & 4 deletions tests/index.test.ts
Expand Up @@ -223,17 +223,18 @@ describe('request building', () => {
});

describe('retries', () => {
test('single retry', async () => {
test('retry on timeout', async () => {
let count = 0;
const testFetch = async (url: RequestInfo, { signal }: RequestInit = {}): Promise<Response> => {
if (!count++)
if (count++ === 0) {
return new Promise(
(resolve, reject) => signal?.addEventListener('abort', () => reject(new Error('timed out'))),
);
}
return new Response(JSON.stringify({ a: 1 }), { headers: { 'Content-Type': 'application/json' } });
};

const client = new Anthropic({ apiKey: 'my-anthropic-api-key', timeout: 2000, fetch: testFetch });
const client = new Anthropic({ apiKey: 'my-anthropic-api-key', timeout: 10, fetch: testFetch });

expect(await client.request({ path: '/foo', method: 'get' })).toEqual({ a: 1 });
expect(count).toEqual(2);
Expand All @@ -244,5 +245,59 @@ describe('retries', () => {
.then((r) => r.text()),
).toEqual(JSON.stringify({ a: 1 }));
expect(count).toEqual(3);
}, 10000);
});

test('retry on 429 with retry-after', async () => {
let count = 0;
const testFetch = async (url: RequestInfo, { signal }: RequestInit = {}): Promise<Response> => {
if (count++ === 0) {
return new Response(undefined, {
status: 429,
headers: {
'Retry-After': '0.1',
},
});
}
return new Response(JSON.stringify({ a: 1 }), { headers: { 'Content-Type': 'application/json' } });
};

const client = new Anthropic({ apiKey: 'my-anthropic-api-key', fetch: testFetch });

expect(await client.request({ path: '/foo', method: 'get' })).toEqual({ a: 1 });
expect(count).toEqual(2);
expect(
await client
.request({ path: '/foo', method: 'get' })
.asResponse()
.then((r) => r.text()),
).toEqual(JSON.stringify({ a: 1 }));
expect(count).toEqual(3);
});

test('retry on 429 with retry-after-ms', async () => {
let count = 0;
const testFetch = async (url: RequestInfo, { signal }: RequestInit = {}): Promise<Response> => {
if (count++ === 0) {
return new Response(undefined, {
status: 429,
headers: {
'Retry-After-Ms': '10',
},
});
}
return new Response(JSON.stringify({ a: 1 }), { headers: { 'Content-Type': 'application/json' } });
};

const client = new Anthropic({ apiKey: 'my-anthropic-api-key', fetch: testFetch });

expect(await client.request({ path: '/foo', method: 'get' })).toEqual({ a: 1 });
expect(count).toEqual(2);
expect(
await client
.request({ path: '/foo', method: 'get' })
.asResponse()
.then((r) => r.text()),
).toEqual(JSON.stringify({ a: 1 }));
expect(count).toEqual(3);
});
});

0 comments on commit b4b70fd

Please sign in to comment.