diff --git a/README.md b/README.md index daba6e63..4d64feb9 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,18 @@ Error codes are as followed: | >=500 | `InternalServerError` | | N/A | `APIConnectionError` | +## Request IDs + +> For more information on debugging requests, see [these docs](https://docs.anthropic.com/en/api/errors#request-id) + +All object responses in the SDK provide a `_request_id` property which is added from the `request-id` response header so that you can quickly log failing requests and report them back to Anthropic. + +```ts +const message = await client.messages.create({ max_tokens: 1024, messages: [{ role: 'user', content: 'Hello, Claude' }], model: 'claude-3-opus-20240229' }); +console.log(completion._request_id) // req_018EeWyXxfu5pfWkrYcMdjWG +``` + + ### Retries Certain errors will be automatically retried 2 times by default, with a short exponential backoff. diff --git a/src/core.ts b/src/core.ts index a3e22246..dadcafc3 100644 --- a/src/core.ts +++ b/src/core.ts @@ -37,7 +37,7 @@ type APIResponseProps = { controller: AbortController; }; -async function defaultParseResponse(props: APIResponseProps): Promise { +async function defaultParseResponse(props: APIResponseProps): Promise> { const { response } = props; if (props.options.stream) { debug('response', response.status, response.url, response.headers, response.body); @@ -54,11 +54,11 @@ async function defaultParseResponse(props: APIResponseProps): Promise { // fetch refuses to read the body when the status code is 204. if (response.status === 204) { - return null as T; + return null as WithRequestID; } if (props.options.__binaryResponse) { - return response as unknown as T; + return response as unknown as WithRequestID; } const contentType = response.headers.get('content-type'); @@ -69,26 +69,44 @@ async function defaultParseResponse(props: APIResponseProps): Promise { debug('response', response.status, response.url, response.headers, json); - return json as T; + return _addRequestID(json as T, response); } const text = await response.text(); debug('response', response.status, response.url, response.headers, text); // TODO handle blob, arraybuffer, other content types, etc. - return text as unknown as T; + return text as unknown as WithRequestID; +} + +type WithRequestID = + T extends Array | Response | AbstractPage ? T + : T extends Record ? T & { _request_id?: string | null } + : T; + +function _addRequestID(value: T, response: Response): WithRequestID { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return value as WithRequestID; + } + + return Object.defineProperty(value, '_request_id', { + value: response.headers.get('request-id'), + enumerable: false, + }) as WithRequestID; } /** * A subclass of `Promise` providing additional helper methods * for interacting with the SDK. */ -export class APIPromise extends Promise { - private parsedPromise: Promise | undefined; +export class APIPromise extends Promise> { + private parsedPromise: Promise> | undefined; constructor( private responsePromise: Promise, - private parseResponse: (props: APIResponseProps) => PromiseOrValue = defaultParseResponse, + private parseResponse: ( + props: APIResponseProps, + ) => PromiseOrValue> = defaultParseResponse, ) { super((resolve) => { // this is maybe a bit weird but this has to be a no-op to not implicitly @@ -100,7 +118,7 @@ export class APIPromise extends Promise { _thenUnwrap(transform: (data: T, props: APIResponseProps) => U): APIPromise { return new APIPromise(this.responsePromise, async (props) => - transform(await this.parseResponse(props), props), + _addRequestID(transform(await this.parseResponse(props), props), props.response), ); } @@ -120,33 +138,35 @@ export class APIPromise extends Promise { asResponse(): Promise { return this.responsePromise.then((p) => p.response); } + /** - * Gets the parsed response data and the raw `Response` instance. + * Gets the parsed response data, the raw `Response` instance and the ID of the request, + * returned vie the `request-id` header which is useful for debugging requests and resporting + * issues to Anthropic. * * If you just want to get the raw `Response` instance without parsing it, * you can use {@link asResponse()}. * - * * 👋 Getting the wrong TypeScript type for `Response`? * Try setting `"moduleResolution": "NodeNext"` if you can, * or add one of these imports before your first `import … from '@anthropic-ai/sdk'`: * - `import '@anthropic-ai/sdk/shims/node'` (if you're running on Node) * - `import '@anthropic-ai/sdk/shims/web'` (otherwise) */ - async withResponse(): Promise<{ data: T; response: Response }> { + async withResponse(): Promise<{ data: T; response: Response; request_id: string | null | undefined }> { const [data, response] = await Promise.all([this.parse(), this.asResponse()]); - return { data, response }; + return { data, response, request_id: response.headers.get('request-id') }; } - private parse(): Promise { + private parse(): Promise> { if (!this.parsedPromise) { - this.parsedPromise = this.responsePromise.then(this.parseResponse); + this.parsedPromise = this.responsePromise.then(this.parseResponse) as any as Promise>; } return this.parsedPromise; } - override then( - onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, + override then, TResult2 = never>( + onfulfilled?: ((value: WithRequestID) => TResult1 | PromiseLike) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null, ): Promise { return this.parse().then(onfulfilled, onrejected); @@ -154,11 +174,11 @@ export class APIPromise extends Promise { override catch( onrejected?: ((reason: any) => TResult | PromiseLike) | undefined | null, - ): Promise { + ): Promise | TResult> { return this.parse().catch(onrejected); } - override finally(onfinally?: (() => void) | undefined | null): Promise { + override finally(onfinally?: (() => void) | undefined | null): Promise> { return this.parse().finally(onfinally); } } @@ -724,7 +744,13 @@ export class PagePromise< ) { super( request, - async (props) => new Page(client, props.response, await defaultParseResponse(props), props.options), + async (props) => + new Page( + client, + props.response, + await defaultParseResponse(props), + props.options, + ) as WithRequestID, ); } diff --git a/tests/responses.test.ts b/tests/responses.test.ts index d0db2b1f..db58e0b7 100644 --- a/tests/responses.test.ts +++ b/tests/responses.test.ts @@ -1,5 +1,8 @@ -import { createResponseHeaders } from '@anthropic-ai/sdk/core'; +import { APIPromise, createResponseHeaders } from '@anthropic-ai/sdk/core'; +import Anthropic from '@anthropic-ai/sdk/index'; import { Headers } from '@anthropic-ai/sdk/_shims/index'; +import { Response } from 'node-fetch'; +import { compareType } from './utils/typing'; describe('response parsing', () => { // TODO: test unicode characters @@ -23,3 +26,129 @@ describe('response parsing', () => { expect(headers['content-type']).toBe('text/xml, application/json'); }); }); + +describe('request id', () => { + test('types', () => { + compareType>, string>(true); + compareType>, number>(true); + compareType>, null>(true); + compareType>, void>(true); + compareType>, Response>(true); + compareType>, Response>(true); + compareType>, { foo: string } & { _request_id?: string | null }>( + true, + ); + compareType>>, Array<{ foo: string }>>(true); + }); + + test('withResponse', async () => { + const client = new Anthropic({ + apiKey: 'dummy', + fetch: async () => + new Response(JSON.stringify({ id: 'bar' }), { + headers: { 'request-id': 'req_xxx', 'content-type': 'application/json' }, + }), + }); + + const { + data: message, + response, + request_id, + } = await client.messages + .create({ messages: [], model: 'claude-3-opus-20240229', max_tokens: 1024 }) + .withResponse(); + + expect(request_id).toBe('req_xxx'); + expect(response.headers.get('request-id')).toBe('req_xxx'); + expect(message.id).toBe('bar'); + expect(JSON.stringify(message)).toBe('{"id":"bar"}'); + }); + + test('object response', async () => { + const client = new Anthropic({ + apiKey: 'dummy', + fetch: async () => + new Response(JSON.stringify({ id: 'bar' }), { + headers: { 'request-id': 'req_xxx', 'content-type': 'application/json' }, + }), + }); + + const rsp = await client.messages.create({ + messages: [], + model: 'claude-3-opus-20240229', + max_tokens: 1024, + }); + expect(rsp.id).toBe('bar'); + expect(rsp._request_id).toBe('req_xxx'); + expect(JSON.stringify(rsp)).toBe('{"id":"bar"}'); + }); + + test('envelope response', async () => { + const promise = new APIPromise<{ data: { foo: string } }>( + (async () => { + return { + response: new Response(JSON.stringify({ data: { foo: 'bar' } }), { + headers: { 'request-id': 'req_xxx', 'content-type': 'application/json' }, + }), + controller: {} as any, + options: {} as any, + }; + })(), + )._thenUnwrap((d) => d.data); + + const rsp = await promise; + expect(rsp.foo).toBe('bar'); + expect(rsp._request_id).toBe('req_xxx'); + }); + + test('page response', async () => { + const client = new Anthropic({ + apiKey: 'dummy', + fetch: async () => + new Response(JSON.stringify({ data: [{ foo: 'bar' }] }), { + headers: { 'request-id': 'req_xxx', 'content-type': 'application/json' }, + }), + }); + + const page = await client.beta.messages.batches.list(); + expect(page.data).toMatchObject([{ foo: 'bar' }]); + expect((page as any)._request_id).toBeUndefined(); + }); + + test('array response', async () => { + const promise = new APIPromise>( + (async () => { + return { + response: new Response(JSON.stringify([{ foo: 'bar' }]), { + headers: { 'request-id': 'req_xxx', 'content-type': 'application/json' }, + }), + controller: {} as any, + options: {} as any, + }; + })(), + ); + + const rsp = await promise; + expect(rsp.length).toBe(1); + expect(rsp[0]).toMatchObject({ foo: 'bar' }); + expect((rsp as any)._request_id).toBeUndefined(); + }); + + test('string response', async () => { + const promise = new APIPromise( + (async () => { + return { + response: new Response('hello world', { + headers: { 'request-id': 'req_xxx', 'content-type': 'application/text' }, + }), + controller: {} as any, + options: {} as any, + }; + })(), + ); + + const result = await promise; + expect(result).toBe('hello world'); + expect((result as any)._request_id).toBeUndefined(); + }); +}); diff --git a/tests/utils/typing.ts b/tests/utils/typing.ts new file mode 100644 index 00000000..4a791d2a --- /dev/null +++ b/tests/utils/typing.ts @@ -0,0 +1,9 @@ +type Equal = (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 ? true : false; + +export const expectType = (_expression: T): void => { + return; +}; + +export const compareType = (_expression: Equal): void => { + return; +};