Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,4 @@ export {
TaskNotFoundError,
UnsupportedOperationError,
} from '../errors.js';
export type { A2AErrorHttpDetails } from '../errors.js';
27 changes: 18 additions & 9 deletions src/client/transports/rest_transport.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { TransportProtocolName } from '../../core.js';
import {
A2A_ERROR_CODE,
type A2AErrorHttpDetails,
AuthenticatedExtendedCardNotConfiguredError,
ContentTypeNotSupportedError,
InvalidAgentResponseError,
Expand Down Expand Up @@ -260,6 +261,7 @@ export class RestTransport implements Transport {
private async _handleErrorResponse(response: Response, path: string): Promise<never> {
let errorBodyText = '(empty or non-JSON response)';
let errorBody: RestErrorResponse | undefined;
const httpDetails = RestTransport.extractHttpDetails(response);

try {
errorBodyText = await response.text();
Expand All @@ -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(
Expand Down Expand Up @@ -337,22 +339,29 @@ export class RestTransport implements Transport {
}
}

private static mapToError(error: RestErrorResponse): Error {
private static extractHttpDetails(response: Response): A2AErrorHttpDetails {
return {
statusCode: response.status,
headers: Object.fromEntries(response.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)}` : ''}`
Expand Down
74 changes: 67 additions & 7 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
}

// 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<string, string>;

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<string, string>;

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<string, string>;

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<string, string>;

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<string, string>;

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<string, string>;

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<string, string>;

constructor(message?: string, httpDetails?: A2AErrorHttpDetails) {
super(message ?? 'Authenticated Extended Card not configured');
this.name = 'AuthenticatedExtendedCardNotConfiguredError';
this.statusCode = httpDetails?.statusCode;
this.headers = httpDetails?.headers;
}
}
143 changes: 143 additions & 0 deletions test/client/transports/rest_transport.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import {
TaskNotFoundError,
TaskNotCancelableError,
PushNotificationNotSupportedError,
UnsupportedOperationError,
ContentTypeNotSupportedError,
InvalidAgentResponseError,
AuthenticatedExtendedCardNotConfiguredError,
} from '../../../src/errors.js';
import {
createMessageParams,
Expand Down Expand Up @@ -368,6 +372,145 @@ 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');
}
});
});
});

Expand Down