diff --git a/.changeset/silent-wolves-cry.md b/.changeset/silent-wolves-cry.md new file mode 100644 index 000000000000..f15656d72568 --- /dev/null +++ b/.changeset/silent-wolves-cry.md @@ -0,0 +1,21 @@ +--- +'@solana/rpc-transport-http': minor +'@solana/errors': minor +--- + +When the HTTP transport throws an error, you can now access the response headers through `e.context.headers`. This can be useful, for instance, if the HTTP error is a 429 Rate Limit error, and the response contains a `Retry-After` header. + +```ts +try { + const { value: latestBlockhash } = await rpc.getLatestBlockhash().send(); +} catch (e) { + if (isSolanaError(e, SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR)) { + if (e.context.code === 429 /* rate limit error */) { + const retryAfterHeaderValue = e.context.headers.get('Retry-After'); + if (retryAfterHeaderValue != null) { + // ... + } + } + } +} +``` diff --git a/packages/errors/src/context.ts b/packages/errors/src/context.ts index 7f2cb6cc600a..0a32587f304b 100644 --- a/packages/errors/src/context.ts +++ b/packages/errors/src/context.ts @@ -506,6 +506,7 @@ export type SolanaErrorContext = DefaultUnspecifiedErrorContextToUndefined< value: bigint; }; [SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR]: { + headers: Headers; message: string; statusCode: number; }; diff --git a/packages/rpc-transport-http/src/__tests__/http-transport-test.ts b/packages/rpc-transport-http/src/__tests__/http-transport-test.ts index 72d0a32346e5..382caf78ca87 100644 --- a/packages/rpc-transport-http/src/__tests__/http-transport-test.ts +++ b/packages/rpc-transport-http/src/__tests__/http-transport-test.ts @@ -19,8 +19,11 @@ describe('createHttpTransport', () => { }); }); describe('when the endpoint returns a non-200 status code', () => { + let expectedHeaders: Headers; beforeEach(() => { + expectedHeaders = new Headers([['Sekrit-Response-Header', 'doNotLog']]); fetchSpy.mockResolvedValue({ + headers: expectedHeaders, ok: false, status: 404, statusText: 'We looked everywhere', @@ -31,11 +34,45 @@ describe('createHttpTransport', () => { const requestPromise = makeHttpRequest({ payload: 123 }); await expect(requestPromise).rejects.toThrow( new SolanaError(SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR, { + headers: expectedHeaders, message: 'We looked everywhere', statusCode: 404, }), ); }); + it('exposes the response headers on the error context', async () => { + expect.assertions(2); + let thrownError!: SolanaError; + try { + await makeHttpRequest({ payload: 123 }); + } catch (e) { + thrownError = e as SolanaError; + } + expect(thrownError).toBeDefined(); + expect(thrownError.context.headers).toBe(expectedHeaders); + }); + it('does not leak the response header values through the error message', async () => { + expect.assertions(2); + let thrownError!: SolanaError; + try { + await makeHttpRequest({ payload: 123 }); + } catch (e) { + thrownError = e as SolanaError; + } + expect(thrownError).toBeDefined(); + expect(thrownError.message).not.toMatch(/doNotLog/); + }); + it('the `Headers` on the error context do not leak the header values when stringified', async () => { + expect.assertions(2); + let thrownError!: SolanaError; + try { + await makeHttpRequest({ payload: 123 }); + } catch (e) { + thrownError = e as SolanaError; + } + expect(thrownError).toBeDefined(); + expect(`${thrownError.context.headers}`).not.toMatch(/doNotLog/); + }); }); describe('when the transport fatals', () => { beforeEach(() => { diff --git a/packages/rpc-transport-http/src/http-transport.ts b/packages/rpc-transport-http/src/http-transport.ts index e06d48fce8eb..f93319413e1f 100644 --- a/packages/rpc-transport-http/src/http-transport.ts +++ b/packages/rpc-transport-http/src/http-transport.ts @@ -65,6 +65,7 @@ export function createHttpTransport(config: Config): RpcTransport { const response = await fetch(url, requestInfo); if (!response.ok) { throw new SolanaError(SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR, { + headers: response.headers, message: response.statusText, statusCode: response.status, });