From 96aa8ccd569af1613b9103fdb4f095a703450c89 Mon Sep 17 00:00:00 2001 From: corvid-agent <0xOpenBytes@gmail.com> Date: Mon, 9 Feb 2026 14:38:52 -0700 Subject: [PATCH 1/3] feat: expose HTTP status code and headers on SDK error classes Add optional `statusCode` and `headers` properties to all A2A SDK error classes (TaskNotFoundError, TaskNotCancelableError, etc.) so that consumers can implement HTTP-aware error handling (e.g. distinguishing 401 from 404 from 429). The REST transport now populates these fields from the HTTP response when mapping errors. Closes #317 Co-Authored-By: Claude Opus 4.6 --- src/client/index.ts | 1 + src/client/transports/rest_transport.ts | 28 ++-- src/errors.ts | 74 ++++++++- test/client/transports/rest_transport.spec.ts | 145 ++++++++++++++++++ 4 files changed, 232 insertions(+), 16 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index a9b4298f..b9ee1016 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -45,3 +45,4 @@ export { TaskNotFoundError, UnsupportedOperationError, } from '../errors.js'; +export type { A2AErrorHttpDetails } from '../errors.js'; diff --git a/src/client/transports/rest_transport.ts b/src/client/transports/rest_transport.ts index 15f2080d..6ef772af 100644 --- a/src/client/transports/rest_transport.ts +++ b/src/client/transports/rest_transport.ts @@ -1,6 +1,7 @@ import { TransportProtocolName } from '../../core.js'; import { A2A_ERROR_CODE, + type A2AErrorHttpDetails, AuthenticatedExtendedCardNotConfiguredError, ContentTypeNotSupportedError, InvalidAgentResponseError, @@ -260,6 +261,7 @@ export class RestTransport implements Transport { private async _handleErrorResponse(response: Response, path: string): Promise { let errorBodyText = '(empty or non-JSON response)'; let errorBody: RestErrorResponse | undefined; + const httpDetails = RestTransport.extractHttpDetails(response); try { errorBodyText = await response.text(); @@ -274,7 +276,7 @@ export class RestTransport implements Transport { } if (errorBody && typeof errorBody.code === 'number') { - throw RestTransport.mapToError(errorBody); + throw RestTransport.mapToError(errorBody, httpDetails); } throw new Error( @@ -337,22 +339,30 @@ export class RestTransport implements Transport { } } - private static mapToError(error: RestErrorResponse): Error { + private static extractHttpDetails(response: Response): A2AErrorHttpDetails { + const headers: Record = {}; + response.headers.forEach((value, key) => { + headers[key] = value; + }); + return { statusCode: response.status, headers }; + } + + private static mapToError(error: RestErrorResponse, httpDetails?: A2AErrorHttpDetails): Error { switch (error.code) { case A2A_ERROR_CODE.TASK_NOT_FOUND: - return new TaskNotFoundError(error.message); + return new TaskNotFoundError(error.message, httpDetails); case A2A_ERROR_CODE.TASK_NOT_CANCELABLE: - return new TaskNotCancelableError(error.message); + return new TaskNotCancelableError(error.message, httpDetails); case A2A_ERROR_CODE.PUSH_NOTIFICATION_NOT_SUPPORTED: - return new PushNotificationNotSupportedError(error.message); + return new PushNotificationNotSupportedError(error.message, httpDetails); case A2A_ERROR_CODE.UNSUPPORTED_OPERATION: - return new UnsupportedOperationError(error.message); + return new UnsupportedOperationError(error.message, httpDetails); case A2A_ERROR_CODE.CONTENT_TYPE_NOT_SUPPORTED: - return new ContentTypeNotSupportedError(error.message); + return new ContentTypeNotSupportedError(error.message, httpDetails); case A2A_ERROR_CODE.INVALID_AGENT_RESPONSE: - return new InvalidAgentResponseError(error.message); + return new InvalidAgentResponseError(error.message, httpDetails); case A2A_ERROR_CODE.AUTHENTICATED_EXTENDED_CARD_NOT_CONFIGURED: - return new AuthenticatedExtendedCardNotConfiguredError(error.message); + return new AuthenticatedExtendedCardNotConfiguredError(error.message, httpDetails); default: return new Error( `REST error: ${error.message} (Code: ${error.code})${error.data ? ` Data: ${JSON.stringify(error.data)}` : ''}` diff --git a/src/errors.ts b/src/errors.ts index 6fbdf260..8bc37260 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -14,56 +14,116 @@ export const A2A_ERROR_CODE = { AUTHENTICATED_EXTENDED_CARD_NOT_CONFIGURED: -32007, } as const; +/** + * Optional HTTP transport details that can be attached to any A2A error + * originating from an HTTP response. + */ +export interface A2AErrorHttpDetails { + /** The HTTP status code from the response (e.g. 401, 404, 429, 503). */ + statusCode?: number; + /** Selected HTTP response headers, if available. */ + headers?: Record; +} + // Transport-agnostic errors according to https://a2a-protocol.org/v0.3.0/specification/#82-a2a-specific-errors. // Due to a name conflict with legacy JSON-RPC types reexported from src/index.ts // below errors are going to be exported via src/client/index.ts to allow usage // from external transport implementations. export class TaskNotFoundError extends Error { - constructor(message?: string) { + /** The HTTP status code from the response, if the error originated from an HTTP transport. */ + statusCode?: number; + /** Selected HTTP response headers, if the error originated from an HTTP transport. */ + headers?: Record; + + constructor(message?: string, httpDetails?: A2AErrorHttpDetails) { super(message ?? 'Task not found'); this.name = 'TaskNotFoundError'; + this.statusCode = httpDetails?.statusCode; + this.headers = httpDetails?.headers; } } export class TaskNotCancelableError extends Error { - constructor(message?: string) { + /** The HTTP status code from the response, if the error originated from an HTTP transport. */ + statusCode?: number; + /** Selected HTTP response headers, if the error originated from an HTTP transport. */ + headers?: Record; + + constructor(message?: string, httpDetails?: A2AErrorHttpDetails) { super(message ?? 'Task cannot be canceled'); this.name = 'TaskNotCancelableError'; + this.statusCode = httpDetails?.statusCode; + this.headers = httpDetails?.headers; } } export class PushNotificationNotSupportedError extends Error { - constructor(message?: string) { + /** The HTTP status code from the response, if the error originated from an HTTP transport. */ + statusCode?: number; + /** Selected HTTP response headers, if the error originated from an HTTP transport. */ + headers?: Record; + + constructor(message?: string, httpDetails?: A2AErrorHttpDetails) { super(message ?? 'Push Notification is not supported'); this.name = 'PushNotificationNotSupportedError'; + this.statusCode = httpDetails?.statusCode; + this.headers = httpDetails?.headers; } } export class UnsupportedOperationError extends Error { - constructor(message?: string) { + /** The HTTP status code from the response, if the error originated from an HTTP transport. */ + statusCode?: number; + /** Selected HTTP response headers, if the error originated from an HTTP transport. */ + headers?: Record; + + constructor(message?: string, httpDetails?: A2AErrorHttpDetails) { super(message ?? 'This operation is not supported'); this.name = 'UnsupportedOperationError'; + this.statusCode = httpDetails?.statusCode; + this.headers = httpDetails?.headers; } } export class ContentTypeNotSupportedError extends Error { - constructor(message?: string) { + /** The HTTP status code from the response, if the error originated from an HTTP transport. */ + statusCode?: number; + /** Selected HTTP response headers, if the error originated from an HTTP transport. */ + headers?: Record; + + constructor(message?: string, httpDetails?: A2AErrorHttpDetails) { super(message ?? 'Incompatible content types'); this.name = 'ContentTypeNotSupportedError'; + this.statusCode = httpDetails?.statusCode; + this.headers = httpDetails?.headers; } } export class InvalidAgentResponseError extends Error { - constructor(message?: string) { + /** The HTTP status code from the response, if the error originated from an HTTP transport. */ + statusCode?: number; + /** Selected HTTP response headers, if the error originated from an HTTP transport. */ + headers?: Record; + + constructor(message?: string, httpDetails?: A2AErrorHttpDetails) { super(message ?? 'Invalid agent response type'); this.name = 'InvalidAgentResponseError'; + this.statusCode = httpDetails?.statusCode; + this.headers = httpDetails?.headers; } } export class AuthenticatedExtendedCardNotConfiguredError extends Error { - constructor(message?: string) { + /** The HTTP status code from the response, if the error originated from an HTTP transport. */ + statusCode?: number; + /** Selected HTTP response headers, if the error originated from an HTTP transport. */ + headers?: Record; + + constructor(message?: string, httpDetails?: A2AErrorHttpDetails) { super(message ?? 'Authenticated Extended Card not configured'); this.name = 'AuthenticatedExtendedCardNotConfiguredError'; + this.statusCode = httpDetails?.statusCode; + this.headers = httpDetails?.headers; } } diff --git a/test/client/transports/rest_transport.spec.ts b/test/client/transports/rest_transport.spec.ts index 03e5c136..98fe6089 100644 --- a/test/client/transports/rest_transport.spec.ts +++ b/test/client/transports/rest_transport.spec.ts @@ -11,6 +11,10 @@ import { TaskNotFoundError, TaskNotCancelableError, PushNotificationNotSupportedError, + UnsupportedOperationError, + ContentTypeNotSupportedError, + InvalidAgentResponseError, + AuthenticatedExtendedCardNotConfiguredError, } from '../../../src/errors.js'; import { createMessageParams, @@ -368,6 +372,147 @@ describe('RestTransport', () => { await expect(transport.getTask({ id: 'task-123' })).rejects.toThrow('Network error'); }); + + it('should include HTTP statusCode on TaskNotFoundError', async () => { + mockFetch.mockResolvedValue(createRestErrorResponse(-32001, 'Task not found', 404)); + + try { + await transport.getTask({ id: 'nonexistent' }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(TaskNotFoundError); + const error = err as TaskNotFoundError; + expect(error.statusCode).to.equal(404); + expect(error.headers).toBeDefined(); + expect(error.headers!['content-type']).to.equal('application/json'); + } + }); + + it('should include HTTP statusCode on TaskNotCancelableError', async () => { + mockFetch.mockResolvedValue( + createRestErrorResponse(-32002, 'Task cannot be canceled', 409) + ); + + try { + await transport.cancelTask({ id: 'task-123' }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(TaskNotCancelableError); + const error = err as TaskNotCancelableError; + expect(error.statusCode).to.equal(409); + } + }); + + it('should include HTTP statusCode on PushNotificationNotSupportedError', async () => { + mockFetch.mockResolvedValue( + createRestErrorResponse(-32003, 'Push notifications not supported', 400) + ); + + const mockConfig: TaskPushNotificationConfig = { + taskId: 'task-123', + pushNotificationConfig: { + id: 'config-1', + url: 'https://notify.example.com/webhook', + authentication: undefined, + token: 'secret', + }, + }; + + try { + await transport.setTaskPushNotificationConfig(mockConfig); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(PushNotificationNotSupportedError); + const error = err as PushNotificationNotSupportedError; + expect(error.statusCode).to.equal(400); + } + }); + + it('should include HTTP statusCode on UnsupportedOperationError', async () => { + mockFetch.mockResolvedValue( + createRestErrorResponse(-32004, 'This operation is not supported', 405) + ); + + try { + await transport.getTask({ id: 'task-123' }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(UnsupportedOperationError); + const error = err as UnsupportedOperationError; + expect(error.statusCode).to.equal(405); + } + }); + + it('should include HTTP statusCode on ContentTypeNotSupportedError', async () => { + mockFetch.mockResolvedValue( + createRestErrorResponse(-32005, 'Incompatible content types', 415) + ); + + try { + await transport.sendMessage(createMessageParams()); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(ContentTypeNotSupportedError); + const error = err as ContentTypeNotSupportedError; + expect(error.statusCode).to.equal(415); + } + }); + + it('should include HTTP statusCode on InvalidAgentResponseError', async () => { + mockFetch.mockResolvedValue( + createRestErrorResponse(-32006, 'Invalid agent response type', 502) + ); + + try { + await transport.sendMessage(createMessageParams()); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(InvalidAgentResponseError); + const error = err as InvalidAgentResponseError; + expect(error.statusCode).to.equal(502); + } + }); + + it('should include HTTP statusCode on AuthenticatedExtendedCardNotConfiguredError', async () => { + mockFetch.mockResolvedValue( + createRestErrorResponse(-32007, 'Extended card not configured', 401) + ); + + try { + await transport.getExtendedAgentCard(); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(AuthenticatedExtendedCardNotConfiguredError); + const error = err as AuthenticatedExtendedCardNotConfiguredError; + expect(error.statusCode).to.equal(401); + } + }); + + it('should include response headers on errors for HTTP-aware handling', async () => { + const customHeaders = { + 'content-type': 'application/json', + 'retry-after': '30', + 'x-ratelimit-remaining': '0', + }; + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ code: -32001, message: 'Task not found' }), { + status: 429, + headers: customHeaders, + }) + ); + + try { + await transport.getTask({ id: 'task-123' }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(TaskNotFoundError); + const error = err as TaskNotFoundError; + expect(error.statusCode).to.equal(429); + expect(error.headers).toBeDefined(); + expect(error.headers!['retry-after']).to.equal('30'); + expect(error.headers!['x-ratelimit-remaining']).to.equal('0'); + } + }); }); }); From fb2b5faaf289c8bd7dde108af3f1f0615486c931 Mon Sep 17 00:00:00 2001 From: corvid-agent <0xOpenBytes@gmail.com> Date: Mon, 9 Feb 2026 14:53:03 -0700 Subject: [PATCH 2/3] style: fix Prettier formatting in rest_transport test Co-Authored-By: Claude Opus 4.6 --- test/client/transports/rest_transport.spec.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/client/transports/rest_transport.spec.ts b/test/client/transports/rest_transport.spec.ts index 98fe6089..94235f53 100644 --- a/test/client/transports/rest_transport.spec.ts +++ b/test/client/transports/rest_transport.spec.ts @@ -389,9 +389,7 @@ describe('RestTransport', () => { }); it('should include HTTP statusCode on TaskNotCancelableError', async () => { - mockFetch.mockResolvedValue( - createRestErrorResponse(-32002, 'Task cannot be canceled', 409) - ); + mockFetch.mockResolvedValue(createRestErrorResponse(-32002, 'Task cannot be canceled', 409)); try { await transport.cancelTask({ id: 'task-123' }); From 0be73a4568cf058781d0d96132f32cedd1d3378e Mon Sep 17 00:00:00 2001 From: corvid-agent <0xOpenBytes@gmail.com> Date: Tue, 10 Feb 2026 07:17:29 -0700 Subject: [PATCH 3/3] refactor: use Object.fromEntries for headers conversion Address review feedback: simplify headers extraction by using Object.fromEntries() instead of manual forEach loop. Co-Authored-By: Claude Opus 4.5 --- src/client/transports/rest_transport.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/client/transports/rest_transport.ts b/src/client/transports/rest_transport.ts index 6ef772af..fc9384f7 100644 --- a/src/client/transports/rest_transport.ts +++ b/src/client/transports/rest_transport.ts @@ -340,11 +340,10 @@ export class RestTransport implements Transport { } private static extractHttpDetails(response: Response): A2AErrorHttpDetails { - const headers: Record = {}; - response.headers.forEach((value, key) => { - headers[key] = value; - }); - return { statusCode: response.status, headers }; + return { + statusCode: response.status, + headers: Object.fromEntries(response.headers), + }; } private static mapToError(error: RestErrorResponse, httpDetails?: A2AErrorHttpDetails): Error {