From f6b08fad25d095c8dd51c1856363814b0b54843e Mon Sep 17 00:00:00 2001 From: JakubWorek Date: Fri, 17 Jul 2026 12:13:31 +0000 Subject: [PATCH 01/11] feat!: unify A2AError hierarchy with transport specific subclasses --- docs/migration-guide.md | 60 ++- src/client/index.ts | 11 - src/client/multitransport-client.ts | 2 +- src/client/transports/grpc/grpc_transport.ts | 40 +- src/client/transports/json_rpc_transport.ts | 2 +- src/client/transports/rest_transport.ts | 44 +- .../client/transports/grpc/grpc_transport.ts | 40 +- .../client/transports/json_rpc_transport.ts | 8 +- .../v0_3/client/transports/rest_transport.ts | 28 +- src/compat/v0_3/server/error.ts | 185 ++++---- .../v0_3/server/express/agent_card_handler.ts | 2 +- src/compat/v0_3/server/grpc/grpc_service.ts | 79 +--- .../transports/rest/rest_transport_handler.ts | 20 +- src/compat/v0_3/translate/agent_card.ts | 2 +- src/compat/v0_3/translate/errors.ts | 65 ++- src/errors.ts | 354 ---------------- src/errors/base.ts | 394 ++++++++++++++++++ src/errors/grpc.ts | 237 +++++++++++ src/errors/index.ts | 6 + src/errors/json_rpc.ts | 203 +++++++++ src/errors/rest.ts | 192 +++++++++ src/index.ts | 5 + src/server/express/json_rpc_handler.ts | 2 +- src/server/express/rest_handler.ts | 2 +- src/server/grpc/error_details.ts | 67 --- src/server/grpc/grpc_service.ts | 58 +-- src/server/index.ts | 11 - .../default_request_handler.ts | 12 +- src/server/store.ts | 2 +- .../jsonrpc/jsonrpc_transport_handler.ts | 20 +- .../transports/rest/rest_transport_handler.ts | 71 +--- src/server/version.ts | 2 +- src/types/converters/from_proto.ts | 2 +- src/types/converters/to_proto.ts | 2 +- test/client/transports/grpc_transport.spec.ts | 6 +- test/client/transports/rest_transport.spec.ts | 2 +- test/client/util.ts | 19 +- .../transports/grpc/grpc_transport.spec.ts | 9 +- .../transports/json_rpc_transport.spec.ts | 4 +- .../client/transports/rest_transport.spec.ts | 2 +- test/compat/v0_3/constants.spec.ts | 36 +- .../v0_3/server/grpc/grpc_service.spec.ts | 4 +- .../jsonrpc/jsonrpc_transport_handler.spec.ts | 2 +- .../rest/rest_transport_handler.spec.ts | 2 +- test/compat/v0_3/translate/agent_card.spec.ts | 2 +- test/compat/v0_3/translate/errors.spec.ts | 13 +- test/e2e.spec.ts | 2 +- test/errors.spec.ts | 27 +- test/server/default_request_handler.spec.ts | 2 +- test/server/express/express_app.spec.ts | 2 +- test/server/express/rest_handler.spec.ts | 2 +- test/server/grpc/from_proto.spec.ts | 2 +- test/server/grpc/grpc_handler.spec.ts | 2 +- test/server/jsonrpc_transport_handler.spec.ts | 2 +- .../request_handler/resubscribe.spec.ts | 2 +- test/server/rest_transport_handler.spec.ts | 2 +- test/server/version.spec.ts | 2 +- 57 files changed, 1427 insertions(+), 951 deletions(-) delete mode 100644 src/errors.ts create mode 100644 src/errors/base.ts create mode 100644 src/errors/grpc.ts create mode 100644 src/errors/index.ts create mode 100644 src/errors/json_rpc.ts create mode 100644 src/errors/rest.ts delete mode 100644 src/server/grpc/error_details.ts diff --git a/docs/migration-guide.md b/docs/migration-guide.md index 8c08d963..2d0316ea 100644 --- a/docs/migration-guide.md +++ b/docs/migration-guide.md @@ -267,8 +267,10 @@ tenant-prefixed routes (`/:tenant/tasks/:taskId`, etc.) and validates the ### 3.2 Error Classes Replaced -The monolithic `A2AError` class with static factory methods is removed. -Use specific error classes: +The monolithic `A2AError` class with static factory methods is removed. Errors +now form a shared transport-agnostic hierarchy — one `A2AError` base with +semantic subclasses (`TaskNotFoundError`, `RequestMalformedError`, …) — and +live at the SDK root, not under `/client` or `/server`: ```typescript // v0.3 @@ -277,18 +279,40 @@ throw A2AError.taskNotFound('task-1'); throw A2AError.invalidParams('bad input'); // v1.0 -import { TaskNotFoundError, RequestMalformedError } from '@a2a-js/sdk/server'; -throw new TaskNotFoundError('task-1'); -throw new RequestMalformedError('bad input'); +import { TaskNotFoundError, RequestMalformedError } from '@a2a-js/sdk'; +throw new TaskNotFoundError({ message: 'task-1' }); +throw new RequestMalformedError({ message: 'bad input' }); ``` -Available error classes from `@a2a-js/sdk/server`: `TaskNotFoundError`, -`TaskNotCancelableError`, `RequestMalformedError`, `UnsupportedOperationError`, +Semantic classes: `TaskNotFoundError`, `TaskNotCancelableError`, +`RequestMalformedError`, `UnsupportedOperationError`, `PushNotificationNotSupportedError`, `ContentTypeNotSupportedError`, -`ExtendedAgentCardNotConfiguredError`, `VersionNotSupportedError`. +`InvalidAgentResponseError`, `ExtendedAgentCardNotConfiguredError`, +`ExtensionSupportRequiredError`, `VersionNotSupportedError`, `GenericError`. + +Per-transport variants (`RestTaskNotFoundError`, `GrpcTaskNotFoundError`, +`JsonRpcTaskNotFoundError`, …) carry transport-native context; narrow via the +`isRestError` / `isGrpcError` / `isJsonRpcError` type guards. All catch-side +API surfaces are on the base: + +```typescript +import { isRestError, TaskNotFoundError } from '@a2a-js/sdk'; + +try { + await client.getTask({ id }); +} catch (e) { + if (e instanceof TaskNotFoundError) { + if (isRestError(e)) { + // e.statusCode, e.headers, e.cause are typed + if (e.statusCode === 429) backoff(e.headers?.['retry-after']); + } + } +} +``` Error codes and gRPC/HTTP status mappings are defined in the -[spec](https://a2a-protocol.org/v1.0.0/specification/#54-error-code-mappings). +[spec](https://a2a-protocol.org/v1.0.0/specification/#54-error-code-mappings) +and live in a single registry (`A2A_ERROR_SPECS`) exported from `@a2a-js/sdk`. ### 3.3 `ServerCallContext` -- Now Mandatory @@ -305,7 +329,7 @@ new ServerCallContext(requestedExtensions, user); new ServerCallContext({ requestedExtensions, user, tenant: 'my-tenant', requestedVersion: '1.0' }); ``` -`RequestContext` now wraps the incoming `SendMessageRequest`, +`RequestContext` now wraps the incoming `SendMessageRequest`, and `context` moved from last (optional) to 4th (mandatory). The loose `userMessage` parameter is replaced by `request: SendMessageRequest`; agent executors read the message via `ctx.userMessage` (convenience accessor @@ -445,11 +469,11 @@ await verify(agentCard); ## 5. Import Path Changes -| v0.3 Import | v1.0 Import | -| ------------------------------------------------------------ | ------------------------------------------------------------- | -| `import { A2AClient } from '@a2a-js/sdk/client'` | Removed -- use `ClientFactory` + `Client` | -| `import { TextPart, FilePart, DataPart } from '@a2a-js/sdk'` | Removed -- use `Part` | -| `import { MessageSendParams } from '@a2a-js/sdk'` | `import { SendMessageRequest } from '@a2a-js/sdk'` | -| `import { TaskQueryParams } from '@a2a-js/sdk'` | `import { GetTaskRequest } from '@a2a-js/sdk'` | -| `import { TaskIdParams } from '@a2a-js/sdk'` | `import { CancelTaskRequest } from '@a2a-js/sdk'` | -| `import { A2AError } from '@a2a-js/sdk/server'` | `import { TaskNotFoundError, ... } from '@a2a-js/sdk/server'` | +| v0.3 Import | v1.0 Import | +| ------------------------------------------------------------ | ------------------------------------------------------ | +| `import { A2AClient } from '@a2a-js/sdk/client'` | Removed -- use `ClientFactory` + `Client` | +| `import { TextPart, FilePart, DataPart } from '@a2a-js/sdk'` | Removed -- use `Part` | +| `import { MessageSendParams } from '@a2a-js/sdk'` | `import { SendMessageRequest } from '@a2a-js/sdk'` | +| `import { TaskQueryParams } from '@a2a-js/sdk'` | `import { GetTaskRequest } from '@a2a-js/sdk'` | +| `import { TaskIdParams } from '@a2a-js/sdk'` | `import { CancelTaskRequest } from '@a2a-js/sdk'` | +| `import { A2AError } from '@a2a-js/sdk/server'` | `import { TaskNotFoundError, ... } from '@a2a-js/sdk'` | diff --git a/src/client/index.ts b/src/client/index.ts index 61ef0963..7d9c8ec7 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -26,14 +26,3 @@ export { withA2AVersion, } from './service-parameters.js'; export { ClientCallContext, type ContextUpdate, ClientCallContextKey } from './context.js'; -export { - ExtendedAgentCardNotConfiguredError, - ContentTypeNotSupportedError, - InvalidAgentResponseError, - PushNotificationNotSupportedError, - TaskNotCancelableError, - TaskNotFoundError, - UnsupportedOperationError, - RequestMalformedError, - VersionNotSupportedError, -} from '../errors.js'; diff --git a/src/client/multitransport-client.ts b/src/client/multitransport-client.ts index 76a0a788..76322b9e 100644 --- a/src/client/multitransport-client.ts +++ b/src/client/multitransport-client.ts @@ -2,7 +2,7 @@ import { withA2AVersion } from './service-parameters.js'; import { AgentCardSignatureVerifier } from '../signature.js'; import { LEGACY_HTTP_EXTENSION_HEADER } from '../compat/v0_3/index.js'; import { HTTP_EXTENSION_HEADER } from '../constants.js'; -import { PushNotificationNotSupportedError } from '../errors.js'; +import { PushNotificationNotSupportedError } from '../errors/index.js'; import { isLegacyVersion } from '../version_utils.js'; import { TaskPushNotificationConfig, Task, AgentCard, SendMessageResult } from '../index.js'; import { diff --git a/src/client/transports/grpc/grpc_transport.ts b/src/client/transports/grpc/grpc_transport.ts index 5c6f8a5b..8baa4e4d 100644 --- a/src/client/transports/grpc/grpc_transport.ts +++ b/src/client/transports/grpc/grpc_transport.ts @@ -24,8 +24,7 @@ import { RequestOptions } from '../../multitransport-client.js'; import { Transport, TransportFactory } from '../transport.js'; import { FromProto } from '../../../types/converters/from_proto.js'; -import { A2A_REASON_TO_ERROR_CLASS, ERROR_INFO_TYPE } from '../../../errors.js'; -import { decodeStatus, decodeErrorInfo } from '../../../server/grpc/error_details.js'; +import { fromGrpcError } from '../../../errors/index.js'; import { LegacyGrpcTransport } from '../../../compat/v0_3/client/transports/grpc/index.js'; import { isLegacyVersion } from '../../../version_utils.js'; import { pickMatchingInterface } from '../pick_interface.js'; @@ -310,43 +309,14 @@ export class GrpcTransport implements Transport { return metadata; } - private static mapFromErrorInfo(error: grpc.ServiceError): Error | undefined { - const bin = error.metadata?.get('grpc-status-details-bin'); - if (!bin || bin.length === 0) return undefined; - - const raw = bin[0]; - const buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(raw, 'binary'); - - const status = decodeStatus(buffer); - - for (const detail of status.details) { - if (detail.typeUrl === ERROR_INFO_TYPE) { - const errorInfo = decodeErrorInfo(detail.value); - - const ErrorClass = A2A_REASON_TO_ERROR_CLASS[errorInfo.reason]; - if (!ErrorClass) return undefined; - - return new ErrorClass(error.details); - } - } - - return undefined; - } - /** - * Maps a gRPC `ServiceError` to an SDK error class via - * `google.rpc.ErrorInfo` from `grpc-status-details-bin` metadata. - * Falls back to a generic `Error` carrying the gRPC code and details + * Maps a gRPC `ServiceError` to a semantic {@link import('../../../errors/index.js').GrpcA2AError} + * via `google.rpc.ErrorInfo` from `grpc-status-details-bin`. Falls + * back to `GrpcGenericError` preserving the gRPC status and details * when no ErrorInfo is present. */ private static mapToError(error: grpc.ServiceError, method?: keyof A2AServiceClient): Error { - const fromErrorInfo = GrpcTransport.mapFromErrorInfo(error); - if (fromErrorInfo) return fromErrorInfo; - - const methodContext = method ? ' for ' + String(method) : ''; - return new Error('gRPC error' + methodContext + ': ' + error.code + ' ' + error.details, { - cause: error, - }); + return fromGrpcError(error, method ? String(method) : undefined); } } diff --git a/src/client/transports/json_rpc_transport.ts b/src/client/transports/json_rpc_transport.ts index 3b07fe3e..d3328d3c 100644 --- a/src/client/transports/json_rpc_transport.ts +++ b/src/client/transports/json_rpc_transport.ts @@ -1,5 +1,5 @@ import { JSONRPCErrorResponse, TransportProtocolName } from '../../core.js'; -import { mapJsonRpcErrorToSdkError } from '../../errors.js'; +import { fromJsonRpcErrorResponse as mapJsonRpcErrorToSdkError } from '../../errors/index.js'; import { Task, AgentCard, diff --git a/src/client/transports/rest_transport.ts b/src/client/transports/rest_transport.ts index 45503ad6..67e2c1ba 100644 --- a/src/client/transports/rest_transport.ts +++ b/src/client/transports/rest_transport.ts @@ -1,5 +1,5 @@ import { TransportProtocolName } from '../../core.js'; -import { A2A_REASON_TO_ERROR_CLASS, ERROR_INFO_TYPE } from '../../errors.js'; +import { fromRestErrorBody } from '../../errors/index.js'; import { SendMessageResult, A2A_PROTOCOL_VERSION, A2A_CONTENT_TYPE } from '../../index.js'; import { JSON_CONTENT_TYPE } from '../../constants.js'; @@ -329,15 +329,27 @@ export class RestTransport implements Transport { // Body wasn't JSON — fall through to a generic error. } + const transportCtx = { + statusCode: response.status, + headers: RestTransport._collectHeaders(response), + }; if (errorStatus) { - throw RestTransport.mapToError(errorStatus); + throw fromRestErrorBody(errorStatus, transportCtx); } - - throw new Error( - `HTTP error for ${path}! Status: ${response.status} ${response.statusText}. Response: ${errorBodyText}` + throw fromRestErrorBody( + { message: `HTTP error for ${path}: ${response.status} ${response.statusText}` }, + transportCtx ); } + private static _collectHeaders(response: Response): Record { + const out: Record = {}; + response.headers.forEach((value, key) => { + out[key] = value; + }); + return out; + } + private async *_sendStreamingRequest( path: string, body: unknown | undefined, @@ -367,11 +379,15 @@ export class RestTransport implements Transport { ); } + const sseTransportCtx = { + statusCode: response.status, + headers: RestTransport._collectHeaders(response), + }; for await (const event of parseSseStream(response)) { if (event.type === 'error') { const errorData = JSON.parse(event.data) as { error?: RestErrorStatus }; if (errorData.error && typeof errorData.error === 'object') { - throw RestTransport.mapToError(errorData.error); + throw fromRestErrorBody(errorData.error, sseTransportCtx); } throw new Error(`SSE error event: ${JSON.stringify(errorData)}`); } @@ -394,22 +410,6 @@ export class RestTransport implements Transport { ); } } - - private static mapToError(error: RestErrorStatus): Error { - const message = error.message || 'Unknown error'; - - if (Array.isArray(error.details)) { - const errorInfo = error.details.find((d) => d['@type'] === ERROR_INFO_TYPE); - if (errorInfo && typeof errorInfo['reason'] === 'string') { - const ErrorClass = A2A_REASON_TO_ERROR_CLASS[errorInfo['reason'] as string]; - if (ErrorClass) return new ErrorClass(message); - } - } - - return new Error( - `REST error: ${error.status || 'UNKNOWN'} (${error.code || 'unknown code'}) - ${message}` - ); - } } export class RestTransportFactoryOptions { diff --git a/src/compat/v0_3/client/transports/grpc/grpc_transport.ts b/src/compat/v0_3/client/transports/grpc/grpc_transport.ts index b74e94d6..6d96328c 100644 --- a/src/compat/v0_3/client/transports/grpc/grpc_transport.ts +++ b/src/compat/v0_3/client/transports/grpc/grpc_transport.ts @@ -17,16 +17,11 @@ import * as grpc from '@grpc/grpc-js'; import { TransportProtocolName } from '../../../../../core.js'; -import { - A2A_REASON_TO_ERROR_CLASS, - ERROR_INFO_TYPE, - UnsupportedOperationError, -} from '../../../../../errors.js'; +import { fromGrpcError, UnsupportedOperationError } from '../../../../../errors/index.js'; import { A2A_LEGACY_PROTOCOL_VERSION } from '../../../../../constants.js'; import type { SendMessageResult } from '../../../../../index.js'; import type { RequestOptions } from '../../../../../client/multitransport-client.js'; import type { Transport } from '../../../../../client/transports/transport.js'; -import { decodeErrorInfo, decodeStatus } from '../../../../../server/grpc/error_details.js'; import { A2AServiceClient, type CreateTaskPushNotificationConfigRequest, @@ -423,38 +418,9 @@ export class LegacyGrpcTransport implements Transport { /** * Decodes `google.rpc.ErrorInfo` from `grpc-status-details-bin` when * present (this SDK's `legacyGrpcService` emits it); otherwise returns - * a generic `Error` preserving the gRPC code and details. + * `GrpcGenericError` preserving the gRPC code and details. */ private static _mapToError(error: grpc.ServiceError, method?: string): Error { - const fromErrorInfo = LegacyGrpcTransport._mapFromErrorInfo(error); - if (fromErrorInfo) return fromErrorInfo; - - const methodContext = method ? ' for ' + method : ''; - return new Error('gRPC error' + methodContext + ': ' + error.code + ' ' + error.details, { - cause: error, - }); - } - - private static _mapFromErrorInfo(error: grpc.ServiceError): Error | undefined { - const bin = error.metadata?.get('grpc-status-details-bin'); - if (!bin || bin.length === 0) return undefined; - - const raw = bin[0]; - const buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(raw, 'binary'); - - const status = decodeStatus(buffer); - - for (const detail of status.details) { - if (detail.typeUrl === ERROR_INFO_TYPE) { - const errorInfo = decodeErrorInfo(detail.value); - - const ErrorClass = A2A_REASON_TO_ERROR_CLASS[errorInfo.reason]; - if (!ErrorClass) return undefined; - - return new ErrorClass(error.details); - } - } - - return undefined; + return fromGrpcError(error, method); } } diff --git a/src/compat/v0_3/client/transports/json_rpc_transport.ts b/src/compat/v0_3/client/transports/json_rpc_transport.ts index 232bf574..00c5e74c 100644 --- a/src/compat/v0_3/client/transports/json_rpc_transport.ts +++ b/src/compat/v0_3/client/transports/json_rpc_transport.ts @@ -12,10 +12,10 @@ import { JSON_CONTENT_TYPE } from '../../../../constants.js'; import type { JSONRPCErrorResponse, TransportProtocolName } from '../../../../core.js'; import { A2A_ERROR_CODE, + fromJsonRpcErrorResponse as mapJsonRpcErrorToSdkError, InvalidAgentResponseError, - JSONRPCTransportError, - mapJsonRpcErrorToSdkError, -} from '../../../../errors.js'; + JsonRpcTransportError, +} from '../../../../errors/index.js'; import type { SendMessageResult } from '../../../../index.js'; import type { RequestOptions } from '../../../../client/multitransport-client.js'; import { Transport } from '../../../../client/transports/transport.js'; @@ -216,7 +216,7 @@ export class LegacyJsonRpcTransport implements Transport { _params: V1ListTasksRequest, _options?: RequestOptions ): Promise { - throw new JSONRPCTransportError({ + throw new JsonRpcTransportError({ jsonrpc: '2.0', id: null, error: { diff --git a/src/compat/v0_3/client/transports/rest_transport.ts b/src/compat/v0_3/client/transports/rest_transport.ts index ebeb3cc2..e8bc69d9 100644 --- a/src/compat/v0_3/client/transports/rest_transport.ts +++ b/src/compat/v0_3/client/transports/rest_transport.ts @@ -16,10 +16,11 @@ */ import { + A2A_ERROR_CLASSES, + A2A_ERROR_SPECS_BY_CODE, InvalidAgentResponseError, - mapA2aErrorToSdkError, UnsupportedOperationError, -} from '../../../../errors.js'; +} from '../../../../errors/index.js'; import type { TransportProtocolName } from '../../../../core.js'; import type { SendMessageResult } from '../../../../index.js'; import type { RequestOptions } from '../../../../client/multitransport-client.js'; @@ -421,10 +422,7 @@ export class LegacyRestTransport implements Transport { try { const parsed = JSON.parse(jsonData) as unknown; if (LegacyRestTransport._isLegacyRestErrorBody(parsed)) { - return mapA2aErrorToSdkError(parsed, () => { - const dataSuffix = parsed.data ? ` Data: ${JSON.stringify(parsed.data)}` : ''; - return new Error(`REST error: ${parsed.message} (Code: ${parsed.code})${dataSuffix}`); - }); + return LegacyRestTransport._errorFromLegacyBody(parsed); } return new Error(`SSE error event: ${jsonData}`); } catch { @@ -450,11 +448,7 @@ export class LegacyRestTransport implements Transport { } if (errorBody) { - const body = errorBody; - throw mapA2aErrorToSdkError(body, () => { - const dataSuffix = body.data ? ` Data: ${JSON.stringify(body.data)}` : ''; - return new Error(`REST error: ${body.message} (Code: ${body.code})${dataSuffix}`); - }); + throw LegacyRestTransport._errorFromLegacyBody(errorBody); } throw new Error( @@ -462,6 +456,18 @@ export class LegacyRestTransport implements Transport { ); } + /** + * Reconstructs a semantic SDK error from a v0.3 error body. Unknown + * codes fall through to a generic `Error` preserving the code/data + * in the message for debugging. + */ + private static _errorFromLegacyBody(body: LegacyRestErrorBody): Error { + const spec = A2A_ERROR_SPECS_BY_CODE[body.code]; + if (spec) return new A2A_ERROR_CLASSES[spec.name]({ message: body.message }); + const dataSuffix = body.data ? ` Data: ${JSON.stringify(body.data)}` : ''; + return new Error(`REST error: ${body.message} (Code: ${body.code})${dataSuffix}`); + } + private static _isLegacyRestErrorBody(value: unknown): value is LegacyRestErrorBody { return ( typeof value === 'object' && diff --git a/src/compat/v0_3/server/error.ts b/src/compat/v0_3/server/error.ts index 38aa8052..9bc3644e 100644 --- a/src/compat/v0_3/server/error.ts +++ b/src/compat/v0_3/server/error.ts @@ -1,75 +1,118 @@ -import * as schema from '../types/types.js'; +/** + * v0.3 compat error facade. Callers write `A2AError.taskNotFound(id)` or + * `new A2AError(-32602, 'msg', data)` and receive a v1.0-aligned + * {@link A2AError} instance so client and server share one hierarchy. + * Wire codes without a semantic twin (`PARSE_ERROR`, `INVALID_REQUEST`, + * `METHOD_NOT_FOUND`) are preserved via `JsonRpc*Error.envelopeCode`. + */ + +import { + A2A_ERROR_CODE, + A2A_ERROR_CLASSES, + A2A_ERROR_SPECS_BY_CODE, + A2AError as BaseA2AError, + ExtendedAgentCardNotConfiguredError, + GenericError, + JsonRpcRequestMalformedError, + JsonRpcUnsupportedOperationError, + PushNotificationNotSupportedError, + RequestMalformedError, + TaskNotCancelableError, + TaskNotFoundError, + UnsupportedOperationError, +} from '../../../errors/index.js'; /** - * v0.3 compat-layer error carrying a JSON-RPC error code and optional - * task ID context. + * Ergonomic wrapper: `new A2AError(code, message, data?)` returns the + * matching semantic class (unknown codes fall back to a + * `JsonRpc*Error` preserving the wire code). Also exposes the classic + * v0.3 static factories (`.taskNotFound`, `.invalidParams`, …). + * + * The returned value is always an instance of {@link BaseA2AError}; + * `instanceof A2AError` also matches thanks to a `Symbol.hasInstance` + * shim. */ -export class A2AError extends Error { - public code: number; - public data?: Record; - public taskId?: string; - - constructor(code: number, message: string, data?: Record, taskId?: string) { - super(message); - this.name = 'A2AError'; - this.code = code; - this.data = data; - this.taskId = taskId; - } - - /** Formats the error into a standard JSON-RPC error object. */ - toJSONRPCError(): schema.JSONRPCError { - const errorObject: schema.JSONRPCError = { - code: this.code, - message: this.message, - }; - - if (this.data !== undefined) { - errorObject.data = this.data; - } - - return errorObject; - } - - // Factory methods for common errors. - - static parseError(message: string, data?: Record): A2AError { - return new A2AError(-32700, message, data); - } - - static invalidRequest(message: string, data?: Record): A2AError { - return new A2AError(-32600, message, data); - } - - static methodNotFound(method: string): A2AError { - return new A2AError(-32601, `Method not found: ${method}`); - } - - static invalidParams(message: string, data?: Record): A2AError { - return new A2AError(-32602, message, data); - } - - static internalError(message: string, data?: Record): A2AError { - return new A2AError(-32603, message, data); - } - - static taskNotFound(taskId: string): A2AError { - return new A2AError(-32001, `Task not found: ${taskId}`, undefined, taskId); - } - - static taskNotCancelable(taskId: string): A2AError { - return new A2AError(-32002, `Task not cancelable: ${taskId}`, undefined, taskId); - } - - static pushNotificationNotSupported(): A2AError { - return new A2AError(-32003, 'Push Notification is not supported'); - } - - static unsupportedOperation(operation: string): A2AError { - return new A2AError(-32004, `Unsupported operation: ${operation}`); - } - - static authenticatedExtendedCardNotConfigured(): A2AError { - return new A2AError(-32007, `Extended card not configured.`); - } +interface A2AErrorFacade { + new (code: number, message: string, data?: Record): BaseA2AError; + parseError(message: string, data?: Record): BaseA2AError; + invalidRequest(message: string, data?: Record): BaseA2AError; + methodNotFound(method: string): BaseA2AError; + invalidParams(message: string, data?: Record): BaseA2AError; + internalError(message: string, data?: Record): BaseA2AError; + taskNotFound(taskId: string): BaseA2AError; + taskNotCancelable(taskId: string): BaseA2AError; + pushNotificationNotSupported(): BaseA2AError; + unsupportedOperation(operation: string): BaseA2AError; + authenticatedExtendedCardNotConfigured(): BaseA2AError; + [Symbol.hasInstance](value: unknown): boolean; } + +function makeA2AError(code: number, message: string, data?: Record): BaseA2AError { + const spec = A2A_ERROR_SPECS_BY_CODE[code]; + if (spec) return new A2A_ERROR_CLASSES[spec.name]({ message }); + return new JsonRpcRequestMalformedError({ + message, + envelopeCode: code, + data: data as never, + }); +} + +export const A2AError = makeA2AError as unknown as A2AErrorFacade; + +/** Alias so callers writing `err: A2AError` keep type-checking. */ +export type A2AError = BaseA2AError; + +Object.defineProperty(A2AError, Symbol.hasInstance, { + value: (v: unknown) => v instanceof BaseA2AError, +}); + +A2AError.parseError = (message, data) => + new JsonRpcRequestMalformedError({ + message, + envelopeCode: A2A_ERROR_CODE.PARSE_ERROR, + data: data as never, + }); + +A2AError.invalidRequest = (message, data) => + new JsonRpcRequestMalformedError({ + message, + envelopeCode: A2A_ERROR_CODE.INVALID_REQUEST, + data: data as never, + }); + +A2AError.methodNotFound = (method) => + new JsonRpcUnsupportedOperationError({ + message: `Method not found: ${method}`, + envelopeCode: A2A_ERROR_CODE.METHOD_NOT_FOUND, + }); + +A2AError.invalidParams = (message, data) => + data === undefined + ? new RequestMalformedError({ message }) + : new JsonRpcRequestMalformedError({ + message, + envelopeCode: A2A_ERROR_CODE.INVALID_PARAMS, + data: data as never, + }); + +A2AError.internalError = (message, data) => + data === undefined + ? new GenericError({ message }) + : new JsonRpcRequestMalformedError({ + message, + envelopeCode: A2A_ERROR_CODE.INTERNAL_ERROR, + data: data as never, + }); + +A2AError.taskNotFound = (taskId) => new TaskNotFoundError({ message: `Task not found: ${taskId}` }); + +A2AError.taskNotCancelable = (taskId) => + new TaskNotCancelableError({ message: `Task not cancelable: ${taskId}` }); + +A2AError.pushNotificationNotSupported = () => new PushNotificationNotSupportedError(); + +A2AError.unsupportedOperation = (operation) => + new UnsupportedOperationError({ message: `Unsupported operation: ${operation}` }); + +A2AError.authenticatedExtendedCardNotConfigured = () => + new ExtendedAgentCardNotConfiguredError({ message: 'Extended card not configured.' }); diff --git a/src/compat/v0_3/server/express/agent_card_handler.ts b/src/compat/v0_3/server/express/agent_card_handler.ts index 998c60bd..9ac86fe3 100644 --- a/src/compat/v0_3/server/express/agent_card_handler.ts +++ b/src/compat/v0_3/server/express/agent_card_handler.ts @@ -19,7 +19,7 @@ import express, { } from 'express'; import { A2A_VERSION_HEADER } from '../../../../constants.js'; -import { VersionNotSupportedError } from '../../../../errors.js'; +import { VersionNotSupportedError } from '../../../../errors/index.js'; import type { AgentCardCacheOptions, AgentCardProvider, diff --git a/src/compat/v0_3/server/grpc/grpc_service.ts b/src/compat/v0_3/server/grpc/grpc_service.ts index 6077c618..c72e4283 100644 --- a/src/compat/v0_3/server/grpc/grpc_service.ts +++ b/src/compat/v0_3/server/grpc/grpc_service.ts @@ -35,27 +35,21 @@ import { Empty } from '../../grpc/pb/google/protobuf/empty.js'; import { A2ARequestHandler } from '../../../../server/request_handler/a2a_request_handler.js'; import { ServerCallContext } from '../../../../server/context.js'; import { Extensions } from '../../../../extensions.js'; -import { buildGrpcErrorMetadata } from '../../../../server/grpc/error_details.js'; import { UserBuilder } from './common.js'; import { A2A_VERSION_HEADER, HTTP_EXTENSION_HEADER } from '../../../../constants.js'; import { LEGACY_HTTP_EXTENSION_HEADER } from '../../constants.js'; import { - ContentTypeNotSupportedError, - ExtendedAgentCardNotConfiguredError, - ExtensionSupportRequiredError, - GenericError, + A2AError, + buildGrpcErrorMetadata, + GRPC_STATUS, + grpcStatusFor, InvalidAgentResponseError, - PushNotificationNotSupportedError, - RequestMalformedError, - TaskNotCancelableError, - TaskNotFoundError, - UnsupportedOperationError, - VersionNotSupportedError, -} from '../../../../errors.js'; + isJsonRpcError, +} from '../../../../errors/index.js'; import { validateVersion } from '../../../../server/version.js'; import { FromProto } from '../../types/converters/from_proto.js'; import { ToProto } from '../../types/converters/to_proto.js'; -import { A2AError as LegacyA2AError } from '../error.js'; + import { extractTaskAndPushNotificationConfigId, extractTaskId, @@ -437,58 +431,29 @@ function _serializeListTaskPushNotificationConfigResponse( // Error mapping. -// JSON-RPC error code -> gRPC status, used to translate `LegacyA2AError` -// (which carries a JSON-RPC code, not a class identity). +// JSON-RPC envelope code -> gRPC status, used only for `JsonRpc*Error` +// instances whose `envelopeCode` overrides the semantic default (e.g. +// `METHOD_NOT_FOUND` -> UNIMPLEMENTED, not the semantic +// `UnsupportedOperationError` -> FAILED_PRECONDITION). const LEGACY_CODE_TO_GRPC_STATUS: Readonly> = { [-32700]: grpc.status.INVALID_ARGUMENT, // Parse error [-32600]: grpc.status.INVALID_ARGUMENT, // Invalid Request [-32601]: grpc.status.UNIMPLEMENTED, // Method not found - [-32602]: grpc.status.INVALID_ARGUMENT, // Invalid params - [-32603]: grpc.status.INTERNAL, // Internal error - [-32001]: grpc.status.NOT_FOUND, // Task not found - [-32002]: grpc.status.FAILED_PRECONDITION, // Task not cancelable - [-32003]: grpc.status.FAILED_PRECONDITION, // Push notification not supported - [-32004]: grpc.status.FAILED_PRECONDITION, // Unsupported operation - [-32005]: grpc.status.INVALID_ARGUMENT, // Content-Type not supported - [-32006]: grpc.status.INTERNAL, // Invalid agent response - [-32007]: grpc.status.FAILED_PRECONDITION, // Extended card not configured }; -// `instanceof` chain so user-defined subclasses of A2A error types resolve -// to the correct gRPC status of the nearest base class. Also attaches a -// `google.rpc.ErrorInfo` detail for v1.0-aware clients. const mapToError = (error: unknown): Partial => { - let code = grpc.status.UNKNOWN; - if (error instanceof LegacyA2AError) { - code = LEGACY_CODE_TO_GRPC_STATUS[error.code] ?? grpc.status.UNKNOWN; - } else if (error instanceof TaskNotFoundError) code = grpc.status.NOT_FOUND; - else if (error instanceof TaskNotCancelableError) code = grpc.status.FAILED_PRECONDITION; - else if (error instanceof PushNotificationNotSupportedError) - code = grpc.status.FAILED_PRECONDITION; - else if (error instanceof UnsupportedOperationError) code = grpc.status.FAILED_PRECONDITION; - else if (error instanceof ContentTypeNotSupportedError) code = grpc.status.INVALID_ARGUMENT; - else if (error instanceof InvalidAgentResponseError) code = grpc.status.INTERNAL; - else if (error instanceof ExtendedAgentCardNotConfiguredError) - code = grpc.status.FAILED_PRECONDITION; - else if (error instanceof ExtensionSupportRequiredError) code = grpc.status.FAILED_PRECONDITION; - else if (error instanceof VersionNotSupportedError) code = grpc.status.FAILED_PRECONDITION; - else if (error instanceof RequestMalformedError) code = grpc.status.INVALID_ARGUMENT; - else if (error instanceof GenericError) code = grpc.status.INTERNAL; - - const message = error instanceof Error ? error.message : 'Internal server error'; - - const result: Partial = { - code, - details: message, - }; - - if (error instanceof Error) { - const errorMetadata = buildGrpcErrorMetadata(code, message, error); - if (errorMetadata) { - result.metadata = errorMetadata; - } + let code: number; + if (isJsonRpcError(error) && LEGACY_CODE_TO_GRPC_STATUS[error.envelopeCode] !== undefined) { + code = LEGACY_CODE_TO_GRPC_STATUS[error.envelopeCode]; + } else if (error instanceof A2AError) { + code = grpcStatusFor(error); + } else { + code = GRPC_STATUS.UNKNOWN; } - + const message = error instanceof Error ? error.message : 'Internal server error'; + const result: Partial = { code, details: message }; + const md = buildGrpcErrorMetadata(grpc.Metadata, error); + if (md) result.metadata = md; return result; }; diff --git a/src/compat/v0_3/server/transports/rest/rest_transport_handler.ts b/src/compat/v0_3/server/transports/rest/rest_transport_handler.ts index c1b30c5d..e557ba57 100644 --- a/src/compat/v0_3/server/transports/rest/rest_transport_handler.ts +++ b/src/compat/v0_3/server/transports/rest/rest_transport_handler.ts @@ -8,8 +8,9 @@ import type { ServerCallContext } from '../../../../../server/context.js'; import type { A2ARequestHandler } from '../../../../../server/request_handler/a2a_request_handler.js'; import { HTTP_STATUS, - mapErrorToStatus as mapV1ErrorToStatus, -} from '../../../../../server/transports/rest/rest_transport_handler.js'; + isJsonRpcError, + restStatusFor as mapV1ErrorToStatus, +} from '../../../../../errors/index.js'; import type { AgentCard as V1AgentCard, Message as V1Message, @@ -27,6 +28,7 @@ import { toCompatStreamResponse, toCoreSendMessageRequest } from '../../../trans import { toCompatTask } from '../../../translate/tasks.js'; import type * as legacy from '../../../types/types.js'; import { A2AError as LegacyA2AError } from '../../error.js'; +import type { A2AError as A2AErrorBase } from '../../../../../errors/index.js'; // Numeric A2A error codes and HTTP semantics are identical between v0.3 // and v1.0, so we reuse the v1.0 mapping helpers as-is. @@ -52,13 +54,15 @@ const LEGACY_CODE_TO_HTTP_STATUS: Readonly> = { /** * Maps an error to its HTTP status code with v0.3-compat awareness. - * `LegacyA2AError` carries a JSON-RPC code rather than a class identity, - * so it bypasses the v1.0 class-based mapping; everything else defers - * to the v1.0 mapper. + * `JsonRpc*Error` instances carry an `envelopeCode` that may differ + * from the semantic default (e.g. `METHOD_NOT_FOUND` -> 501, not the + * generic `UnsupportedOperationError` -> 400); those override the + * class-based mapping. Everything else defers to the v1.0 mapper. */ export function mapErrorToStatus(error: unknown): number { - if (error instanceof LegacyA2AError) { - return LEGACY_CODE_TO_HTTP_STATUS[error.code] ?? HTTP_STATUS.INTERNAL_SERVER_ERROR; + if (isJsonRpcError(error)) { + const override = LEGACY_CODE_TO_HTTP_STATUS[error.envelopeCode]; + if (override !== undefined) return override; } return mapV1ErrorToStatus(error); } @@ -243,7 +247,7 @@ export class LegacyRestTransportHandler { private static readonly CAPABILITY_ERRORS: Record< 'streaming' | 'pushNotifications', - () => LegacyA2AError + () => A2AErrorBase > = { streaming: () => LegacyA2AError.unsupportedOperation('Agent does not support streaming'), pushNotifications: () => LegacyA2AError.pushNotificationNotSupported(), diff --git a/src/compat/v0_3/translate/agent_card.ts b/src/compat/v0_3/translate/agent_card.ts index 77b661d0..06ceb737 100644 --- a/src/compat/v0_3/translate/agent_card.ts +++ b/src/compat/v0_3/translate/agent_card.ts @@ -12,7 +12,7 @@ * `VersionNotSupportedError` if none remain. */ -import { VersionNotSupportedError } from '../../../errors.js'; +import { VersionNotSupportedError } from '../../../errors/index.js'; import { PROTOCOL_VERSION_0_3, isLegacyVersion } from './versions.js'; import { toCompatSecurityRequirement, diff --git a/src/compat/v0_3/translate/errors.ts b/src/compat/v0_3/translate/errors.ts index 2e2f4ba3..611443bd 100644 --- a/src/compat/v0_3/translate/errors.ts +++ b/src/compat/v0_3/translate/errors.ts @@ -1,20 +1,25 @@ /** - * Error translator from v1.0 SDK errors to v0.3 wire shapes. v0.3 - * JSON-RPC and REST both use a bare `{ code, message, data? }` body - * (no `details[]`, no outer `{ error }` wrapper, no `status` field), - * so one converter serves both. + * Error translator to v0.3 wire shapes. v0.3 JSON-RPC and REST both + * use a bare `{ code, message, data? }` body (no `details[]`, no outer + * `{ error }` wrapper, no `status` field), so one converter serves + * both. * - * v1.0-only error codes are passed through with their numeric code - * unchanged rather than collapsed to `INTERNAL_ERROR` — preserves - * debuggability for v0.3 clients that happen to recognise them. + * `JsonRpc*Error` instances preserve their `envelopeCode`, so wire + * codes like `PARSE_ERROR`, `INVALID_REQUEST`, and `METHOD_NOT_FOUND` + * survive the trip through the compat layer even though they have no + * v1.0 semantic class. * * The v0.3 gRPC handler doesn't use this module; it keeps emitting * `google.rpc.ErrorInfo` in `grpc-status-details-bin` for v1.0-aware * clients (see `server/grpc/grpc_service.ts`). */ -import { A2A_ERROR_CLASS_TO_CODE, A2A_ERROR_CODE } from '../../../errors.js'; -import { A2AError as LegacyA2AError } from '../server/error.js'; +import { + A2A_ERROR_CODE, + A2A_ERROR_SPECS, + A2AError, + isJsonRpcError, +} from '../../../errors/index.js'; import type { JSONRPCError } from '../types/types.js'; /** v0.3 REST error body: bare `{ code, message, data? }`. */ @@ -25,43 +30,23 @@ export interface LegacyRestErrorBody { } /** - * Resolves any thrown value into a `{ code, message, data? }` triple. - * Honours `LegacyA2AError` verbatim, maps known v1.0 SDK error classes - * to their numeric codes (dropping `details[]`/`ErrorInfo`), and falls - * back to `INTERNAL_ERROR` for everything else. + * Converts any error to a v0.3-shaped body. Satisfies both the JSON-RPC + * `JSONRPCError` and REST `LegacyRestErrorBody` shapes (structurally + * identical). Drops the v1.0 `details[]` / `ErrorInfo` — v0.3 clients + * don't consume them. */ -function demoteToLegacyShape(error: unknown): { - code: number; - message: string; - data?: Record; -} { - if (error instanceof LegacyA2AError) { +export function toCompatErrorBody(error: unknown): JSONRPCError | LegacyRestErrorBody { + if (isJsonRpcError(error)) { return { - code: error.code, + code: error.envelopeCode, message: error.message, - ...(error.data !== undefined ? { data: error.data } : {}), + ...(error.data !== undefined ? { data: error.data as Record } : {}), }; } - if (error instanceof Error) { - const code = A2A_ERROR_CLASS_TO_CODE[error.name]; - if (code !== undefined) { - return { code, message: error.message }; - } + if (error instanceof A2AError) { + const spec = A2A_ERROR_SPECS[error.name]; + return { code: spec?.code ?? A2A_ERROR_CODE.INTERNAL_ERROR, message: error.message }; } const message = (error instanceof Error && error.message) || 'An unexpected error occurred.'; return { code: A2A_ERROR_CODE.INTERNAL_ERROR, message }; } - -/** - * Converts any error to a v0.3-shaped body. Satisfies both the JSON-RPC - * `JSONRPCError` and REST `LegacyRestErrorBody` shapes (structurally - * identical). Never carries v1.0 `details[]` / `ErrorInfo`. - */ -export function toCompatErrorBody(error: unknown): JSONRPCError | LegacyRestErrorBody { - const { code, message, data } = demoteToLegacyShape(error); - return { - code, - message, - ...(data !== undefined ? { data } : {}), - }; -} diff --git a/src/errors.ts b/src/errors.ts deleted file mode 100644 index e8620320..00000000 --- a/src/errors.ts +++ /dev/null @@ -1,354 +0,0 @@ -import type { JSONRPCErrorResponse } from './core.js'; - -// Legacy JSON-RPC error codes. -export const A2A_ERROR_CODE = { - PARSE_ERROR: -32700, - INVALID_REQUEST: -32600, - METHOD_NOT_FOUND: -32601, - INVALID_PARAMS: -32602, - INTERNAL_ERROR: -32603, - TASK_NOT_FOUND: -32001, - TASK_NOT_CANCELABLE: -32002, - PUSH_NOTIFICATION_NOT_SUPPORTED: -32003, - UNSUPPORTED_OPERATION: -32004, - CONTENT_TYPE_NOT_SUPPORTED: -32005, - INVALID_AGENT_RESPONSE: -32006, - EXTENDED_CARD_NOT_CONFIGURED: -32007, - EXTENSION_SUPPORT_REQUIRED: -32008, - VERSION_NOT_SUPPORTED: -32009, -} as const; - -/** Domain value for all A2A-specific errors in `google.rpc.ErrorInfo`. */ -export const A2A_ERROR_DOMAIN = 'a2a-protocol.org'; - -/** `@type` URL for `google.rpc.ErrorInfo` in ProtoJSON `Any` representation. */ -export const ERROR_INFO_TYPE = 'type.googleapis.com/google.rpc.ErrorInfo'; - -/** A structured detail object included in error responses. */ -export interface ErrorDetail { - '@type': string; - [key: string]: unknown; -} - -/** - * The `google.rpc.ErrorInfo` structure used across all A2A transport bindings. - * Included in `error.data` (JSON-RPC), `error.details` (REST), and - * `status.details` (gRPC). - */ -export interface A2AErrorInfo extends ErrorDetail { - '@type': typeof ERROR_INFO_TYPE; - reason: string; - domain: typeof A2A_ERROR_DOMAIN; - metadata?: Record; -} - -/** REST error response structure (google.rpc.Status JSON representation). */ -export interface RestErrorBody { - error: { - code: number; - status: string; - message: string; - details: ErrorDetail[]; - }; -} - -/** - * Mapping of error class names to UPPER_SNAKE_CASE reason codes used in - * `google.rpc.ErrorInfo.reason`. - */ -export const A2A_ERROR_REASON: Record = { - TaskNotFoundError: 'TASK_NOT_FOUND', - TaskNotCancelableError: 'TASK_NOT_CANCELABLE', - PushNotificationNotSupportedError: 'PUSH_NOTIFICATION_NOT_SUPPORTED', - UnsupportedOperationError: 'UNSUPPORTED_OPERATION', - ContentTypeNotSupportedError: 'CONTENT_TYPE_NOT_SUPPORTED', - InvalidAgentResponseError: 'INVALID_AGENT_RESPONSE', - ExtendedAgentCardNotConfiguredError: 'EXTENDED_AGENT_CARD_NOT_CONFIGURED', - ExtensionSupportRequiredError: 'EXTENSION_SUPPORT_REQUIRED', - VersionNotSupportedError: 'VERSION_NOT_SUPPORTED', - RequestMalformedError: 'INVALID_PARAMS', - GenericError: 'INTERNAL_ERROR', -}; - -/** - * Reverse mapping from reason codes to error class names. Used by client - * transports to reconstruct SDK error classes from ErrorInfo. - */ -export const A2A_REASON_TO_ERROR: Record = Object.fromEntries( - Object.entries(A2A_ERROR_REASON).map(([cls, reason]) => [reason, cls]) -); - -/** Maps JSON-RPC error codes to error class names. */ -export const A2A_ERROR_CODE_TO_CLASS: Record = { - [A2A_ERROR_CODE.TASK_NOT_FOUND]: 'TaskNotFoundError', - [A2A_ERROR_CODE.TASK_NOT_CANCELABLE]: 'TaskNotCancelableError', - [A2A_ERROR_CODE.PUSH_NOTIFICATION_NOT_SUPPORTED]: 'PushNotificationNotSupportedError', - [A2A_ERROR_CODE.UNSUPPORTED_OPERATION]: 'UnsupportedOperationError', - [A2A_ERROR_CODE.CONTENT_TYPE_NOT_SUPPORTED]: 'ContentTypeNotSupportedError', - [A2A_ERROR_CODE.INVALID_AGENT_RESPONSE]: 'InvalidAgentResponseError', - [A2A_ERROR_CODE.EXTENDED_CARD_NOT_CONFIGURED]: 'ExtendedAgentCardNotConfiguredError', - [A2A_ERROR_CODE.EXTENSION_SUPPORT_REQUIRED]: 'ExtensionSupportRequiredError', - [A2A_ERROR_CODE.VERSION_NOT_SUPPORTED]: 'VersionNotSupportedError', - [A2A_ERROR_CODE.INVALID_PARAMS]: 'RequestMalformedError', - [A2A_ERROR_CODE.INTERNAL_ERROR]: 'GenericError', -}; - -/** - * Inverse of {@link A2A_ERROR_CODE_TO_CLASS}. Used by JSON-RPC transport - * handlers to resolve an error instance to the numeric code carried in the - * response envelope's `error.code` field. Keyed on `error.name`. - */ -export const A2A_ERROR_CLASS_TO_CODE: Record = Object.fromEntries( - Object.entries(A2A_ERROR_CODE_TO_CLASS).map(([code, cls]) => [cls, Number(code)]) -); - -/** - * Builds a `google.rpc.ErrorInfo` detail object from an error instance, or - * returns `undefined` if the error has no known reason. - */ -export function buildErrorInfo( - error: Error, - metadata?: Record -): A2AErrorInfo | undefined { - const reason = A2A_ERROR_REASON[error.name]; - if (!reason) return undefined; - return { - '@type': ERROR_INFO_TYPE, - reason, - domain: A2A_ERROR_DOMAIN, - ...(metadata && Object.keys(metadata).length > 0 ? { metadata } : {}), - }; -} - -/** - * Per-error gRPC status name mapping. Also used in REST error responses - * for the `error.status` field. Multiple A2A errors may share the same - * HTTP status code (e.g. 400) but have different gRPC statuses - * (FAILED_PRECONDITION vs INVALID_ARGUMENT). - */ -export const A2A_ERROR_GRPC_STATUS: Record = { - TaskNotFoundError: 'NOT_FOUND', - TaskNotCancelableError: 'FAILED_PRECONDITION', - PushNotificationNotSupportedError: 'FAILED_PRECONDITION', - UnsupportedOperationError: 'FAILED_PRECONDITION', - ContentTypeNotSupportedError: 'INVALID_ARGUMENT', - InvalidAgentResponseError: 'INTERNAL', - ExtendedAgentCardNotConfiguredError: 'FAILED_PRECONDITION', - ExtensionSupportRequiredError: 'FAILED_PRECONDITION', - VersionNotSupportedError: 'FAILED_PRECONDITION', - RequestMalformedError: 'INVALID_ARGUMENT', - GenericError: 'INTERNAL', -}; - -/** - * Returns the gRPC status name for an error instance, falling back to - * HTTP-status-based inference for unknown errors. - */ -export function getGrpcStatusName(error: unknown, httpStatus: number): string { - if (error instanceof Error && A2A_ERROR_GRPC_STATUS[error.name]) { - return A2A_ERROR_GRPC_STATUS[error.name]; - } - if (httpStatus === 404) return 'NOT_FOUND'; - if (httpStatus === 500) return 'INTERNAL'; - if (httpStatus === 400) return 'INVALID_ARGUMENT'; - return 'UNKNOWN'; -} - -// SDK-specific errors not covered by the protocol's documentation. Used -// when the error does not fit into any of the other error categories. - -export class RequestMalformedError extends Error { - constructor(message?: string) { - super(message ?? 'Request malformed'); - this.name = 'RequestMalformedError'; - } -} - -export class GenericError extends Error { - constructor(message?: string) { - super(message ?? 'An unexpected error occurred.'); - this.name = 'GenericError'; - } -} - -// Transport-agnostic A2A-specific errors. - -export class TaskNotFoundError extends Error { - constructor(message?: string) { - super(message ?? 'Task not found'); - this.name = 'TaskNotFoundError'; - } -} - -export class TaskNotCancelableError extends Error { - constructor(message?: string) { - super(message ?? 'Task cannot be canceled'); - this.name = 'TaskNotCancelableError'; - } -} - -export class PushNotificationNotSupportedError extends Error { - constructor(message?: string) { - super(message ?? 'Push Notification is not supported'); - this.name = 'PushNotificationNotSupportedError'; - } -} - -export class UnsupportedOperationError extends Error { - constructor(message?: string) { - super(message ?? 'This operation is not supported'); - this.name = 'UnsupportedOperationError'; - } -} - -export class ContentTypeNotSupportedError extends Error { - constructor(message?: string) { - super(message ?? 'Incompatible content types'); - this.name = 'ContentTypeNotSupportedError'; - } -} - -export class InvalidAgentResponseError extends Error { - constructor(message?: string) { - super(message ?? 'Invalid agent response type'); - this.name = 'InvalidAgentResponseError'; - } -} - -export class ExtendedAgentCardNotConfiguredError extends Error { - constructor(message?: string) { - super(message ?? 'Extended Agent Card not configured'); - this.name = 'ExtendedAgentCardNotConfiguredError'; - } -} - -export class ExtensionSupportRequiredError extends Error { - constructor(message?: string) { - super(message ?? 'Extension support required'); - this.name = 'ExtensionSupportRequiredError'; - } -} - -export class VersionNotSupportedError extends Error { - constructor(message?: string) { - super(message ?? 'Version not supported'); - this.name = 'VersionNotSupportedError'; - } -} - -/** - * Maps UPPER_SNAKE_CASE reason codes to error class constructors. Used by - * client transports to reconstruct SDK error instances from - * `google.rpc.ErrorInfo.reason` values received in error responses. - */ -export const A2A_REASON_TO_ERROR_CLASS: Record Error> = { - TASK_NOT_FOUND: TaskNotFoundError, - TASK_NOT_CANCELABLE: TaskNotCancelableError, - PUSH_NOTIFICATION_NOT_SUPPORTED: PushNotificationNotSupportedError, - UNSUPPORTED_OPERATION: UnsupportedOperationError, - CONTENT_TYPE_NOT_SUPPORTED: ContentTypeNotSupportedError, - INVALID_AGENT_RESPONSE: InvalidAgentResponseError, - EXTENDED_AGENT_CARD_NOT_CONFIGURED: ExtendedAgentCardNotConfiguredError, - EXTENSION_SUPPORT_REQUIRED: ExtensionSupportRequiredError, - VERSION_NOT_SUPPORTED: VersionNotSupportedError, - INVALID_PARAMS: RequestMalformedError, - INTERNAL_ERROR: GenericError, -}; - -/** - * Maps error class names to error class constructors. Used by client - * transports to reconstruct SDK error instances from legacy error - * responses that include the error class name. - */ -export const A2A_NAME_TO_ERROR_CLASS: Record Error> = - Object.fromEntries( - Object.entries(A2A_ERROR_REASON).map(([name, reason]) => [ - name, - A2A_REASON_TO_ERROR_CLASS[reason], - ]) - ); - -/** - * Returned when a JSON-RPC error envelope carries a code that doesn't map - * to a known A2A SDK error class. - */ -export class JSONRPCTransportError extends Error { - constructor(public errorResponse: JSONRPCErrorResponse) { - super( - `JSON-RPC error: ${errorResponse.error.message} (Code: ${errorResponse.error.code}) Data: ${JSON.stringify(errorResponse.error.data || {})}` - ); - this.name = 'JSONRPCTransportError'; - } -} - -/** - * Maps an A2A numeric error code + message to a typed SDK error class. - * Envelope-agnostic; unknown codes return `fallback()` so each transport - * can surface a shape-appropriate generic error. - */ -export function mapA2aErrorToSdkError( - err: { code: number; message: string }, - fallback: () => Error -): Error { - const errorMessage = err.message; - switch (err.code) { - case A2A_ERROR_CODE.PARSE_ERROR: - case A2A_ERROR_CODE.INVALID_REQUEST: - case A2A_ERROR_CODE.METHOD_NOT_FOUND: - case A2A_ERROR_CODE.INVALID_PARAMS: - case A2A_ERROR_CODE.INTERNAL_ERROR: - return new RequestMalformedError(errorMessage); - case A2A_ERROR_CODE.TASK_NOT_FOUND: - return new TaskNotFoundError(errorMessage); - case A2A_ERROR_CODE.TASK_NOT_CANCELABLE: - return new TaskNotCancelableError(errorMessage); - case A2A_ERROR_CODE.PUSH_NOTIFICATION_NOT_SUPPORTED: - return new PushNotificationNotSupportedError(errorMessage); - case A2A_ERROR_CODE.UNSUPPORTED_OPERATION: - return new UnsupportedOperationError(errorMessage); - case A2A_ERROR_CODE.CONTENT_TYPE_NOT_SUPPORTED: - return new ContentTypeNotSupportedError(errorMessage); - case A2A_ERROR_CODE.INVALID_AGENT_RESPONSE: - return new InvalidAgentResponseError(errorMessage); - case A2A_ERROR_CODE.EXTENDED_CARD_NOT_CONFIGURED: - return new ExtendedAgentCardNotConfiguredError(errorMessage); - case A2A_ERROR_CODE.EXTENSION_SUPPORT_REQUIRED: - return new ExtensionSupportRequiredError(errorMessage); - case A2A_ERROR_CODE.VERSION_NOT_SUPPORTED: - return new VersionNotSupportedError(errorMessage); - default: - return fallback(); - } -} - -/** - * Maps a JSON-RPC error envelope to a typed SDK error instance. Falls back - * to {@link JSONRPCTransportError} carrying the full envelope when the - * code is unknown. - */ -export function mapJsonRpcErrorToSdkError(response: JSONRPCErrorResponse): Error { - return mapA2aErrorToSdkError(response.error, () => new JSONRPCTransportError(response)); -} - -/** - * Coerces an arbitrary rejected-promise reason into a printable message. - * Promise rejections are not required to be `Error` instances; reading - * `.message` on a non-Error rejection would throw a fresh `TypeError` from - * inside the catch handler and mask the original failure. - */ -export function extractErrorMessage(err: unknown): string { - if (err instanceof Error) { - return err.message; - } - if (err === null || err === undefined) { - return String(err); - } - if (typeof err === 'string') { - return err; - } - try { - return JSON.stringify(err); - } catch { - return String(err); - } -} diff --git a/src/errors/base.ts b/src/errors/base.ts new file mode 100644 index 00000000..1679cc97 --- /dev/null +++ b/src/errors/base.ts @@ -0,0 +1,394 @@ +/** + * Transport-agnostic A2A error hierarchy shared by client and server. + * + * Every SDK error extends {@link A2AError}. Transport-specific subclasses + * (in `./rest`, `./grpc`, `./json_rpc`) mix in a transport-context + * interface; catch sites narrow via `instanceof ` and + * `isRestError` / `isGrpcError` / `isJsonRpcError` type guards. + */ + +/** JSON-RPC 2.0 error codes reserved for A2A. */ +export const A2A_ERROR_CODE = { + PARSE_ERROR: -32700, + INVALID_REQUEST: -32600, + METHOD_NOT_FOUND: -32601, + INVALID_PARAMS: -32602, + INTERNAL_ERROR: -32603, + TASK_NOT_FOUND: -32001, + TASK_NOT_CANCELABLE: -32002, + PUSH_NOTIFICATION_NOT_SUPPORTED: -32003, + UNSUPPORTED_OPERATION: -32004, + CONTENT_TYPE_NOT_SUPPORTED: -32005, + INVALID_AGENT_RESPONSE: -32006, + EXTENDED_CARD_NOT_CONFIGURED: -32007, + EXTENSION_SUPPORT_REQUIRED: -32008, + VERSION_NOT_SUPPORTED: -32009, +} as const; + +/** Domain for `google.rpc.ErrorInfo.domain`. */ +export const A2A_ERROR_DOMAIN = 'a2a-protocol.org'; + +/** `@type`/`typeUrl` for `google.rpc.ErrorInfo` in ProtoJSON `Any`. */ +export const ERROR_INFO_TYPE = 'type.googleapis.com/google.rpc.ErrorInfo'; + +/** HTTP status codes used in REST responses. */ +export const HTTP_STATUS = { + OK: 200, + CREATED: 201, + ACCEPTED: 202, + NO_CONTENT: 204, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + NOT_FOUND: 404, + CONFLICT: 409, + INTERNAL_SERVER_ERROR: 500, + NOT_IMPLEMENTED: 501, +} as const; + +/** gRPC status codes referenced by the registry. Mirrors `@grpc/grpc-js`'s `status` enum. */ +export const GRPC_STATUS = { + OK: 0, + CANCELLED: 1, + UNKNOWN: 2, + INVALID_ARGUMENT: 3, + DEADLINE_EXCEEDED: 4, + NOT_FOUND: 5, + ALREADY_EXISTS: 6, + PERMISSION_DENIED: 7, + RESOURCE_EXHAUSTED: 8, + FAILED_PRECONDITION: 9, + ABORTED: 10, + OUT_OF_RANGE: 11, + UNIMPLEMENTED: 12, + INTERNAL: 13, + UNAVAILABLE: 14, + DATA_LOSS: 15, + UNAUTHENTICATED: 16, +} as const; + +/** Names of the gRPC status codes, for `google.rpc.Status.status`. */ +export const GRPC_STATUS_NAME: Readonly> = { + [GRPC_STATUS.OK]: 'OK', + [GRPC_STATUS.CANCELLED]: 'CANCELLED', + [GRPC_STATUS.UNKNOWN]: 'UNKNOWN', + [GRPC_STATUS.INVALID_ARGUMENT]: 'INVALID_ARGUMENT', + [GRPC_STATUS.DEADLINE_EXCEEDED]: 'DEADLINE_EXCEEDED', + [GRPC_STATUS.NOT_FOUND]: 'NOT_FOUND', + [GRPC_STATUS.ALREADY_EXISTS]: 'ALREADY_EXISTS', + [GRPC_STATUS.PERMISSION_DENIED]: 'PERMISSION_DENIED', + [GRPC_STATUS.RESOURCE_EXHAUSTED]: 'RESOURCE_EXHAUSTED', + [GRPC_STATUS.FAILED_PRECONDITION]: 'FAILED_PRECONDITION', + [GRPC_STATUS.ABORTED]: 'ABORTED', + [GRPC_STATUS.OUT_OF_RANGE]: 'OUT_OF_RANGE', + [GRPC_STATUS.UNIMPLEMENTED]: 'UNIMPLEMENTED', + [GRPC_STATUS.INTERNAL]: 'INTERNAL', + [GRPC_STATUS.UNAVAILABLE]: 'UNAVAILABLE', + [GRPC_STATUS.DATA_LOSS]: 'DATA_LOSS', + [GRPC_STATUS.UNAUTHENTICATED]: 'UNAUTHENTICATED', +}; + +/** A structured detail object included in error responses. */ +export interface ErrorDetail { + '@type': string; + [key: string]: unknown; +} + +/** `google.rpc.ErrorInfo` as it travels on any A2A wire. */ +export interface A2AErrorInfo extends ErrorDetail { + '@type': typeof ERROR_INFO_TYPE; + reason: string; + domain: typeof A2A_ERROR_DOMAIN; + metadata?: Record; +} + +/** REST error body (`google.rpc.Status` JSON). */ +export interface RestErrorBody { + error: { + code: number; + status: string; + message: string; + details: ErrorDetail[]; + }; +} + +/** Options accepted by every `A2AError` constructor. */ +export interface A2AErrorOptions { + /** Human-readable message. If omitted, the per-class default is used. */ + message?: string; + /** Original error / rejection reason (see `Error.cause`). */ + cause?: unknown; + /** Free-form ErrorInfo.metadata carried on the wire when possible. */ + metadata?: Record; +} + +/** + * Base class for every SDK error. Carries the spec-aligned `reason`, + * numeric `code`, and structured `metadata`. Concrete semantic classes + * (below) fill in `reason` / `code` in their constructor. Transport + * subclasses in `./rest`, `./grpc`, `./json_rpc` add wire context via + * the {@link RestA2AError} / {@link GrpcA2AError} / {@link JsonRpcA2AError} + * interfaces. + */ +export class A2AError extends Error { + /** UPPER_SNAKE_CASE reason from `google.rpc.ErrorInfo`. */ + public readonly reason: string = 'INTERNAL_ERROR'; + /** Numeric JSON-RPC / A2A error code. */ + public readonly code: number = A2A_ERROR_CODE.INTERNAL_ERROR; + /** Optional `google.rpc.ErrorInfo.metadata`. */ + public readonly metadata?: Record; + + constructor(defaultMessage: string, options?: A2AErrorOptions) { + super( + options?.message ?? defaultMessage, + options?.cause !== undefined ? { cause: options.cause } : undefined + ); + this.name = new.target.name; + if (options?.metadata && Object.keys(options.metadata).length > 0) { + this.metadata = options.metadata; + } + } + + /** Builds `google.rpc.ErrorInfo` from this error. */ + public toErrorInfo(): A2AErrorInfo { + return { + '@type': ERROR_INFO_TYPE, + reason: this.reason, + domain: A2A_ERROR_DOMAIN, + ...(this.metadata ? { metadata: this.metadata } : {}), + }; + } +} + +/** + * Registry row for one semantic error class. Adding a new error means + * adding one row here; all wire mappings derive from it. + */ +export interface A2AErrorSpec { + name: string; + reason: string; + code: number; + grpcStatus: number; + httpStatus: number; + defaultMessage: string; +} + +const specs: A2AErrorSpec[] = [ + { + name: 'TaskNotFoundError', + reason: 'TASK_NOT_FOUND', + code: A2A_ERROR_CODE.TASK_NOT_FOUND, + grpcStatus: GRPC_STATUS.NOT_FOUND, + httpStatus: HTTP_STATUS.NOT_FOUND, + defaultMessage: 'Task not found', + }, + { + name: 'TaskNotCancelableError', + reason: 'TASK_NOT_CANCELABLE', + code: A2A_ERROR_CODE.TASK_NOT_CANCELABLE, + grpcStatus: GRPC_STATUS.FAILED_PRECONDITION, + httpStatus: HTTP_STATUS.BAD_REQUEST, + defaultMessage: 'Task cannot be canceled', + }, + { + name: 'PushNotificationNotSupportedError', + reason: 'PUSH_NOTIFICATION_NOT_SUPPORTED', + code: A2A_ERROR_CODE.PUSH_NOTIFICATION_NOT_SUPPORTED, + grpcStatus: GRPC_STATUS.FAILED_PRECONDITION, + httpStatus: HTTP_STATUS.BAD_REQUEST, + defaultMessage: 'Push Notification is not supported', + }, + { + name: 'UnsupportedOperationError', + reason: 'UNSUPPORTED_OPERATION', + code: A2A_ERROR_CODE.UNSUPPORTED_OPERATION, + grpcStatus: GRPC_STATUS.FAILED_PRECONDITION, + httpStatus: HTTP_STATUS.BAD_REQUEST, + defaultMessage: 'This operation is not supported', + }, + { + name: 'ContentTypeNotSupportedError', + reason: 'CONTENT_TYPE_NOT_SUPPORTED', + code: A2A_ERROR_CODE.CONTENT_TYPE_NOT_SUPPORTED, + grpcStatus: GRPC_STATUS.INVALID_ARGUMENT, + httpStatus: HTTP_STATUS.BAD_REQUEST, + defaultMessage: 'Incompatible content types', + }, + { + name: 'InvalidAgentResponseError', + reason: 'INVALID_AGENT_RESPONSE', + code: A2A_ERROR_CODE.INVALID_AGENT_RESPONSE, + grpcStatus: GRPC_STATUS.INTERNAL, + httpStatus: HTTP_STATUS.INTERNAL_SERVER_ERROR, + defaultMessage: 'Invalid agent response type', + }, + { + name: 'ExtendedAgentCardNotConfiguredError', + reason: 'EXTENDED_AGENT_CARD_NOT_CONFIGURED', + code: A2A_ERROR_CODE.EXTENDED_CARD_NOT_CONFIGURED, + grpcStatus: GRPC_STATUS.FAILED_PRECONDITION, + httpStatus: HTTP_STATUS.BAD_REQUEST, + defaultMessage: 'Extended Agent Card not configured', + }, + { + name: 'ExtensionSupportRequiredError', + reason: 'EXTENSION_SUPPORT_REQUIRED', + code: A2A_ERROR_CODE.EXTENSION_SUPPORT_REQUIRED, + grpcStatus: GRPC_STATUS.FAILED_PRECONDITION, + httpStatus: HTTP_STATUS.BAD_REQUEST, + defaultMessage: 'Extension support required', + }, + { + name: 'VersionNotSupportedError', + reason: 'VERSION_NOT_SUPPORTED', + code: A2A_ERROR_CODE.VERSION_NOT_SUPPORTED, + grpcStatus: GRPC_STATUS.FAILED_PRECONDITION, + httpStatus: HTTP_STATUS.BAD_REQUEST, + defaultMessage: 'Version not supported', + }, + { + name: 'RequestMalformedError', + reason: 'INVALID_PARAMS', + code: A2A_ERROR_CODE.INVALID_PARAMS, + grpcStatus: GRPC_STATUS.INVALID_ARGUMENT, + httpStatus: HTTP_STATUS.BAD_REQUEST, + defaultMessage: 'Request malformed', + }, + { + name: 'GenericError', + reason: 'INTERNAL_ERROR', + code: A2A_ERROR_CODE.INTERNAL_ERROR, + grpcStatus: GRPC_STATUS.INTERNAL, + httpStatus: HTTP_STATUS.INTERNAL_SERVER_ERROR, + defaultMessage: 'An unexpected error occurred.', + }, +]; + +/** Registry lookups keyed on the various identifiers used across wires. */ +export const A2A_ERROR_SPECS: Readonly> = Object.freeze( + Object.fromEntries(specs.map((s) => [s.name, s])) +); +export const A2A_ERROR_SPECS_BY_REASON: Readonly> = Object.freeze( + Object.fromEntries(specs.map((s) => [s.reason, s])) +); +export const A2A_ERROR_SPECS_BY_CODE: Readonly> = Object.freeze( + Object.fromEntries(specs.map((s) => [s.code, s])) +); + +/** + * Concrete semantic error classes. One per {@link A2AErrorSpec} row. + * Generated by {@link makeSemantic} so adding a new row automatically + * produces a class with the right `name`, `reason`, `code`, and default + * message. Transport variants are declared in `./rest`, `./grpc`, + * `./json_rpc`. + */ +function makeSemantic(spec: A2AErrorSpec): new (options?: A2AErrorOptions | string) => A2AError { + // Named class so `error.name` and stack traces match the spec. + const cls = { + [spec.name]: class extends A2AError { + public override readonly reason = spec.reason; + public override readonly code = spec.code; + constructor(options?: A2AErrorOptions | string) { + super(spec.defaultMessage, typeof options === 'string' ? { message: options } : options); + } + }, + }[spec.name]; + return cls as new (options?: A2AErrorOptions | string) => A2AError; +} + +export const TaskNotFoundError = makeSemantic(A2A_ERROR_SPECS.TaskNotFoundError); +export type TaskNotFoundError = InstanceType; + +export const TaskNotCancelableError = makeSemantic(A2A_ERROR_SPECS.TaskNotCancelableError); +export type TaskNotCancelableError = InstanceType; + +export const PushNotificationNotSupportedError = makeSemantic( + A2A_ERROR_SPECS.PushNotificationNotSupportedError +); +export type PushNotificationNotSupportedError = InstanceType< + typeof PushNotificationNotSupportedError +>; + +export const UnsupportedOperationError = makeSemantic(A2A_ERROR_SPECS.UnsupportedOperationError); +export type UnsupportedOperationError = InstanceType; + +export const ContentTypeNotSupportedError = makeSemantic( + A2A_ERROR_SPECS.ContentTypeNotSupportedError +); +export type ContentTypeNotSupportedError = InstanceType; + +export const InvalidAgentResponseError = makeSemantic(A2A_ERROR_SPECS.InvalidAgentResponseError); +export type InvalidAgentResponseError = InstanceType; + +export const ExtendedAgentCardNotConfiguredError = makeSemantic( + A2A_ERROR_SPECS.ExtendedAgentCardNotConfiguredError +); +export type ExtendedAgentCardNotConfiguredError = InstanceType< + typeof ExtendedAgentCardNotConfiguredError +>; + +export const ExtensionSupportRequiredError = makeSemantic( + A2A_ERROR_SPECS.ExtensionSupportRequiredError +); +export type ExtensionSupportRequiredError = InstanceType; + +export const VersionNotSupportedError = makeSemantic(A2A_ERROR_SPECS.VersionNotSupportedError); +export type VersionNotSupportedError = InstanceType; + +export const RequestMalformedError = makeSemantic(A2A_ERROR_SPECS.RequestMalformedError); +export type RequestMalformedError = InstanceType; + +export const GenericError = makeSemantic(A2A_ERROR_SPECS.GenericError); +export type GenericError = InstanceType; + +/** Constructor type of a semantic {@link A2AError} subclass. */ +export type A2AErrorClass = new (options?: A2AErrorOptions | string) => A2AError; + +/** All semantic error classes indexed by their name. */ +export const A2A_ERROR_CLASSES: Readonly> = Object.freeze({ + TaskNotFoundError, + TaskNotCancelableError, + PushNotificationNotSupportedError, + UnsupportedOperationError, + ContentTypeNotSupportedError, + InvalidAgentResponseError, + ExtendedAgentCardNotConfiguredError, + ExtensionSupportRequiredError, + VersionNotSupportedError, + RequestMalformedError, + GenericError, +}); + +/** + * Coerces an arbitrary rejected-promise reason into a printable message. + * Promise rejections are not required to be `Error` instances. + */ +export function extractErrorMessage(err: unknown): string { + if (err instanceof Error) return err.message; + if (err === null || err === undefined) return String(err); + if (typeof err === 'string') return err; + try { + return JSON.stringify(err); + } catch { + return String(err); + } +} + +/** + * Looks up the {@link A2AErrorSpec} matching an `Error` instance by + * class name. Returns `undefined` for non-A2A errors. + */ +export function specForError(error: unknown): A2AErrorSpec | undefined { + if (!(error instanceof Error)) return undefined; + return A2A_ERROR_SPECS[error.name]; +} + +/** + * Backward-compat shim retained for callers that still pass a `{ code, + * message }` pair. Use {@link A2A_ERROR_SPECS_BY_CODE} plus a class + * constructor directly when writing new code. + */ +export function errorForCode(code: number, message: string): A2AError { + const spec = A2A_ERROR_SPECS_BY_CODE[code]; + const cls = spec ? A2A_ERROR_CLASSES[spec.name] : A2A_ERROR_CLASSES.GenericError; + return new cls({ message }); +} diff --git a/src/errors/grpc.ts b/src/errors/grpc.ts new file mode 100644 index 00000000..d8ca4da2 --- /dev/null +++ b/src/errors/grpc.ts @@ -0,0 +1,237 @@ +/** + * gRPC transport error subclasses and wire helpers. Absorbs the + * previous `src/server/grpc/error_details.ts`. + * + * gRPC does NOT carry a `cause` on the wire, so the transport context + * exposes status code + the `grpc-status-details-bin` trailing + * metadata blob only. + */ + +import type * as grpc from '@grpc/grpc-js'; +import { + A2A_ERROR_CLASSES, + A2A_ERROR_DOMAIN, + A2A_ERROR_SPECS, + A2A_ERROR_SPECS_BY_REASON, + A2AError, + type A2AErrorOptions, + ERROR_INFO_TYPE, + GRPC_STATUS, +} from './base.js'; +import { Any } from '../grpc/pb/google/protobuf/any.js'; +import { ErrorInfo } from '../grpc/pb/google/rpc/error_details.js'; +import { Status } from '../grpc/pb/google/rpc/status.js'; + +/** Trailing metadata key for `google.rpc.Status`. */ +export const GRPC_STATUS_DETAILS_BIN = 'grpc-status-details-bin'; + +/** Transport context carried by every `Grpc*Error`. */ +export interface GrpcA2AError extends A2AError { + readonly transport: 'grpc'; + readonly status: number; + /** Raw `grpc-status-details-bin` blob for callers that want to re-encode. */ + readonly statusDetailsBin?: Buffer; +} + +/** Options accepted by every `Grpc*Error` constructor. */ +export interface GrpcA2AErrorOptions extends A2AErrorOptions { + /** gRPC status code. Defaults to the semantic error's spec value. */ + status?: number; + /** Raw `grpc-status-details-bin` blob if received on the wire. */ + statusDetailsBin?: Buffer; +} + +/** Type guard narrowing an unknown / `A2AError` to {@link GrpcA2AError}. */ +export function isGrpcError(err: unknown): err is GrpcA2AError { + return err instanceof A2AError && (err as { transport?: string }).transport === 'grpc'; +} + +function makeGrpc(name: string): new (options?: GrpcA2AErrorOptions) => GrpcA2AError { + const spec = A2A_ERROR_SPECS[name]; + const Base = A2A_ERROR_CLASSES[name]; + const cls = { + [`Grpc${name}`]: class extends Base { + public readonly transport = 'grpc'; + public readonly status: number; + public readonly statusDetailsBin?: Buffer; + constructor(options?: GrpcA2AErrorOptions) { + super(options); + this.name = name; + this.status = options?.status ?? spec.grpcStatus; + if (options?.statusDetailsBin) this.statusDetailsBin = options.statusDetailsBin; + } + }, + }[`Grpc${name}`]; + return cls as unknown as new (options?: GrpcA2AErrorOptions) => GrpcA2AError; +} + +export const GrpcTaskNotFoundError = makeGrpc('TaskNotFoundError'); +export type GrpcTaskNotFoundError = InstanceType; + +export const GrpcTaskNotCancelableError = makeGrpc('TaskNotCancelableError'); +export type GrpcTaskNotCancelableError = InstanceType; + +export const GrpcPushNotificationNotSupportedError = makeGrpc('PushNotificationNotSupportedError'); +export type GrpcPushNotificationNotSupportedError = InstanceType< + typeof GrpcPushNotificationNotSupportedError +>; + +export const GrpcUnsupportedOperationError = makeGrpc('UnsupportedOperationError'); +export type GrpcUnsupportedOperationError = InstanceType; + +export const GrpcContentTypeNotSupportedError = makeGrpc('ContentTypeNotSupportedError'); +export type GrpcContentTypeNotSupportedError = InstanceType< + typeof GrpcContentTypeNotSupportedError +>; + +export const GrpcInvalidAgentResponseError = makeGrpc('InvalidAgentResponseError'); +export type GrpcInvalidAgentResponseError = InstanceType; + +export const GrpcExtendedAgentCardNotConfiguredError = makeGrpc( + 'ExtendedAgentCardNotConfiguredError' +); +export type GrpcExtendedAgentCardNotConfiguredError = InstanceType< + typeof GrpcExtendedAgentCardNotConfiguredError +>; + +export const GrpcExtensionSupportRequiredError = makeGrpc('ExtensionSupportRequiredError'); +export type GrpcExtensionSupportRequiredError = InstanceType< + typeof GrpcExtensionSupportRequiredError +>; + +export const GrpcVersionNotSupportedError = makeGrpc('VersionNotSupportedError'); +export type GrpcVersionNotSupportedError = InstanceType; + +export const GrpcRequestMalformedError = makeGrpc('RequestMalformedError'); +export type GrpcRequestMalformedError = InstanceType; + +export const GrpcGenericError = makeGrpc('GenericError'); +export type GrpcGenericError = InstanceType; + +/** gRPC twins indexed by their semantic parent's name. */ +export const GRPC_ERROR_CLASSES: Readonly< + Record GrpcA2AError> +> = Object.freeze({ + TaskNotFoundError: GrpcTaskNotFoundError, + TaskNotCancelableError: GrpcTaskNotCancelableError, + PushNotificationNotSupportedError: GrpcPushNotificationNotSupportedError, + UnsupportedOperationError: GrpcUnsupportedOperationError, + ContentTypeNotSupportedError: GrpcContentTypeNotSupportedError, + InvalidAgentResponseError: GrpcInvalidAgentResponseError, + ExtendedAgentCardNotConfiguredError: GrpcExtendedAgentCardNotConfiguredError, + ExtensionSupportRequiredError: GrpcExtensionSupportRequiredError, + VersionNotSupportedError: GrpcVersionNotSupportedError, + RequestMalformedError: GrpcRequestMalformedError, + GenericError: GrpcGenericError, +}); + +/** Returns the gRPC status the server should send for a given error. */ +export function grpcStatusFor(error: unknown): number { + if (isGrpcError(error)) return error.status; + if (error instanceof A2AError) + return A2A_ERROR_SPECS[error.name]?.grpcStatus ?? GRPC_STATUS.UNKNOWN; + return GRPC_STATUS.UNKNOWN; +} + +/** Encodes a `google.rpc.Status` + `ErrorInfo` blob for `grpc-status-details-bin`. */ +export function encodeGrpcStatusDetails( + status: number, + message: string, + reason: string, + metadata?: Record +): Buffer { + const errorInfoBytes = Buffer.from( + ErrorInfo.encode({ reason, domain: A2A_ERROR_DOMAIN, metadata: metadata ?? {} }).finish() + ); + return Buffer.from( + Status.encode({ + code: status, + message, + details: [{ typeUrl: ERROR_INFO_TYPE, value: errorInfoBytes }], + }).finish() + ); +} + +/** Builds trailing gRPC metadata carrying the encoded status blob, or `undefined` if unknown error. */ +export function buildGrpcErrorMetadata( + Metadata: typeof grpc.Metadata, + error: unknown +): grpc.Metadata | undefined { + if (!(error instanceof A2AError)) return undefined; + const spec = A2A_ERROR_SPECS[error.name]; + if (!spec) return undefined; + const blob = encodeGrpcStatusDetails( + grpcStatusFor(error), + error.message, + spec.reason, + error.metadata + ); + const md = new Metadata(); + md.set(GRPC_STATUS_DETAILS_BIN, blob); + return md; +} + +/** Decoded shape of a `google.rpc.Status`. */ +export interface DecodedStatus { + code: number; + message: string; + details: Any[]; +} + +/** Decoded shape of a `google.rpc.ErrorInfo`. */ +export interface DecodedErrorInfo { + reason: string; + domain: string; + metadata: Record; +} + +/** Decodes `google.rpc.Status` from a `grpc-status-details-bin` buffer. */ +export function decodeStatus(buffer: Buffer): DecodedStatus { + return Status.decode(new Uint8Array(buffer)); +} + +/** Decodes `google.rpc.ErrorInfo` from a buffer. */ +export function decodeErrorInfo(buffer: Buffer): DecodedErrorInfo { + return ErrorInfo.decode(new Uint8Array(buffer)); +} + +/** + * Rebuilds a gRPC-specific SDK error from a `grpc.ServiceError`. Reads + * `grpc-status-details-bin` for `ErrorInfo.reason`; falls back to + * {@link GrpcGenericError} carrying the raw status and details string. + * `method` is included in the fallback message for debuggability. + */ +export function fromGrpcError(error: grpc.ServiceError, method?: string): GrpcA2AError { + const bin = error.metadata?.get(GRPC_STATUS_DETAILS_BIN); + let statusDetailsBin: Buffer | undefined; + if (bin && bin.length > 0) { + const raw = bin[0]; + const buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(raw, 'binary'); + statusDetailsBin = buffer; + const status = decodeStatus(buffer); + for (const detail of status.details) { + if (detail.typeUrl === ERROR_INFO_TYPE) { + const info = decodeErrorInfo(detail.value); + const spec = A2A_ERROR_SPECS_BY_REASON[info.reason]; + if (spec) { + const metadata = + info.domain === A2A_ERROR_DOMAIN && info.metadata ? info.metadata : undefined; + return new GRPC_ERROR_CLASSES[spec.name]({ + message: error.details || status.message, + metadata, + status: error.code, + statusDetailsBin, + }); + } + } + } + } + const suffix = method ? ` for ${method}` : ''; + return new GrpcGenericError({ + message: + `gRPC error${suffix}: ${error.code ?? GRPC_STATUS.UNKNOWN} ${error.details ?? ''}`.trim(), + cause: error, + status: error.code ?? GRPC_STATUS.UNKNOWN, + statusDetailsBin, + }); +} diff --git a/src/errors/index.ts b/src/errors/index.ts new file mode 100644 index 00000000..bce38aac --- /dev/null +++ b/src/errors/index.ts @@ -0,0 +1,6 @@ +/** Barrel for the transport-agnostic A2A error hierarchy. */ + +export * from './base.js'; +export * from './rest.js'; +export * from './grpc.js'; +export * from './json_rpc.js'; diff --git a/src/errors/json_rpc.ts b/src/errors/json_rpc.ts new file mode 100644 index 00000000..088b79a4 --- /dev/null +++ b/src/errors/json_rpc.ts @@ -0,0 +1,203 @@ +/** + * JSON-RPC transport error subclasses and envelope helpers. + */ + +import type { JSONRPCError, JSONRPCErrorResponse } from '../core.js'; +import { + A2A_ERROR_CLASSES, + A2A_ERROR_CODE, + A2A_ERROR_SPECS, + A2A_ERROR_SPECS_BY_CODE, + A2AError, + type A2AErrorOptions, + type ErrorDetail, +} from './base.js'; + +/** Transport context carried by every `JsonRpc*Error`. */ +export interface JsonRpcA2AError extends A2AError { + readonly transport: 'jsonrpc'; + readonly envelopeCode: number; + readonly data?: JSONRPCError['data']; +} + +/** Options accepted by every `JsonRpc*Error` constructor. */ +export interface JsonRpcA2AErrorOptions extends A2AErrorOptions { + /** Envelope `error.code`. Defaults to the semantic error's spec value. */ + envelopeCode?: number; + /** Envelope `error.data`, if any. */ + data?: JSONRPCError['data']; +} + +/** Type guard narrowing an unknown / `A2AError` to {@link JsonRpcA2AError}. */ +export function isJsonRpcError(err: unknown): err is JsonRpcA2AError { + return err instanceof A2AError && (err as { transport?: string }).transport === 'jsonrpc'; +} + +function makeJsonRpc(name: string): new (options?: JsonRpcA2AErrorOptions) => JsonRpcA2AError { + const spec = A2A_ERROR_SPECS[name]; + const Base = A2A_ERROR_CLASSES[name]; + const cls = { + [`JsonRpc${name}`]: class extends Base { + public readonly transport = 'jsonrpc'; + public readonly envelopeCode: number; + public readonly data?: JSONRPCError['data']; + constructor(options?: JsonRpcA2AErrorOptions) { + super(options); + this.name = name; + this.envelopeCode = options?.envelopeCode ?? spec.code; + if (options?.data !== undefined) this.data = options.data; + } + }, + }[`JsonRpc${name}`]; + return cls as unknown as new (options?: JsonRpcA2AErrorOptions) => JsonRpcA2AError; +} + +export const JsonRpcTaskNotFoundError = makeJsonRpc('TaskNotFoundError'); +export type JsonRpcTaskNotFoundError = InstanceType; + +export const JsonRpcTaskNotCancelableError = makeJsonRpc('TaskNotCancelableError'); +export type JsonRpcTaskNotCancelableError = InstanceType; + +export const JsonRpcPushNotificationNotSupportedError = makeJsonRpc( + 'PushNotificationNotSupportedError' +); +export type JsonRpcPushNotificationNotSupportedError = InstanceType< + typeof JsonRpcPushNotificationNotSupportedError +>; + +export const JsonRpcUnsupportedOperationError = makeJsonRpc('UnsupportedOperationError'); +export type JsonRpcUnsupportedOperationError = InstanceType< + typeof JsonRpcUnsupportedOperationError +>; + +export const JsonRpcContentTypeNotSupportedError = makeJsonRpc('ContentTypeNotSupportedError'); +export type JsonRpcContentTypeNotSupportedError = InstanceType< + typeof JsonRpcContentTypeNotSupportedError +>; + +export const JsonRpcInvalidAgentResponseError = makeJsonRpc('InvalidAgentResponseError'); +export type JsonRpcInvalidAgentResponseError = InstanceType< + typeof JsonRpcInvalidAgentResponseError +>; + +export const JsonRpcExtendedAgentCardNotConfiguredError = makeJsonRpc( + 'ExtendedAgentCardNotConfiguredError' +); +export type JsonRpcExtendedAgentCardNotConfiguredError = InstanceType< + typeof JsonRpcExtendedAgentCardNotConfiguredError +>; + +export const JsonRpcExtensionSupportRequiredError = makeJsonRpc('ExtensionSupportRequiredError'); +export type JsonRpcExtensionSupportRequiredError = InstanceType< + typeof JsonRpcExtensionSupportRequiredError +>; + +export const JsonRpcVersionNotSupportedError = makeJsonRpc('VersionNotSupportedError'); +export type JsonRpcVersionNotSupportedError = InstanceType; + +export const JsonRpcRequestMalformedError = makeJsonRpc('RequestMalformedError'); +export type JsonRpcRequestMalformedError = InstanceType; + +export const JsonRpcGenericError = makeJsonRpc('GenericError'); +export type JsonRpcGenericError = InstanceType; + +/** JSON-RPC twins indexed by their semantic parent's name. */ +export const JSON_RPC_ERROR_CLASSES: Readonly< + Record JsonRpcA2AError> +> = Object.freeze({ + TaskNotFoundError: JsonRpcTaskNotFoundError, + TaskNotCancelableError: JsonRpcTaskNotCancelableError, + PushNotificationNotSupportedError: JsonRpcPushNotificationNotSupportedError, + UnsupportedOperationError: JsonRpcUnsupportedOperationError, + ContentTypeNotSupportedError: JsonRpcContentTypeNotSupportedError, + InvalidAgentResponseError: JsonRpcInvalidAgentResponseError, + ExtendedAgentCardNotConfiguredError: JsonRpcExtendedAgentCardNotConfiguredError, + ExtensionSupportRequiredError: JsonRpcExtensionSupportRequiredError, + VersionNotSupportedError: JsonRpcVersionNotSupportedError, + RequestMalformedError: JsonRpcRequestMalformedError, + GenericError: JsonRpcGenericError, +}); + +/** + * Envelope for a JSON-RPC error not covered by any semantic code (e.g. + * `METHOD_NOT_FOUND`, `PARSE_ERROR`, or a custom vendor code). Extends + * {@link JsonRpcGenericError} so it still satisfies the transport + * interface and `instanceof A2AError` checks. + */ +export class JsonRpcTransportError extends JsonRpcGenericError { + public readonly errorResponse: JSONRPCErrorResponse; + constructor(envelope: JSONRPCErrorResponse) { + super({ + message: `JSON-RPC error: ${envelope.error.message} (Code: ${envelope.error.code}) Data: ${JSON.stringify(envelope.error.data || {})}`, + envelopeCode: envelope.error.code, + data: envelope.error.data, + }); + // Override the parent's `this.name = 'GenericError'` so + // `error.name === 'JsonRpcTransportError'` for catch-site consumers. + this.name = 'JsonRpcTransportError'; + this.errorResponse = envelope; + } +} + +/** + * Serializes an error to a JSON-RPC `error` envelope. Includes + * `google.rpc.ErrorInfo` in `data[]` for semantic errors. If the error + * is a `JsonRpc*Error`, its `envelopeCode` overrides the semantic + * default — used by the v0.3 compat layer to preserve wire codes like + * `PARSE_ERROR` / `METHOD_NOT_FOUND` that don't map to any semantic + * class. + */ +export function toJsonRpcError(error: unknown): { + code: number; + message: string; + data?: ErrorDetail[]; +} { + if (isJsonRpcError(error)) { + const spec = A2A_ERROR_SPECS[error.name]; + return { + code: error.envelopeCode, + message: error.message, + ...(spec ? { data: [error.toErrorInfo()] } : {}), + }; + } + if (error instanceof A2AError) { + const spec = A2A_ERROR_SPECS[error.name]; + if (spec) { + return { + code: spec.code, + message: error.message, + data: [error.toErrorInfo()], + }; + } + } + const message = (error instanceof Error && error.message) || 'An unexpected error occurred.'; + return { code: A2A_ERROR_CODE.INTERNAL_ERROR, message }; +} + +/** + * JSON-RPC reserved codes without a dedicated semantic class. Map to + * the closest semantic twin so callers can still `instanceof + * RequestMalformedError` etc. + */ +const RESERVED_CODE_TO_SEMANTIC: Readonly> = { + [A2A_ERROR_CODE.PARSE_ERROR]: 'RequestMalformedError', + [A2A_ERROR_CODE.INVALID_REQUEST]: 'RequestMalformedError', + [A2A_ERROR_CODE.METHOD_NOT_FOUND]: 'RequestMalformedError', +}; + +/** + * Rebuilds a semantic JSON-RPC error from a received envelope. Unknown + * codes yield a {@link JsonRpcTransportError} carrying the full envelope. + */ +export function fromJsonRpcErrorResponse(response: JSONRPCErrorResponse): JsonRpcA2AError { + const spec = A2A_ERROR_SPECS_BY_CODE[response.error.code]; + const semanticName = spec?.name ?? RESERVED_CODE_TO_SEMANTIC[response.error.code]; + if (semanticName) { + return new JSON_RPC_ERROR_CLASSES[semanticName]({ + message: response.error.message, + envelopeCode: response.error.code, + data: response.error.data, + }); + } + return new JsonRpcTransportError(response); +} diff --git a/src/errors/rest.ts b/src/errors/rest.ts new file mode 100644 index 00000000..c5d1b7ca --- /dev/null +++ b/src/errors/rest.ts @@ -0,0 +1,192 @@ +/** + * REST/HTTP+JSON transport error subclasses and wire helpers. + * + * Every semantic error has a REST twin (e.g. `RestTaskNotFoundError`) + * that carries HTTP status, response headers, and `cause`. All REST + * twins satisfy {@link RestA2AError}; narrow via {@link isRestError}. + */ + +import { + A2A_ERROR_CLASSES, + A2A_ERROR_DOMAIN, + A2A_ERROR_SPECS, + A2A_ERROR_SPECS_BY_REASON, + A2AError, + type A2AErrorOptions, + ERROR_INFO_TYPE, + type ErrorDetail, + GRPC_STATUS_NAME, + HTTP_STATUS, + type RestErrorBody, +} from './base.js'; + +/** Transport context carried by every `Rest*Error`. */ +export interface RestA2AError extends A2AError { + readonly transport: 'rest'; + readonly statusCode: number; + readonly headers?: Record; +} + +/** Options accepted by every `Rest*Error` constructor. */ +export interface RestA2AErrorOptions extends A2AErrorOptions { + /** HTTP status code. Defaults to the semantic error's spec value. */ + statusCode?: number; + /** Response headers seen on the wire (client-side) or to send (server-side). */ + headers?: Record; +} + +/** + * Type guard for {@link RestA2AError}. Narrows an unknown / `A2AError` + * to the REST interface so callers can access `statusCode`, `headers`. + */ +export function isRestError(err: unknown): err is RestA2AError { + return err instanceof A2AError && (err as { transport?: string }).transport === 'rest'; +} + +/** Builds the REST twin of a semantic error class. */ +function makeRest(name: string): new (options?: RestA2AErrorOptions) => RestA2AError { + const spec = A2A_ERROR_SPECS[name]; + const Base = A2A_ERROR_CLASSES[name]; + const cls = { + [`Rest${name}`]: class extends Base { + public readonly transport = 'rest'; + public readonly statusCode: number; + public readonly headers?: Record; + constructor(options?: RestA2AErrorOptions) { + super(options); + // Keep `error.name` aligned with the semantic class so + // `error.name === 'TaskNotFoundError'` still holds. + this.name = name; + this.statusCode = options?.statusCode ?? spec.httpStatus; + if (options?.headers) this.headers = options.headers; + } + }, + }[`Rest${name}`]; + return cls as unknown as new (options?: RestA2AErrorOptions) => RestA2AError; +} + +// One concrete class per semantic error. Boring but required for +// `instanceof RestTaskNotFoundError`. +export const RestTaskNotFoundError = makeRest('TaskNotFoundError'); +export type RestTaskNotFoundError = InstanceType; + +export const RestTaskNotCancelableError = makeRest('TaskNotCancelableError'); +export type RestTaskNotCancelableError = InstanceType; + +export const RestPushNotificationNotSupportedError = makeRest('PushNotificationNotSupportedError'); +export type RestPushNotificationNotSupportedError = InstanceType< + typeof RestPushNotificationNotSupportedError +>; + +export const RestUnsupportedOperationError = makeRest('UnsupportedOperationError'); +export type RestUnsupportedOperationError = InstanceType; + +export const RestContentTypeNotSupportedError = makeRest('ContentTypeNotSupportedError'); +export type RestContentTypeNotSupportedError = InstanceType< + typeof RestContentTypeNotSupportedError +>; + +export const RestInvalidAgentResponseError = makeRest('InvalidAgentResponseError'); +export type RestInvalidAgentResponseError = InstanceType; + +export const RestExtendedAgentCardNotConfiguredError = makeRest( + 'ExtendedAgentCardNotConfiguredError' +); +export type RestExtendedAgentCardNotConfiguredError = InstanceType< + typeof RestExtendedAgentCardNotConfiguredError +>; + +export const RestExtensionSupportRequiredError = makeRest('ExtensionSupportRequiredError'); +export type RestExtensionSupportRequiredError = InstanceType< + typeof RestExtensionSupportRequiredError +>; + +export const RestVersionNotSupportedError = makeRest('VersionNotSupportedError'); +export type RestVersionNotSupportedError = InstanceType; + +export const RestRequestMalformedError = makeRest('RequestMalformedError'); +export type RestRequestMalformedError = InstanceType; + +export const RestGenericError = makeRest('GenericError'); +export type RestGenericError = InstanceType; + +/** REST twins indexed by their semantic parent's name. */ +export const REST_ERROR_CLASSES: Readonly< + Record RestA2AError> +> = Object.freeze({ + TaskNotFoundError: RestTaskNotFoundError, + TaskNotCancelableError: RestTaskNotCancelableError, + PushNotificationNotSupportedError: RestPushNotificationNotSupportedError, + UnsupportedOperationError: RestUnsupportedOperationError, + ContentTypeNotSupportedError: RestContentTypeNotSupportedError, + InvalidAgentResponseError: RestInvalidAgentResponseError, + ExtendedAgentCardNotConfiguredError: RestExtendedAgentCardNotConfiguredError, + ExtensionSupportRequiredError: RestExtensionSupportRequiredError, + VersionNotSupportedError: RestVersionNotSupportedError, + RequestMalformedError: RestRequestMalformedError, + GenericError: RestGenericError, +}); + +/** + * Returns the HTTP status the server should send for a given error. + * Uses the semantic spec; falls back to 500 for unknown throwables. + */ +export function restStatusFor(error: unknown): number { + if (isRestError(error)) return error.statusCode; + if (error instanceof A2AError) return A2A_ERROR_SPECS[error.name]?.httpStatus ?? 500; + return HTTP_STATUS.INTERNAL_SERVER_ERROR; +} + +/** + * Serializes an error as a `google.rpc.Status` JSON body. + * The `status` field uses the gRPC status name (from the semantic + * spec if available, otherwise inferred from `httpStatus`). + */ +export function toRestErrorBody(error: unknown, httpStatus: number): RestErrorBody { + const message = error instanceof Error ? error.message : 'An unexpected error occurred.'; + const details: ErrorDetail[] = []; + let statusName = 'UNKNOWN'; + + if (error instanceof A2AError) { + details.push(error.toErrorInfo()); + statusName = GRPC_STATUS_NAME[A2A_ERROR_SPECS[error.name].grpcStatus] ?? 'UNKNOWN'; + } else if (httpStatus === HTTP_STATUS.NOT_FOUND) statusName = 'NOT_FOUND'; + else if (httpStatus === HTTP_STATUS.INTERNAL_SERVER_ERROR) statusName = 'INTERNAL'; + else if (httpStatus === HTTP_STATUS.BAD_REQUEST) statusName = 'INVALID_ARGUMENT'; + + return { error: { code: httpStatus, status: statusName, message, details } }; +} + +/** + * Rebuilds a REST-specific SDK error from a parsed error body. + * `details[]` is scanned for `ErrorInfo`; if found, its `reason` + * selects the semantic twin. Otherwise falls back to + * {@link RestGenericError} with the raw message. + */ +export function fromRestErrorBody( + body: { + message?: string; + code?: number; + status?: string; + details?: Array>; + }, + transportCtx: { statusCode: number; headers?: Record } +): RestA2AError { + const message = body.message || 'Unknown error'; + const details = body.details; + if (Array.isArray(details)) { + for (const d of details) { + if (d['@type'] === ERROR_INFO_TYPE && typeof d.reason === 'string') { + const spec = A2A_ERROR_SPECS_BY_REASON[d.reason]; + if (spec) { + const metadata = + d.domain === A2A_ERROR_DOMAIN && d.metadata && typeof d.metadata === 'object' + ? (d.metadata as Record) + : undefined; + return new REST_ERROR_CLASSES[spec.name]({ message, metadata, ...transportCtx }); + } + } + } + } + return new RestGenericError({ message, ...transportCtx }); +} diff --git a/src/index.ts b/src/index.ts index e80ae3f4..d62b005b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,3 +33,8 @@ export { parseSseStream, type SseEvent, } from './sse_utils.js'; + +// Transport-agnostic error hierarchy: base class, semantic subclasses, +// per-transport variants (Rest*/Grpc*/JsonRpc*), and `is*Error` type +// guards. Also re-exported from `./client` and `./server`. +export * from './errors/index.js'; diff --git a/src/server/express/json_rpc_handler.ts b/src/server/express/json_rpc_handler.ts index 39d7bc3a..e795c7a3 100644 --- a/src/server/express/json_rpc_handler.ts +++ b/src/server/express/json_rpc_handler.ts @@ -14,7 +14,7 @@ import { A2A_VERSION_HEADER, HTTP_EXTENSION_HEADER, JSON_CONTENT_TYPE } from '.. import { UserBuilder, delegateAsyncIterator } from './common.js'; import { SSE_HEADERS, formatSSEEvent, formatSSEErrorEvent } from '../../sse_utils.js'; import { Extensions } from '../../extensions.js'; -import { A2A_ERROR_CODE, ContentTypeNotSupportedError } from '../../errors.js'; +import { A2A_ERROR_CODE, ContentTypeNotSupportedError } from '../../errors/index.js'; import { validateVersion } from '../version.js'; import { LegacyJsonRpcTransportHandler } from '../../compat/v0_3/server/index.js'; import { diff --git a/src/server/express/rest_handler.ts b/src/server/express/rest_handler.ts index 2033ac6a..5601a671 100644 --- a/src/server/express/rest_handler.ts +++ b/src/server/express/rest_handler.ts @@ -41,7 +41,7 @@ import { TaskPushNotificationConfig, } from '../../types/pb/a2a.js'; import { ToProto } from '../../types/converters/to_proto.js'; -import { ContentTypeNotSupportedError, RequestMalformedError } from '../../errors.js'; +import { ContentTypeNotSupportedError, RequestMalformedError } from '../../errors/index.js'; /** Options for configuring the HTTP+JSON/REST handler. */ export interface RestHandlerOptions { diff --git a/src/server/grpc/error_details.ts b/src/server/grpc/error_details.ts deleted file mode 100644 index 588ced34..00000000 --- a/src/server/grpc/error_details.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Utilities for encoding and decoding `google.rpc.Status` with - * `google.rpc.ErrorInfo` in gRPC error metadata. - */ - -import * as grpc from '@grpc/grpc-js'; -import { A2A_ERROR_DOMAIN, A2A_ERROR_REASON, ERROR_INFO_TYPE } from '../../errors.js'; -import { Status } from '../../grpc/pb/google/rpc/status.js'; -import { ErrorInfo } from '../../grpc/pb/google/rpc/error_details.js'; -import { Any } from '../../grpc/pb/google/protobuf/any.js'; - -/** - * Builds gRPC trailing metadata with `grpc-status-details-bin` carrying - * a `google.rpc.Status` + `google.rpc.ErrorInfo`. Returns `undefined` - * if `error` has no known reason mapping. - */ -export function buildGrpcErrorMetadata( - grpcCode: number, - message: string, - error: Error -): grpc.Metadata | undefined { - const reason = A2A_ERROR_REASON[error.name]; - if (!reason) return undefined; - - const errorInfoBytes = Buffer.from( - ErrorInfo.encode({ - reason, - domain: A2A_ERROR_DOMAIN, - metadata: {}, - }).finish() - ); - - const statusBytes = Buffer.from( - Status.encode({ - code: grpcCode, - message, - details: [ - { - typeUrl: ERROR_INFO_TYPE, - value: errorInfoBytes, - }, - ], - }).finish() - ); - - const metadata = new grpc.Metadata(); - metadata.set('grpc-status-details-bin', statusBytes); - return metadata; -} - -/** Decodes a `google.rpc.Status` protobuf message from binary. */ -export function decodeStatus(buffer: Buffer): { - code: number; - message: string; - details: Any[]; -} { - return Status.decode(new Uint8Array(buffer)); -} - -/** Decodes a `google.rpc.ErrorInfo` protobuf message from binary. */ -export function decodeErrorInfo(buffer: Buffer): { - reason: string; - domain: string; - metadata: Record; -} { - return ErrorInfo.decode(new Uint8Array(buffer)); -} diff --git a/src/server/grpc/grpc_service.ts b/src/server/grpc/grpc_service.ts index 9ee74e11..5b7245fa 100644 --- a/src/server/grpc/grpc_service.ts +++ b/src/server/grpc/grpc_service.ts @@ -27,22 +27,14 @@ import { defaultServerCallContextBuilder, } from '../context.js'; import { Extensions } from '../../extensions.js'; -import { buildGrpcErrorMetadata } from './error_details.js'; import { UserBuilder } from './common.js'; import { A2A_VERSION_HEADER, HTTP_EXTENSION_HEADER } from '../../constants.js'; import { - ContentTypeNotSupportedError, - ExtendedAgentCardNotConfiguredError, - ExtensionSupportRequiredError, - GenericError, - InvalidAgentResponseError, - PushNotificationNotSupportedError, - RequestMalformedError, - TaskNotCancelableError, - TaskNotFoundError, - UnsupportedOperationError, - VersionNotSupportedError, -} from '../../errors.js'; + A2AError, + buildGrpcErrorMetadata, + GRPC_STATUS, + grpcStatusFor, +} from '../../errors/index.js'; import { validateVersion } from '../version.js'; /** Options for configuring the gRPC handler. */ @@ -224,41 +216,17 @@ export function grpcService(options: GrpcServiceOptions): A2AServiceServer { } /** - * Maps an error to a gRPC error with status details. For A2A-specific - * errors, attaches `google.rpc.ErrorInfo` in `grpc-status-details-bin`. - * Uses `instanceof` so user-defined subclasses of A2A error types resolve - * to the gRPC status of the nearest base. + * Maps an error to a gRPC error with status details. For {@link A2AError} + * instances, attaches `google.rpc.ErrorInfo` in `grpc-status-details-bin`. + * The gRPC status comes from the semantic error's registry entry + * (`grpcStatusFor`), so user-defined subclasses inherit the base status. */ const mapToError = (error: unknown): Partial => { - let code = grpc.status.UNKNOWN; - if (error instanceof TaskNotFoundError) code = grpc.status.NOT_FOUND; - else if (error instanceof TaskNotCancelableError) code = grpc.status.FAILED_PRECONDITION; - else if (error instanceof PushNotificationNotSupportedError) - code = grpc.status.FAILED_PRECONDITION; - else if (error instanceof UnsupportedOperationError) code = grpc.status.FAILED_PRECONDITION; - else if (error instanceof ContentTypeNotSupportedError) code = grpc.status.INVALID_ARGUMENT; - else if (error instanceof InvalidAgentResponseError) code = grpc.status.INTERNAL; - else if (error instanceof ExtendedAgentCardNotConfiguredError) - code = grpc.status.FAILED_PRECONDITION; - else if (error instanceof ExtensionSupportRequiredError) code = grpc.status.FAILED_PRECONDITION; - else if (error instanceof VersionNotSupportedError) code = grpc.status.FAILED_PRECONDITION; - else if (error instanceof RequestMalformedError) code = grpc.status.INVALID_ARGUMENT; - else if (error instanceof GenericError) code = grpc.status.INTERNAL; - + const code = error instanceof A2AError ? grpcStatusFor(error) : GRPC_STATUS.UNKNOWN; const message = error instanceof Error ? error.message : 'Internal server error'; - - const result: Partial = { - code, - details: message, - }; - - if (error instanceof Error) { - const errorMetadata = buildGrpcErrorMetadata(code, message, error); - if (errorMetadata) { - result.metadata = errorMetadata; - } - } - + const result: Partial = { code, details: message }; + const md = buildGrpcErrorMetadata(grpc.Metadata, error); + if (md) result.metadata = md; return result; }; diff --git a/src/server/index.ts b/src/server/index.ts index 04b22701..7b070662 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -42,17 +42,6 @@ export type { RequestHeaders, } from './context.js'; export { validateVersion, getSupportedVersions } from './version.js'; -export { - RequestMalformedError, - TaskNotFoundError, - TaskNotCancelableError, - PushNotificationNotSupportedError, - UnsupportedOperationError, - ContentTypeNotSupportedError, - InvalidAgentResponseError, - ExtendedAgentCardNotConfiguredError, - VersionNotSupportedError, -} from '../errors.js'; export type { PushNotificationSender } from './push_notification/push_notification_sender.js'; export { DefaultPushNotificationSender } from './push_notification/default_push_notification_sender.js'; diff --git a/src/server/request_handler/default_request_handler.ts b/src/server/request_handler/default_request_handler.ts index c18af308..62222c35 100644 --- a/src/server/request_handler/default_request_handler.ts +++ b/src/server/request_handler/default_request_handler.ts @@ -1,15 +1,15 @@ import { v4 as uuidv4 } from 'uuid'; import { - RequestMalformedError, + ExtendedAgentCardNotConfiguredError, + ExtensionSupportRequiredError, + GenericError, PushNotificationNotSupportedError, + RequestMalformedError, TaskNotCancelableError, TaskNotFoundError, UnsupportedOperationError, - GenericError, - ExtendedAgentCardNotConfiguredError, - ExtensionSupportRequiredError, -} from '../../errors.js'; +} from '../../errors/index.js'; import { Message, @@ -64,7 +64,7 @@ import { StreamPattern, } from '../utils.js'; import { AgentCardSignatureGenerator } from '../../signature.js'; -import { extractErrorMessage } from '../../errors.js'; +import { extractErrorMessage } from '../../errors/index.js'; /** * Default implementation of the A2A request handler. diff --git a/src/server/store.ts b/src/server/store.ts index 6a4ea760..cde97b4f 100644 --- a/src/server/store.ts +++ b/src/server/store.ts @@ -1,7 +1,7 @@ import { Task, ListTasksRequest, ListTasksResponse } from '../index.js'; import { ServerCallContext } from './context.js'; import { DEFAULT_PAGE_SIZE } from '../constants.js'; -import { RequestMalformedError } from '../errors.js'; +import { RequestMalformedError } from '../errors/index.js'; import { OwnerResolver, resolveUserScope } from './owner_resolver.js'; import { ScopedStore } from './utils.js'; diff --git a/src/server/transports/jsonrpc/jsonrpc_transport_handler.ts b/src/server/transports/jsonrpc/jsonrpc_transport_handler.ts index d3eb1b2d..b1a7f72e 100644 --- a/src/server/transports/jsonrpc/jsonrpc_transport_handler.ts +++ b/src/server/transports/jsonrpc/jsonrpc_transport_handler.ts @@ -17,13 +17,12 @@ import { AgentCard, } from '../../../index.js'; import { - A2A_ERROR_CLASS_TO_CODE, A2A_ERROR_CODE, + type ErrorDetail, RequestMalformedError, + toJsonRpcError, UnsupportedOperationError, - buildErrorInfo, - type ErrorDetail, -} from '../../../errors.js'; +} from '../../../errors/index.js'; import { JSONRPCErrorResponse } from '../../../core.js'; export type A2ARequest = { @@ -270,17 +269,6 @@ export class JsonRpcTransportHandler { message: string; data?: ErrorDetail[]; } { - if (error instanceof Error) { - const code = A2A_ERROR_CLASS_TO_CODE[error.name]; - if (code !== undefined) { - const data: ErrorDetail[] = []; - const errorInfo = buildErrorInfo(error); - if (errorInfo) data.push(errorInfo); - return { code, message: error.message, ...(data.length > 0 ? { data } : {}) }; - } - } - - const message = (error instanceof Error && error.message) || 'An unexpected error occurred.'; - return { code: A2A_ERROR_CODE.INTERNAL_ERROR, message }; + return toJsonRpcError(error); } } diff --git a/src/server/transports/rest/rest_transport_handler.ts b/src/server/transports/rest/rest_transport_handler.ts index e16edce2..b09ab736 100644 --- a/src/server/transports/rest/rest_transport_handler.ts +++ b/src/server/transports/rest/rest_transport_handler.ts @@ -22,76 +22,15 @@ import { } from '../../../index.js'; import { taskStateFromJSON } from '../../../types/pb/a2a.js'; import { - buildErrorInfo, - ContentTypeNotSupportedError, - ExtendedAgentCardNotConfiguredError, - ExtensionSupportRequiredError, - getGrpcStatusName, - InvalidAgentResponseError, + HTTP_STATUS, PushNotificationNotSupportedError, RequestMalformedError, - TaskNotCancelableError, - TaskNotFoundError, + restStatusFor as mapErrorToStatus, + toRestErrorBody as toHTTPError, UnsupportedOperationError, - VersionNotSupportedError, - type ErrorDetail, - type RestErrorBody, -} from '../../../errors.js'; +} from '../../../errors/index.js'; -/** HTTP status codes used in REST responses. */ -export const HTTP_STATUS = { - OK: 200, - CREATED: 201, - ACCEPTED: 202, - NO_CONTENT: 204, - BAD_REQUEST: 400, - UNAUTHORIZED: 401, - NOT_FOUND: 404, - CONFLICT: 409, - INTERNAL_SERVER_ERROR: 500, - NOT_IMPLEMENTED: 501, -} as const; - -/** Maps an error instance to its HTTP status code. */ -export function mapErrorToStatus(error: unknown): number { - if (error instanceof TaskNotFoundError) return HTTP_STATUS.NOT_FOUND; - if (error instanceof TaskNotCancelableError) return HTTP_STATUS.BAD_REQUEST; - if (error instanceof PushNotificationNotSupportedError) return HTTP_STATUS.BAD_REQUEST; - if (error instanceof UnsupportedOperationError) return HTTP_STATUS.BAD_REQUEST; - if (error instanceof ContentTypeNotSupportedError) return HTTP_STATUS.BAD_REQUEST; - if (error instanceof InvalidAgentResponseError) return HTTP_STATUS.INTERNAL_SERVER_ERROR; - if (error instanceof ExtendedAgentCardNotConfiguredError) return HTTP_STATUS.BAD_REQUEST; - if (error instanceof ExtensionSupportRequiredError) return HTTP_STATUS.BAD_REQUEST; - if (error instanceof VersionNotSupportedError) return HTTP_STATUS.BAD_REQUEST; - if (error instanceof RequestMalformedError) return HTTP_STATUS.BAD_REQUEST; - return HTTP_STATUS.INTERNAL_SERVER_ERROR; -} - -/** - * Converts an error to a `google.rpc.Status` JSON response body, with - * `google.rpc.ErrorInfo` in `details` when the error has a known reason. - */ -export function toHTTPError(error: unknown, httpStatus: number): RestErrorBody { - const message = error instanceof Error ? error.message : 'An unexpected error occurred.'; - const status = getGrpcStatusName(error, httpStatus); - const details: ErrorDetail[] = []; - - if (error instanceof Error) { - const errorInfo = buildErrorInfo(error); - if (errorInfo) { - details.push(errorInfo); - } - } - - return { - error: { - code: httpStatus, - status, - message, - details, - }, - }; -} +export { HTTP_STATUS, mapErrorToStatus, toHTTPError }; /** * Handles the REST transport layer, routing requests to an diff --git a/src/server/version.ts b/src/server/version.ts index 843bb196..a12094de 100644 --- a/src/server/version.ts +++ b/src/server/version.ts @@ -1,5 +1,5 @@ import { TransportProtocolName } from '../core.js'; -import { VersionNotSupportedError } from '../errors.js'; +import { VersionNotSupportedError } from '../errors/index.js'; import { AgentCard } from '../index.js'; /** diff --git a/src/types/converters/from_proto.ts b/src/types/converters/from_proto.ts index cfd213a4..5ff74a16 100644 --- a/src/types/converters/from_proto.ts +++ b/src/types/converters/from_proto.ts @@ -1,4 +1,4 @@ -import { GenericError } from '../../errors.js'; +import { GenericError } from '../../errors/index.js'; import { Message, SendMessageResponse, Task } from '../pb/a2a.js'; /** diff --git a/src/types/converters/to_proto.ts b/src/types/converters/to_proto.ts index f088eb53..85bf2ceb 100644 --- a/src/types/converters/to_proto.ts +++ b/src/types/converters/to_proto.ts @@ -1,4 +1,4 @@ -import { GenericError } from '../../errors.js'; +import { GenericError } from '../../errors/index.js'; import { Message, SendMessageResponse, Task } from '../pb/a2a.js'; export class ToProto { diff --git a/test/client/transports/grpc_transport.spec.ts b/test/client/transports/grpc_transport.spec.ts index 657e411b..587810e0 100644 --- a/test/client/transports/grpc_transport.spec.ts +++ b/test/client/transports/grpc_transport.spec.ts @@ -9,8 +9,8 @@ import { TaskNotFoundError, TaskNotCancelableError, PushNotificationNotSupportedError, -} from '../../../src/errors.js'; -import { buildGrpcErrorMetadata } from '../../../src/server/grpc/error_details.js'; +} from '../../../src/errors/index.js'; +import { buildGrpcErrorMetadata } from '../../../src/errors/index.js'; import { createMessageParams, createMockAgentCard, @@ -55,7 +55,7 @@ describe('GrpcTransport', () => { const mockUnaryError = (method: Mock, code: number, message: string, sdkError?: Error) => { method.mockImplementation((_req: any, _meta: any, _opts: any, callback: any) => { - const metadata = sdkError ? buildGrpcErrorMetadata(code, message, sdkError) : undefined; + const metadata = sdkError ? buildGrpcErrorMetadata(Metadata, sdkError) : undefined; const error: Partial = { code: code, details: message, diff --git a/test/client/transports/rest_transport.spec.ts b/test/client/transports/rest_transport.spec.ts index cb234ac7..d754eaa9 100644 --- a/test/client/transports/rest_transport.spec.ts +++ b/test/client/transports/rest_transport.spec.ts @@ -11,7 +11,7 @@ import { TaskNotFoundError, TaskNotCancelableError, PushNotificationNotSupportedError, -} from '../../../src/errors.js'; +} from '../../../src/errors/index.js'; import { createMessageParams, createMockAgentCard, diff --git a/test/client/util.ts b/test/client/util.ts index 4daec992..5de3b97b 100644 --- a/test/client/util.ts +++ b/test/client/util.ts @@ -3,12 +3,11 @@ import { AGENT_CARD_PATH, JSON_CONTENT_TYPE } from '../../src/constants.js'; import { Role, SendMessageResponse, Task, TaskState } from '../../src/types/pb/a2a.js'; import { SendMessageResult } from '../../src/index.js'; import { - A2A_ERROR_CODE_TO_CLASS, A2A_ERROR_DOMAIN, - A2A_ERROR_GRPC_STATUS, - A2A_ERROR_REASON, + A2A_ERROR_SPECS_BY_CODE, ERROR_INFO_TYPE, -} from '../../src/errors.js'; + GRPC_STATUS_NAME, +} from '../../src/errors/index.js'; export function extractRequestId(options?: RequestInit): number { if (!options?.body) { @@ -355,15 +354,11 @@ export function createRestResponse( return new Response(JSON.stringify(data), { status, headers: responseHeaders }); } -// Resolves a JSON-RPC error code to (reason, grpcStatusName) by chaining -// through the canonical mappings: code → className → (reason, grpcStatus). +// Resolves a JSON-RPC error code to (reason, grpcStatusName) via the registry. function resolveErrorCode(code: number): { reason: string; grpcStatus: string } | undefined { - const className = A2A_ERROR_CODE_TO_CLASS[code]; - if (!className) return undefined; - const reason = A2A_ERROR_REASON[className]; - const grpcStatus = A2A_ERROR_GRPC_STATUS[className]; - if (!reason || !grpcStatus) return undefined; - return { reason, grpcStatus }; + const spec = A2A_ERROR_SPECS_BY_CODE[code]; + if (!spec) return undefined; + return { reason: spec.reason, grpcStatus: GRPC_STATUS_NAME[spec.grpcStatus] ?? 'UNKNOWN' }; } // Creates a REST error response in the google.rpc.Status JSON format. diff --git a/test/compat/v0_3/client/transports/grpc/grpc_transport.spec.ts b/test/compat/v0_3/client/transports/grpc/grpc_transport.spec.ts index a14fdbac..47fb387a 100644 --- a/test/compat/v0_3/client/transports/grpc/grpc_transport.spec.ts +++ b/test/compat/v0_3/client/transports/grpc/grpc_transport.spec.ts @@ -5,8 +5,11 @@ import { type LegacyGrpcTransportOptions, } from '../../../../../../src/compat/v0_3/client/transports/grpc/grpc_transport.js'; import { A2AServiceClient } from '../../../../../../src/compat/v0_3/grpc/pb/a2a.js'; -import { TaskNotFoundError, UnsupportedOperationError } from '../../../../../../src/errors.js'; -import { buildGrpcErrorMetadata } from '../../../../../../src/server/grpc/error_details.js'; +import { + TaskNotFoundError, + UnsupportedOperationError, +} from '../../../../../../src/errors/index.js'; +import { buildGrpcErrorMetadata } from '../../../../../../src/errors/index.js'; import { Role as V1Role, TaskState as V1TaskState, @@ -86,7 +89,7 @@ const mockUnarySuccess = (method: Mock, response: unknown) => { const mockUnaryError = (method: Mock, code: number, message: string, sdkError?: Error) => { method.mockImplementation((_req: unknown, _meta: unknown, _opts: unknown, callback: any) => { - const metadata = sdkError ? buildGrpcErrorMetadata(code, message, sdkError) : undefined; + const metadata = sdkError ? buildGrpcErrorMetadata(Metadata, sdkError) : undefined; const error: Partial = { code, details: message, diff --git a/test/compat/v0_3/client/transports/json_rpc_transport.spec.ts b/test/compat/v0_3/client/transports/json_rpc_transport.spec.ts index 37294f4d..e840e128 100644 --- a/test/compat/v0_3/client/transports/json_rpc_transport.spec.ts +++ b/test/compat/v0_3/client/transports/json_rpc_transport.spec.ts @@ -2,9 +2,9 @@ import { describe, it, beforeEach, expect, vi, type Mock } from 'vitest'; import { formatSSEEvent } from '../../../../../src/sse_utils.js'; import { A2A_ERROR_CODE, - JSONRPCTransportError, + JsonRpcTransportError as JSONRPCTransportError, TaskNotFoundError, -} from '../../../../../src/errors.js'; +} from '../../../../../src/errors/index.js'; import { LegacyJsonRpcTransport } from '../../../../../src/compat/v0_3/client/transports/json_rpc_transport.js'; import { Role, diff --git a/test/compat/v0_3/client/transports/rest_transport.spec.ts b/test/compat/v0_3/client/transports/rest_transport.spec.ts index fd85510b..30d220b9 100644 --- a/test/compat/v0_3/client/transports/rest_transport.spec.ts +++ b/test/compat/v0_3/client/transports/rest_transport.spec.ts @@ -7,7 +7,7 @@ import { TaskNotCancelableError, TaskNotFoundError, UnsupportedOperationError, -} from '../../../../../src/errors.js'; +} from '../../../../../src/errors/index.js'; import { formatSSEEvent, formatSSEErrorEvent } from '../../../../../src/sse_utils.js'; import { Role, diff --git a/test/compat/v0_3/constants.spec.ts b/test/compat/v0_3/constants.spec.ts index 822f43a3..78d7a54b 100644 --- a/test/compat/v0_3/constants.spec.ts +++ b/test/compat/v0_3/constants.spec.ts @@ -29,7 +29,15 @@ import { isLegacyJsonRpcMethod, isV1JsonRpcMethod, } from '../../../src/compat/v0_3/constants.js'; -import { A2AError } from '../../../src/compat/v0_3/server/error.js'; +import { A2AError as _LegacyFacade } from '../../../src/compat/v0_3/server/error.js'; +import { A2AError, isJsonRpcError, UnsupportedOperationError } from '../../../src/errors/index.js'; +void _LegacyFacade; + +/** Reads the wire code from a thrown error: envelopeCode wins over spec.code. */ +function wireCode(err: unknown): number { + return isJsonRpcError(err) ? err.envelopeCode : (err as A2AError).code; +} +void UnsupportedOperationError; // silence unused import guard const ALL_LEGACY_METHODS = [ LEGACY_METHOD_MESSAGE_SEND, @@ -128,9 +136,9 @@ describe('compat/v0_3/constants - asymmetric v1.0 methods', () => { expect.fail('expected throw'); } catch (err) { expect(err).toBeInstanceOf(A2AError); - expect((err as A2AError).code).toBe(-32004); - expect((err as A2AError).message).toContain('ListTasks'); - expect((err as A2AError).message).toContain('JSON-RPC'); + expect(wireCode(err)).toBe(-32004); + expect((err as Error).message).toContain('ListTasks'); + expect((err as Error).message).toContain('JSON-RPC'); } }); @@ -140,9 +148,9 @@ describe('compat/v0_3/constants - asymmetric v1.0 methods', () => { expect.fail('expected throw'); } catch (err) { expect(err).toBeInstanceOf(A2AError); - expect((err as A2AError).code).toBe(-32004); - expect((err as A2AError).message).toContain('ListTasks'); - expect((err as A2AError).message).toContain('gRPC'); + expect(wireCode(err)).toBe(-32004); + expect((err as Error).message).toContain('ListTasks'); + expect((err as Error).message).toContain('gRPC'); } }); }); @@ -154,8 +162,8 @@ describe('compat/v0_3/constants - helper error behaviour', () => { expect.fail('expected throw'); } catch (err) { expect(err).toBeInstanceOf(A2AError); - expect((err as A2AError).code).toBe(-32600); - expect((err as A2AError).message).toContain('does/not/exist'); + expect(wireCode(err)).toBe(-32600); + expect((err as Error).message).toContain('does/not/exist'); } }); @@ -165,7 +173,7 @@ describe('compat/v0_3/constants - helper error behaviour', () => { expect.fail('expected throw'); } catch (err) { expect(err).toBeInstanceOf(A2AError); - expect((err as A2AError).code).toBe(-32600); + expect(wireCode(err)).toBe(-32600); } }); @@ -175,7 +183,7 @@ describe('compat/v0_3/constants - helper error behaviour', () => { expect.fail('expected throw'); } catch (err) { expect(err).toBeInstanceOf(A2AError); - expect((err as A2AError).code).toBe(-32600); + expect(wireCode(err)).toBe(-32600); } }); @@ -185,7 +193,7 @@ describe('compat/v0_3/constants - helper error behaviour', () => { expect.fail('expected throw'); } catch (err) { expect(err).toBeInstanceOf(A2AError); - expect((err as A2AError).code).toBe(-32600); + expect(wireCode(err)).toBe(-32600); } }); @@ -195,7 +203,7 @@ describe('compat/v0_3/constants - helper error behaviour', () => { expect.fail('expected throw'); } catch (err) { expect(err).toBeInstanceOf(A2AError); - expect((err as A2AError).code).toBe(-32600); + expect(wireCode(err)).toBe(-32600); } }); @@ -205,7 +213,7 @@ describe('compat/v0_3/constants - helper error behaviour', () => { expect.fail('expected throw'); } catch (err) { expect(err).toBeInstanceOf(A2AError); - expect((err as A2AError).code).toBe(-32600); + expect(wireCode(err)).toBe(-32600); } }); }); diff --git a/test/compat/v0_3/server/grpc/grpc_service.spec.ts b/test/compat/v0_3/server/grpc/grpc_service.spec.ts index 31990938..435439c8 100644 --- a/test/compat/v0_3/server/grpc/grpc_service.spec.ts +++ b/test/compat/v0_3/server/grpc/grpc_service.spec.ts @@ -5,7 +5,7 @@ import { PushNotificationNotSupportedError, TaskNotCancelableError, TaskNotFoundError, -} from '../../../../../src/errors.js'; +} from '../../../../../src/errors/index.js'; import { A2AError as LegacyA2AError } from '../../../../../src/compat/v0_3/server/error.js'; import { A2A_VERSION_HEADER, HTTP_EXTENSION_HEADER } from '../../../../../src/constants.js'; import type { A2ARequestHandler } from '../../../../../src/server/request_handler/a2a_request_handler.js'; @@ -15,7 +15,7 @@ import { type AgentCard as V1AgentCard, type Task as V1Task, } from '../../../../../src/types/pb/a2a.js'; -import { decodeErrorInfo, decodeStatus } from '../../../../../src/server/grpc/error_details.js'; +import { decodeErrorInfo, decodeStatus } from '../../../../../src/errors/index.js'; // v0.3 GRPC interface so validateVersion accepts the defaulted '0.3'. const testAgentCard: V1AgentCard = { diff --git a/test/compat/v0_3/server/transports/jsonrpc/jsonrpc_transport_handler.spec.ts b/test/compat/v0_3/server/transports/jsonrpc/jsonrpc_transport_handler.spec.ts index ce8ce4ca..b633154a 100644 --- a/test/compat/v0_3/server/transports/jsonrpc/jsonrpc_transport_handler.spec.ts +++ b/test/compat/v0_3/server/transports/jsonrpc/jsonrpc_transport_handler.spec.ts @@ -16,7 +16,7 @@ import { TaskNotFoundError, UnsupportedOperationError, VersionNotSupportedError, -} from '../../../../../../src/errors.js'; +} from '../../../../../../src/errors/index.js'; import { Role, TaskState } from '../../../../../../src/types/pb/a2a.js'; import type { AgentCard as V1AgentCard, diff --git a/test/compat/v0_3/server/transports/rest/rest_transport_handler.spec.ts b/test/compat/v0_3/server/transports/rest/rest_transport_handler.spec.ts index f0b53c37..46867219 100644 --- a/test/compat/v0_3/server/transports/rest/rest_transport_handler.spec.ts +++ b/test/compat/v0_3/server/transports/rest/rest_transport_handler.spec.ts @@ -21,7 +21,7 @@ import { TaskNotFoundError, UnsupportedOperationError, VersionNotSupportedError, -} from '../../../../../../src/errors.js'; +} from '../../../../../../src/errors/index.js'; import { Role, TaskState } from '../../../../../../src/types/pb/a2a.js'; import type { AgentCard as V1AgentCard, diff --git a/test/compat/v0_3/translate/agent_card.spec.ts b/test/compat/v0_3/translate/agent_card.spec.ts index 65169721..81c4cf9a 100644 --- a/test/compat/v0_3/translate/agent_card.spec.ts +++ b/test/compat/v0_3/translate/agent_card.spec.ts @@ -16,7 +16,7 @@ import { toCoreAgentProvider, toCoreAgentSkill, } from '../../../../src/compat/v0_3/translate/agent_card.js'; -import { VersionNotSupportedError } from '../../../../src/errors.js'; +import { VersionNotSupportedError } from '../../../../src/errors/index.js'; import type { AgentCard as V1AgentCard, AgentInterface as V1AgentInterface, diff --git a/test/compat/v0_3/translate/errors.spec.ts b/test/compat/v0_3/translate/errors.spec.ts index 437b0906..db180b65 100644 --- a/test/compat/v0_3/translate/errors.spec.ts +++ b/test/compat/v0_3/translate/errors.spec.ts @@ -7,13 +7,14 @@ import { ExtensionSupportRequiredError, GenericError, InvalidAgentResponseError, + JsonRpcRequestMalformedError, PushNotificationNotSupportedError, RequestMalformedError, TaskNotCancelableError, TaskNotFoundError, UnsupportedOperationError, VersionNotSupportedError, -} from '../../../../src/errors.js'; +} from '../../../../src/errors/index.js'; const v1ToLegacyCodeCases: ReadonlyArray Error, number]> = [ [() => new TaskNotFoundError('a'), -32001], @@ -41,8 +42,14 @@ describe('compat/v0_3/translate/errors - toCompatErrorBody', () => { expect(out.message).toContain('t-1'); }); - it('preserves the data field from a LegacyA2AError', () => { - const err = LegacyA2AError.invalidParams('boom', { hint: 'check x' }); + it('preserves the data field from a JsonRpc*Error', () => { + // Callers that need to attach `data` build a `JsonRpc*Error` + // directly rather than using the LegacyA2AError facade. + const err = new JsonRpcRequestMalformedError({ + message: 'boom', + envelopeCode: -32602, + data: { hint: 'check x' }, + }); const out = toCompatErrorBody(err); expect(out.code).toBe(-32602); expect(out.message).toBe('boom'); diff --git a/test/e2e.spec.ts b/test/e2e.spec.ts index 44fd1a95..91db6d14 100644 --- a/test/e2e.spec.ts +++ b/test/e2e.spec.ts @@ -16,7 +16,7 @@ import { TaskNotCancelableError, UnsupportedOperationError, ExtensionSupportRequiredError, -} from '../src/errors.js'; +} from '../src/errors/index.js'; import { agentCardHandler } from '../src/server/express/agent_card_handler.js'; import { jsonRpcHandler } from '../src/server/express/json_rpc_handler.js'; import { restHandler } from '../src/server/express/rest_handler.js'; diff --git a/test/errors.spec.ts b/test/errors.spec.ts index 05f313bb..6f6fe3d0 100644 --- a/test/errors.spec.ts +++ b/test/errors.spec.ts @@ -1,22 +1,33 @@ import { describe, it, expect, vi } from 'vitest'; import type { JSONRPCErrorResponse } from '../src/core.js'; import { + A2A_ERROR_CLASSES, A2A_ERROR_CODE, + A2A_ERROR_SPECS_BY_CODE, ContentTypeNotSupportedError, ExtendedAgentCardNotConfiguredError, ExtensionSupportRequiredError, + extractErrorMessage, + fromJsonRpcErrorResponse as mapJsonRpcErrorToSdkError, InvalidAgentResponseError, - JSONRPCTransportError, + JsonRpcTransportError as JSONRPCTransportError, PushNotificationNotSupportedError, RequestMalformedError, TaskNotCancelableError, TaskNotFoundError, UnsupportedOperationError, VersionNotSupportedError, - mapA2aErrorToSdkError, - mapJsonRpcErrorToSdkError, - extractErrorMessage, -} from '../src/errors.js'; +} from '../src/errors/index.js'; + +/** Thin wrapper that matches the removed `mapA2aErrorToSdkError` shape. */ +function mapA2aErrorToSdkError( + err: { code: number; message: string }, + fallback: () => Error +): Error { + const spec = A2A_ERROR_SPECS_BY_CODE[err.code]; + if (spec) return new A2A_ERROR_CLASSES[spec.name]({ message: err.message }); + return fallback(); +} function makeEnvelope(code: number, message = 'boom'): JSONRPCErrorResponse { return { @@ -32,7 +43,7 @@ describe('mapJsonRpcErrorToSdkError', () => { [A2A_ERROR_CODE.INVALID_REQUEST, RequestMalformedError], [A2A_ERROR_CODE.METHOD_NOT_FOUND, RequestMalformedError], [A2A_ERROR_CODE.INVALID_PARAMS, RequestMalformedError], - [A2A_ERROR_CODE.INTERNAL_ERROR, RequestMalformedError], + [A2A_ERROR_CODE.INTERNAL_ERROR, A2A_ERROR_CLASSES.GenericError], [A2A_ERROR_CODE.TASK_NOT_FOUND, TaskNotFoundError], [A2A_ERROR_CODE.TASK_NOT_CANCELABLE, TaskNotCancelableError], [A2A_ERROR_CODE.PUSH_NOTIFICATION_NOT_SUPPORTED, PushNotificationNotSupportedError], @@ -59,10 +70,10 @@ describe('mapJsonRpcErrorToSdkError', () => { expect(transportError.message).toContain('-99999'); }); - it('JSONRPCTransportError sets a stable name for catch/instanceof callers', () => { + it('JsonRpcTransportError sets a stable name for catch/instanceof callers', () => { const envelope = makeEnvelope(-99999); const result = mapJsonRpcErrorToSdkError(envelope); - expect(result.name).toBe('JSONRPCTransportError'); + expect(result.name).toBe('JsonRpcTransportError'); }); it('preserves the original error message from the envelope', () => { diff --git a/test/server/default_request_handler.spec.ts b/test/server/default_request_handler.spec.ts index 6a9a5262..ca458181 100644 --- a/test/server/default_request_handler.spec.ts +++ b/test/server/default_request_handler.spec.ts @@ -9,7 +9,7 @@ import { TaskNotCancelableError, ExtendedAgentCardNotConfiguredError, ExtensionSupportRequiredError, -} from '../../src/errors.js'; +} from '../../src/errors/index.js'; import { TaskStore, InMemoryTaskStore, diff --git a/test/server/express/express_app.spec.ts b/test/server/express/express_app.spec.ts index c22647fc..26e71945 100644 --- a/test/server/express/express_app.spec.ts +++ b/test/server/express/express_app.spec.ts @@ -32,7 +32,7 @@ import { LegacyJsonRpcTransportHandler } from '../../../src/compat/v0_3/server/i import { AgentCard } from '../../../src/index.js'; import { JSONRPCErrorResponse } from '../../../src/core.js'; import { AGENT_CARD_PATH, HTTP_EXTENSION_HEADER } from '../../../src/constants.js'; -import { A2A_ERROR_CODE, GenericError, RequestMalformedError } from '../../../src/errors.js'; +import { A2A_ERROR_CODE, GenericError, RequestMalformedError } from '../../../src/errors/index.js'; import { ServerCallContext } from '../../../src/server/context.js'; import { User, UnauthenticatedUser } from '../../../src/server/authentication/user.js'; diff --git a/test/server/express/rest_handler.spec.ts b/test/server/express/rest_handler.spec.ts index b6f0dc7b..5c90b3df 100644 --- a/test/server/express/rest_handler.spec.ts +++ b/test/server/express/rest_handler.spec.ts @@ -20,7 +20,7 @@ import { RequestMalformedError, TaskNotFoundError, TaskNotCancelableError, -} from '../../../src/errors.js'; +} from '../../../src/errors/index.js'; import { ListTaskPushNotificationConfigsResponse, Message as ProtoMessage, diff --git a/test/server/grpc/from_proto.spec.ts b/test/server/grpc/from_proto.spec.ts index 5d8c1a49..8dd3e6eb 100644 --- a/test/server/grpc/from_proto.spec.ts +++ b/test/server/grpc/from_proto.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { FromProto } from '../../../src/types/converters/from_proto.js'; import * as proto from '../../../src/types/pb/a2a.js'; -import { GenericError } from '../../../src/errors.js'; +import { GenericError } from '../../../src/errors/index.js'; describe('FromProto', () => { describe('sendMessageResult', () => { diff --git a/test/server/grpc/grpc_handler.spec.ts b/test/server/grpc/grpc_handler.spec.ts index 5124fc5e..4be66086 100644 --- a/test/server/grpc/grpc_handler.spec.ts +++ b/test/server/grpc/grpc_handler.spec.ts @@ -2,7 +2,7 @@ import { describe, it, beforeEach, afterEach, assert, expect, vi, Mock } from 'v import * as grpc from '@grpc/grpc-js'; import * as proto from '../../../src/grpc/pb/a2a.js'; import { A2ARequestHandler } from '../../../src/server/index.js'; -import { TaskNotFoundError } from '../../../src/errors.js'; +import { TaskNotFoundError } from '../../../src/errors/index.js'; import { grpcService } from '../../../src/server/grpc/grpc_service.js'; import { AgentCard, diff --git a/test/server/jsonrpc_transport_handler.spec.ts b/test/server/jsonrpc_transport_handler.spec.ts index c0d1e150..df38c020 100644 --- a/test/server/jsonrpc_transport_handler.spec.ts +++ b/test/server/jsonrpc_transport_handler.spec.ts @@ -15,7 +15,7 @@ import { A2A_ERROR_CODE, GenericError, ExtendedAgentCardNotConfiguredError, -} from '../../src/errors.js'; +} from '../../src/errors/index.js'; describe('JsonRpcTransportHandler', () => { let mockRequestHandler: A2ARequestHandler; diff --git a/test/server/request_handler/resubscribe.spec.ts b/test/server/request_handler/resubscribe.spec.ts index f51d77c6..02388d91 100644 --- a/test/server/request_handler/resubscribe.spec.ts +++ b/test/server/request_handler/resubscribe.spec.ts @@ -16,7 +16,7 @@ import { import { DefaultExecutionEventBusManager } from '../../../src/server/events/execution_event_bus_manager.js'; import { AgentEvent } from '../../../src/server/events/execution_event_bus.js'; import { ServerCallContext } from '../../../src/server/context.js'; -import { TaskNotFoundError, UnsupportedOperationError } from '../../../src/errors.js'; +import { TaskNotFoundError, UnsupportedOperationError } from '../../../src/errors/index.js'; import { TERMINAL_STATE_LIST } from '../../../src/server/utils.js'; import { MockAgentExecutor } from '../mocks/agent-executor.mock.js'; diff --git a/test/server/rest_transport_handler.spec.ts b/test/server/rest_transport_handler.spec.ts index cbd664cc..a900b3f2 100644 --- a/test/server/rest_transport_handler.spec.ts +++ b/test/server/rest_transport_handler.spec.ts @@ -13,7 +13,7 @@ import { TaskNotCancelableError, PushNotificationNotSupportedError, UnsupportedOperationError, -} from '../../src/errors.js'; +} from '../../src/errors/index.js'; import { AgentCard, Task, Message, Role, TaskState, TaskStatus } from '../../src/index.js'; import { ServerCallContext } from '../../src/server/context.js'; diff --git a/test/server/version.spec.ts b/test/server/version.spec.ts index 73a44c5f..e87dfe5d 100644 --- a/test/server/version.spec.ts +++ b/test/server/version.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { validateVersion, getSupportedVersions } from '../../src/server/version.js'; -import { VersionNotSupportedError } from '../../src/errors.js'; +import { VersionNotSupportedError } from '../../src/errors/index.js'; import { AgentCard } from '../../src/index.js'; import { ServerCallContext } from '../../src/server/context.js'; From 3b6cd190d9f318aecb3ddc248361676ad42ff741 Mon Sep 17 00:00:00 2001 From: JakubWorek Date: Fri, 17 Jul 2026 12:42:19 +0000 Subject: [PATCH 02/11] fix suggestions --- src/compat/v0_3/server/error.ts | 22 ++++++++++++++++++++-- src/errors/rest.ts | 3 ++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/compat/v0_3/server/error.ts b/src/compat/v0_3/server/error.ts index 9bc3644e..ea497f5b 100644 --- a/src/compat/v0_3/server/error.ts +++ b/src/compat/v0_3/server/error.ts @@ -13,6 +13,8 @@ import { A2AError as BaseA2AError, ExtendedAgentCardNotConfiguredError, GenericError, + JSON_RPC_ERROR_CLASSES, + JsonRpcGenericError, JsonRpcRequestMalformedError, JsonRpcUnsupportedOperationError, PushNotificationNotSupportedError, @@ -49,7 +51,23 @@ interface A2AErrorFacade { function makeA2AError(code: number, message: string, data?: Record): BaseA2AError { const spec = A2A_ERROR_SPECS_BY_CODE[code]; - if (spec) return new A2A_ERROR_CLASSES[spec.name]({ message }); + if (spec) { + return data === undefined + ? new A2A_ERROR_CLASSES[spec.name]({ message }) + : new JSON_RPC_ERROR_CLASSES[spec.name]({ + message, + envelopeCode: code, + data: data as never, + }); + } + + if (code === A2A_ERROR_CODE.METHOD_NOT_FOUND) { + return new JsonRpcUnsupportedOperationError({ + message, + envelopeCode: code, + data: data as never, + }); + } return new JsonRpcRequestMalformedError({ message, envelopeCode: code, @@ -98,7 +116,7 @@ A2AError.invalidParams = (message, data) => A2AError.internalError = (message, data) => data === undefined ? new GenericError({ message }) - : new JsonRpcRequestMalformedError({ + : new JsonRpcGenericError({ message, envelopeCode: A2A_ERROR_CODE.INTERNAL_ERROR, data: data as never, diff --git a/src/errors/rest.ts b/src/errors/rest.ts index c5d1b7ca..a0b373fb 100644 --- a/src/errors/rest.ts +++ b/src/errors/rest.ts @@ -149,7 +149,8 @@ export function toRestErrorBody(error: unknown, httpStatus: number): RestErrorBo if (error instanceof A2AError) { details.push(error.toErrorInfo()); - statusName = GRPC_STATUS_NAME[A2A_ERROR_SPECS[error.name].grpcStatus] ?? 'UNKNOWN'; + const spec = A2A_ERROR_SPECS[error.name]; + statusName = spec ? (GRPC_STATUS_NAME[spec.grpcStatus] ?? 'UNKNOWN') : 'UNKNOWN'; } else if (httpStatus === HTTP_STATUS.NOT_FOUND) statusName = 'NOT_FOUND'; else if (httpStatus === HTTP_STATUS.INTERNAL_SERVER_ERROR) statusName = 'INTERNAL'; else if (httpStatus === HTTP_STATUS.BAD_REQUEST) statusName = 'INVALID_ARGUMENT'; From 06f475b93b1558543396b93dc9c798e735cdfc43 Mon Sep 17 00:00:00 2001 From: JakubWorek Date: Mon, 20 Jul 2026 08:09:11 +0000 Subject: [PATCH 03/11] fixes after review --- .../v0_3/client/transports/grpc/grpc_transport.ts | 13 ++----------- src/index.ts | 2 +- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/src/compat/v0_3/client/transports/grpc/grpc_transport.ts b/src/compat/v0_3/client/transports/grpc/grpc_transport.ts index 6d96328c..f77d95aa 100644 --- a/src/compat/v0_3/client/transports/grpc/grpc_transport.ts +++ b/src/compat/v0_3/client/transports/grpc/grpc_transport.ts @@ -326,7 +326,7 @@ export class LegacyGrpcTransport implements Transport { options.signal.removeEventListener('abort', onAbort); } if (error) { - return reject(LegacyGrpcTransport._mapToError(error, method)); + return reject(fromGrpcError(error, method)); } try { resolve(converter(response)); @@ -371,7 +371,7 @@ export class LegacyGrpcTransport implements Transport { } } catch (error) { if (LegacyGrpcTransport._isServiceError(error)) { - throw LegacyGrpcTransport._mapToError(error, method); + throw fromGrpcError(error, method); } throw new Error(`GRPC error for ${String(method)}!`, { cause: error }); } finally { @@ -414,13 +414,4 @@ export class LegacyGrpcTransport implements Transport { }; return toCoreStreamResponse(envelope); } - - /** - * Decodes `google.rpc.ErrorInfo` from `grpc-status-details-bin` when - * present (this SDK's `legacyGrpcService` emits it); otherwise returns - * `GrpcGenericError` preserving the gRPC code and details. - */ - private static _mapToError(error: grpc.ServiceError, method?: string): Error { - return fromGrpcError(error, method); - } } diff --git a/src/index.ts b/src/index.ts index d62b005b..0a53450b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,5 +36,5 @@ export { // Transport-agnostic error hierarchy: base class, semantic subclasses, // per-transport variants (Rest*/Grpc*/JsonRpc*), and `is*Error` type -// guards. Also re-exported from `./client` and `./server`. +// guards. export * from './errors/index.js'; From be65ae7feda302acfcef7333db7af48eb8a8128e Mon Sep 17 00:00:00 2001 From: JakubWorek Date: Mon, 20 Jul 2026 08:39:53 +0000 Subject: [PATCH 04/11] test coverage --- test/errors.spec.ts | 369 +++++++++++++++++++++++++++++++++++++++ test/errors_grpc.spec.ts | 65 +++++++ vitest.edge.config.ts | 1 + 3 files changed, 435 insertions(+) create mode 100644 test/errors_grpc.spec.ts diff --git a/test/errors.spec.ts b/test/errors.spec.ts index 6f6fe3d0..d873159c 100644 --- a/test/errors.spec.ts +++ b/test/errors.spec.ts @@ -3,18 +3,37 @@ import type { JSONRPCErrorResponse } from '../src/core.js'; import { A2A_ERROR_CLASSES, A2A_ERROR_CODE, + A2A_ERROR_DOMAIN, + A2A_ERROR_SPECS, A2A_ERROR_SPECS_BY_CODE, + A2AError, ContentTypeNotSupportedError, + ERROR_INFO_TYPE, ExtendedAgentCardNotConfiguredError, ExtensionSupportRequiredError, extractErrorMessage, fromJsonRpcErrorResponse as mapJsonRpcErrorToSdkError, + fromRestErrorBody, + GenericError, + GRPC_STATUS, + GrpcTaskNotFoundError, + grpcStatusFor, + HTTP_STATUS, InvalidAgentResponseError, + isGrpcError, + isJsonRpcError, + isRestError, + JsonRpcRequestMalformedError, + JsonRpcTaskNotFoundError, JsonRpcTransportError as JSONRPCTransportError, PushNotificationNotSupportedError, RequestMalformedError, + RestTaskNotFoundError, + restStatusFor, TaskNotCancelableError, TaskNotFoundError, + toJsonRpcError, + toRestErrorBody, UnsupportedOperationError, VersionNotSupportedError, } from '../src/errors/index.js'; @@ -160,3 +179,353 @@ describe('extractErrorMessage', () => { expect(extractErrorMessage(cyclic)).toBe('[object Object]'); }); }); + +// --------------------------------------------------------------------------- +// Hierarchy: every transport variant is-a semantic subclass is-a A2AError. +// Guards `isRestError` / `isGrpcError` / `isJsonRpcError` narrow correctly, +// and only ONE guard matches per instance (transports are exclusive). +// --------------------------------------------------------------------------- + +describe('A2AError hierarchy', () => { + it('semantic class extends A2AError extends Error', () => { + const e = new TaskNotFoundError({ message: 't-1' }); + expect(e).toBeInstanceOf(TaskNotFoundError); + expect(e).toBeInstanceOf(A2AError); + expect(e).toBeInstanceOf(Error); + expect(e.name).toBe('TaskNotFoundError'); + expect(e.code).toBe(-32001); + expect(e.reason).toBe('TASK_NOT_FOUND'); + }); + + it('RestTaskNotFoundError is-a TaskNotFoundError is-a A2AError', () => { + const e = new RestTaskNotFoundError({ statusCode: 404 }); + expect(e).toBeInstanceOf(RestTaskNotFoundError); + expect(e).toBeInstanceOf(TaskNotFoundError); + expect(e).toBeInstanceOf(A2AError); + expect(e).toBeInstanceOf(Error); + }); + + it('GrpcTaskNotFoundError is-a TaskNotFoundError is-a A2AError', () => { + const e = new GrpcTaskNotFoundError({ status: GRPC_STATUS.NOT_FOUND }); + expect(e).toBeInstanceOf(GrpcTaskNotFoundError); + expect(e).toBeInstanceOf(TaskNotFoundError); + expect(e).toBeInstanceOf(A2AError); + }); + + it('JsonRpcTaskNotFoundError is-a TaskNotFoundError is-a A2AError', () => { + const e = new JsonRpcTaskNotFoundError({ envelopeCode: -32001 }); + expect(e).toBeInstanceOf(JsonRpcTaskNotFoundError); + expect(e).toBeInstanceOf(TaskNotFoundError); + expect(e).toBeInstanceOf(A2AError); + }); + + it('semantic class name is preserved across transport variants', () => { + // Users rely on `error.name === 'TaskNotFoundError'` for logging / + // instanceof-adjacent branching; the makeRest/makeGrpc/makeJsonRpc + // factories override `this.name = ` for this reason. + expect(new RestTaskNotFoundError().name).toBe('TaskNotFoundError'); + expect(new GrpcTaskNotFoundError().name).toBe('TaskNotFoundError'); + expect(new JsonRpcTaskNotFoundError().name).toBe('TaskNotFoundError'); + }); +}); + +describe('transport type guards', () => { + it('isRestError narrows only RestA2AError instances', () => { + expect(isRestError(new RestTaskNotFoundError({ statusCode: 404 }))).toBe(true); + expect(isRestError(new GrpcTaskNotFoundError())).toBe(false); + expect(isRestError(new JsonRpcTaskNotFoundError())).toBe(false); + expect(isRestError(new TaskNotFoundError())).toBe(false); // plain semantic + expect(isRestError(new Error('nope'))).toBe(false); + expect(isRestError(null)).toBe(false); + expect(isRestError(undefined)).toBe(false); + expect(isRestError('string')).toBe(false); + }); + + it('isGrpcError narrows only GrpcA2AError instances', () => { + expect(isGrpcError(new GrpcTaskNotFoundError({ status: GRPC_STATUS.NOT_FOUND }))).toBe(true); + expect(isGrpcError(new RestTaskNotFoundError())).toBe(false); + expect(isGrpcError(new JsonRpcTaskNotFoundError())).toBe(false); + expect(isGrpcError(new TaskNotFoundError())).toBe(false); + expect(isGrpcError(new Error('nope'))).toBe(false); + }); + + it('isJsonRpcError narrows only JsonRpcA2AError instances', () => { + expect(isJsonRpcError(new JsonRpcTaskNotFoundError({ envelopeCode: -32001 }))).toBe(true); + expect(isJsonRpcError(new RestTaskNotFoundError())).toBe(false); + expect(isJsonRpcError(new GrpcTaskNotFoundError())).toBe(false); + expect(isJsonRpcError(new TaskNotFoundError())).toBe(false); + expect(isJsonRpcError(new Error('nope'))).toBe(false); + }); + + it('transports are mutually exclusive: exactly one guard matches per instance', () => { + const rest = new RestTaskNotFoundError({ statusCode: 404 }); + const grpc = new GrpcTaskNotFoundError({ status: GRPC_STATUS.NOT_FOUND }); + const json = new JsonRpcTaskNotFoundError({ envelopeCode: -32001 }); + + expect([isRestError(rest), isGrpcError(rest), isJsonRpcError(rest)]).toEqual([ + true, + false, + false, + ]); + expect([isRestError(grpc), isGrpcError(grpc), isJsonRpcError(grpc)]).toEqual([ + false, + true, + false, + ]); + expect([isRestError(json), isGrpcError(json), isJsonRpcError(json)]).toEqual([ + false, + false, + true, + ]); + }); + + it('guards enable typed access to transport context', () => { + // Compile-time proof (delete the guard and TS complains) + runtime. + const err: A2AError = new RestTaskNotFoundError({ + statusCode: 429, + headers: { 'retry-after': '10' }, + }); + if (isRestError(err)) { + expect(err.statusCode).toBe(429); + expect(err.headers?.['retry-after']).toBe('10'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Options plumbing: message defaults, message override, metadata, cause. +// --------------------------------------------------------------------------- + +describe('A2AError construction', () => { + it('defaults message from spec.defaultMessage when none is passed', () => { + expect(new TaskNotFoundError().message).toBe('Task not found'); + expect(new UnsupportedOperationError().message).toBe('This operation is not supported'); + expect(new GenericError().message).toBe('An unexpected error occurred.'); + }); + + it('accepts a bare string as the message (legacy call shape)', () => { + expect(new TaskNotFoundError('custom text').message).toBe('custom text'); + }); + + it('accepts an options object with message', () => { + expect(new TaskNotFoundError({ message: 'via options' }).message).toBe('via options'); + }); + + it('preserves cause (ES2022 Error.cause)', () => { + const root = new Error('root'); + const e = new TaskNotFoundError({ message: 'wrapped', cause: root }); + expect((e as unknown as { cause: unknown }).cause).toBe(root); + }); + + it('stores metadata when non-empty and omits it when empty', () => { + const withMd = new TaskNotFoundError({ metadata: { taskId: 't-1' } }); + expect(withMd.metadata).toEqual({ taskId: 't-1' }); + + const withoutMd = new TaskNotFoundError({ metadata: {} }); + expect(withoutMd.metadata).toBeUndefined(); + + const noArg = new TaskNotFoundError(); + expect(noArg.metadata).toBeUndefined(); + }); + + it('every semantic error has a corresponding entry in A2A_ERROR_SPECS', () => { + for (const [name, Cls] of Object.entries(A2A_ERROR_CLASSES)) { + const instance = new Cls(); + const spec = A2A_ERROR_SPECS[name]; + expect(spec).toBeDefined(); + expect(instance.code).toBe(spec.code); + expect(instance.reason).toBe(spec.reason); + } + }); +}); + +// --------------------------------------------------------------------------- +// toErrorInfo(): the shape shipped in google.rpc.ErrorInfo (spec §10.6/§11.6). +// --------------------------------------------------------------------------- + +describe('A2AError.toErrorInfo', () => { + it('returns spec-shaped ErrorInfo with the right @type and domain', () => { + const info = new TaskNotFoundError().toErrorInfo(); + expect(info['@type']).toBe(ERROR_INFO_TYPE); + expect(info.reason).toBe('TASK_NOT_FOUND'); + expect(info.domain).toBe(A2A_ERROR_DOMAIN); + expect(info).not.toHaveProperty('metadata'); // omitted when empty + }); + + it('emits metadata when the constructor received a non-empty map', () => { + const info = new TaskNotFoundError({ metadata: { taskId: 't-1' } }).toErrorInfo(); + expect(info.metadata).toEqual({ taskId: 't-1' }); + }); +}); + +// --------------------------------------------------------------------------- +// Status helpers: restStatusFor / grpcStatusFor honor the instance override +// but fall back to the semantic spec, and default to UNKNOWN/500 otherwise. +// --------------------------------------------------------------------------- + +describe('restStatusFor', () => { + it('returns the instance-level statusCode when it is a RestA2AError', () => { + expect(restStatusFor(new RestTaskNotFoundError({ statusCode: 418 }))).toBe(418); + }); + + it('falls back to the spec httpStatus for a plain semantic error', () => { + expect(restStatusFor(new TaskNotFoundError())).toBe(HTTP_STATUS.NOT_FOUND); + expect(restStatusFor(new UnsupportedOperationError())).toBe(HTTP_STATUS.BAD_REQUEST); + expect(restStatusFor(new InvalidAgentResponseError())).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR); + }); + + it('returns 500 for non-A2A throwables', () => { + expect(restStatusFor(new Error('unrelated'))).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR); + expect(restStatusFor('string')).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR); + expect(restStatusFor(undefined)).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR); + }); +}); + +describe('grpcStatusFor', () => { + it('returns the instance-level status when it is a GrpcA2AError', () => { + expect(grpcStatusFor(new GrpcTaskNotFoundError({ status: GRPC_STATUS.CANCELLED }))).toBe( + GRPC_STATUS.CANCELLED + ); + }); + + it('falls back to the spec grpcStatus for a plain semantic error', () => { + expect(grpcStatusFor(new TaskNotFoundError())).toBe(GRPC_STATUS.NOT_FOUND); + expect(grpcStatusFor(new ContentTypeNotSupportedError())).toBe(GRPC_STATUS.INVALID_ARGUMENT); + expect(grpcStatusFor(new InvalidAgentResponseError())).toBe(GRPC_STATUS.INTERNAL); + }); + + it('returns UNKNOWN for non-A2A throwables', () => { + expect(grpcStatusFor(new Error('unrelated'))).toBe(GRPC_STATUS.UNKNOWN); + expect(grpcStatusFor(null)).toBe(GRPC_STATUS.UNKNOWN); + }); +}); + +// --------------------------------------------------------------------------- +// Wire roundtrips: serialize semantic error -> parse -> same class + metadata. +// --------------------------------------------------------------------------- + +describe('REST roundtrip', () => { + it('semantic error survives toRestErrorBody -> fromRestErrorBody', () => { + const original = new TaskNotFoundError({ + message: 'task xyz missing', + metadata: { taskId: 'xyz' }, + }); + const body = toRestErrorBody(original, HTTP_STATUS.NOT_FOUND); + + // §11.6 body shape. + expect(body.error.code).toBe(HTTP_STATUS.NOT_FOUND); + expect(body.error.status).toBe('NOT_FOUND'); + expect(body.error.message).toBe('task xyz missing'); + expect(body.error.details[0]).toMatchObject({ + '@type': ERROR_INFO_TYPE, + reason: 'TASK_NOT_FOUND', + domain: A2A_ERROR_DOMAIN, + metadata: { taskId: 'xyz' }, + }); + + const rebuilt = fromRestErrorBody(body.error, { + statusCode: HTTP_STATUS.NOT_FOUND, + headers: { 'x-a': '1' }, + }); + + expect(rebuilt).toBeInstanceOf(TaskNotFoundError); + expect(rebuilt).toBeInstanceOf(RestTaskNotFoundError); + expect(rebuilt.statusCode).toBe(HTTP_STATUS.NOT_FOUND); + expect(rebuilt.headers?.['x-a']).toBe('1'); + expect(rebuilt.metadata).toEqual({ taskId: 'xyz' }); + expect(rebuilt.message).toBe('task xyz missing'); + }); + + it('body without ErrorInfo detail becomes RestGenericError', () => { + const rebuilt = fromRestErrorBody({ message: 'plain 500', details: [] }, { statusCode: 500 }); + expect(rebuilt).toBeInstanceOf(A2AError); + expect(isRestError(rebuilt)).toBe(true); + expect(rebuilt.name).toBe('GenericError'); + expect(rebuilt.message).toBe('plain 500'); + expect(rebuilt.statusCode).toBe(500); + }); + + it('body with unknown ErrorInfo.reason becomes RestGenericError', () => { + const rebuilt = fromRestErrorBody( + { + message: 'unknown', + details: [{ '@type': ERROR_INFO_TYPE, reason: 'MADE_UP_REASON', domain: 'nope' }], + }, + { statusCode: 500 } + ); + expect(rebuilt.name).toBe('GenericError'); + }); + + it('ignores metadata when the ErrorInfo.domain is not a2a-protocol.org', () => { + const rebuilt = fromRestErrorBody( + { + message: 'foreign', + details: [ + { + '@type': ERROR_INFO_TYPE, + reason: 'TASK_NOT_FOUND', + domain: 'other.example', + metadata: { taskId: 't-1' }, + }, + ], + }, + { statusCode: 404 } + ); + expect(rebuilt).toBeInstanceOf(TaskNotFoundError); + expect(rebuilt.metadata).toBeUndefined(); + }); +}); + +describe('JSON-RPC roundtrip', () => { + it('semantic error survives toJsonRpcError -> fromJsonRpcErrorResponse', () => { + const original = new TaskNotFoundError({ + message: 'task xyz missing', + metadata: { taskId: 'xyz' }, + }); + const envelopeError = toJsonRpcError(original); + + expect(envelopeError.code).toBe(A2A_ERROR_CODE.TASK_NOT_FOUND); + expect(envelopeError.message).toBe('task xyz missing'); + expect(envelopeError.data?.[0]).toMatchObject({ + '@type': ERROR_INFO_TYPE, + reason: 'TASK_NOT_FOUND', + domain: A2A_ERROR_DOMAIN, + metadata: { taskId: 'xyz' }, + }); + + const rebuilt = mapJsonRpcErrorToSdkError({ + jsonrpc: '2.0', + id: 1, + error: envelopeError, + }); + expect(rebuilt).toBeInstanceOf(TaskNotFoundError); + expect(rebuilt).toBeInstanceOf(JsonRpcTaskNotFoundError); + expect(rebuilt.envelopeCode).toBe(A2A_ERROR_CODE.TASK_NOT_FOUND); + expect(rebuilt.message).toBe('task xyz missing'); + }); + + it('JsonRpc*Error.envelopeCode overrides the semantic default', () => { + // v0.3 compat case: METHOD_NOT_FOUND has no semantic twin, so we + // route it through JsonRpcRequestMalformedError with envelopeCode + // overridden. The envelope must preserve that wire code. + const err = new JsonRpcRequestMalformedError({ + message: 'no such method', + envelopeCode: A2A_ERROR_CODE.METHOD_NOT_FOUND, + }); + const envelope = toJsonRpcError(err); + expect(envelope.code).toBe(A2A_ERROR_CODE.METHOD_NOT_FOUND); // NOT -32602 + expect(envelope.message).toBe('no such method'); + }); + + it('unknown code becomes JsonRpcTransportError carrying the full envelope', () => { + const envelope: JSONRPCErrorResponse = { + jsonrpc: '2.0', + id: 9, + error: { code: -99999, message: 'mystery', data: { foo: 'bar' } }, + }; + const rebuilt = mapJsonRpcErrorToSdkError(envelope); + expect(rebuilt).toBeInstanceOf(JSONRPCTransportError); + expect((rebuilt as JSONRPCTransportError).errorResponse).toBe(envelope); + expect(isJsonRpcError(rebuilt)).toBe(true); + }); +}); diff --git a/test/errors_grpc.spec.ts b/test/errors_grpc.spec.ts new file mode 100644 index 00000000..458e990a --- /dev/null +++ b/test/errors_grpc.spec.ts @@ -0,0 +1,65 @@ +/** + * gRPC-transport roundtrip tests for the shared error hierarchy. + * + * Split out from `errors.spec.ts` because `@grpc/grpc-js` is Node-only + * (the Workers-safe edge suite excludes anything that imports it). + */ + +import { Metadata, status as grpcStatus, type ServiceError } from '@grpc/grpc-js'; +import { describe, it, expect } from 'vitest'; +import { + buildGrpcErrorMetadata, + fromGrpcError, + GenericError, + GrpcTaskNotFoundError, + isGrpcError, + TaskNotFoundError, +} from '../src/errors/index.js'; + +describe('gRPC roundtrip', () => { + it('semantic error survives buildGrpcErrorMetadata -> fromGrpcError', () => { + const original = new TaskNotFoundError({ + message: 'task xyz missing', + metadata: { taskId: 'xyz' }, + }); + const md = buildGrpcErrorMetadata(Metadata, original); + expect(md).toBeDefined(); + + const wireError = { + code: grpcStatus.NOT_FOUND, + details: 'task xyz missing', + metadata: md, + message: 'ignored', + name: 'ServiceError', + } as ServiceError; + + const rebuilt = fromGrpcError(wireError); + expect(rebuilt).toBeInstanceOf(TaskNotFoundError); + expect(rebuilt).toBeInstanceOf(GrpcTaskNotFoundError); + expect(rebuilt.status).toBe(grpcStatus.NOT_FOUND); + expect(rebuilt.metadata).toEqual({ taskId: 'xyz' }); + expect(rebuilt.message).toBe('task xyz missing'); + expect(rebuilt.statusDetailsBin).toBeInstanceOf(Buffer); + }); + + it('returns undefined metadata when the input error has no A2A spec entry', () => { + expect(buildGrpcErrorMetadata(Metadata, new Error('unrelated'))).toBeUndefined(); + expect(buildGrpcErrorMetadata(Metadata, 'string')).toBeUndefined(); + }); + + it('service error without grpc-status-details-bin becomes GrpcGenericError', () => { + const wireError = { + code: grpcStatus.INTERNAL, + details: 'boom', + metadata: new Metadata(), + message: 'ignored', + name: 'ServiceError', + } as ServiceError; + const rebuilt = fromGrpcError(wireError, 'SendMessage'); + expect(rebuilt).toBeInstanceOf(GenericError); + expect(isGrpcError(rebuilt)).toBe(true); + expect(rebuilt.status).toBe(grpcStatus.INTERNAL); + // method suffix appears in the fallback message for debuggability. + expect(rebuilt.message).toContain('SendMessage'); + }); +}); diff --git a/vitest.edge.config.ts b/vitest.edge.config.ts index e1162bed..02349adf 100644 --- a/vitest.edge.config.ts +++ b/vitest.edge.config.ts @@ -12,6 +12,7 @@ export default defineWorkersConfig( // gRpc test require Node.js-specific gRPC module 'test/server/grpc/*.spec.ts', 'test/client/transports/grpc_transport.spec.ts', + 'test/errors_grpc.spec.ts', // v0.3 compat gRPC tests also pull in @grpc/grpc-js and are // Node-only for the same reason as the v1.0 gRPC tests above. 'test/compat/v0_3/server/grpc/**', From b9cbbed7c7e7cc748fb8e099b174672baecfaf1e Mon Sep 17 00:00:00 2001 From: JakubWorek Date: Mon, 20 Jul 2026 09:43:19 +0000 Subject: [PATCH 05/11] refactor: modularize error handling --- docs/migration-guide.md | 35 +++++---- package.json | 18 ++++- src/client/transports/grpc/grpc_transport.ts | 2 +- .../client/transports/grpc/grpc_transport.ts | 3 +- src/compat/v0_3/server/grpc/grpc_service.ts | 7 +- src/errors/base.ts | 73 +++++++++++-------- src/errors/{grpc.ts => grpc/index.ts} | 18 ++--- src/errors/index.ts | 10 ++- src/errors/rest.ts | 4 +- src/index.ts | 5 -- src/server/grpc/grpc_service.ts | 10 +-- test/client/transports/grpc_transport.spec.ts | 2 +- test/client/util.ts | 4 +- .../transports/grpc/grpc_transport.spec.ts | 2 +- .../v0_3/server/grpc/grpc_service.spec.ts | 2 +- test/errors.spec.ts | 30 ++++---- test/errors_grpc.spec.ts | 5 +- tsup.config.ts | 2 + 18 files changed, 133 insertions(+), 99 deletions(-) rename src/errors/{grpc.ts => grpc/index.ts} (94%) diff --git a/docs/migration-guide.md b/docs/migration-guide.md index 2d0316ea..d7a32e1f 100644 --- a/docs/migration-guide.md +++ b/docs/migration-guide.md @@ -269,8 +269,10 @@ tenant-prefixed routes (`/:tenant/tasks/:taskId`, etc.) and validates the The monolithic `A2AError` class with static factory methods is removed. Errors now form a shared transport-agnostic hierarchy — one `A2AError` base with -semantic subclasses (`TaskNotFoundError`, `RequestMalformedError`, …) — and -live at the SDK root, not under `/client` or `/server`: +semantic subclasses (`TaskNotFoundError`, `RequestMalformedError`, …). They +live at `@a2a-js/sdk/errors`; gRPC-specific error helpers live at +`@a2a-js/sdk/errors/grpc` so consumers who don't use gRPC don't pull in +`@bufbuild/protobuf`. ```typescript // v0.3 @@ -279,7 +281,7 @@ throw A2AError.taskNotFound('task-1'); throw A2AError.invalidParams('bad input'); // v1.0 -import { TaskNotFoundError, RequestMalformedError } from '@a2a-js/sdk'; +import { TaskNotFoundError, RequestMalformedError } from '@a2a-js/sdk/errors'; throw new TaskNotFoundError({ message: 'task-1' }); throw new RequestMalformedError({ message: 'bad input' }); ``` @@ -296,7 +298,7 @@ Per-transport variants (`RestTaskNotFoundError`, `GrpcTaskNotFoundError`, API surfaces are on the base: ```typescript -import { isRestError, TaskNotFoundError } from '@a2a-js/sdk'; +import { isRestError, TaskNotFoundError } from '@a2a-js/sdk/errors'; try { await client.getTask({ id }); @@ -310,9 +312,16 @@ try { } ``` +For gRPC callers, the transport variant + guard live in a separate subpath: + +```typescript +import { isGrpcError, TaskNotFoundError } from '@a2a-js/sdk/errors/grpc'; +``` + Error codes and gRPC/HTTP status mappings are defined in the [spec](https://a2a-protocol.org/v1.0.0/specification/#54-error-code-mappings) -and live in a single registry (`A2A_ERROR_SPECS`) exported from `@a2a-js/sdk`. +and live in a single registry (`A2A_ERROR_SPECS`) exported from +`@a2a-js/sdk/errors`. ### 3.3 `ServerCallContext` -- Now Mandatory @@ -469,11 +478,11 @@ await verify(agentCard); ## 5. Import Path Changes -| v0.3 Import | v1.0 Import | -| ------------------------------------------------------------ | ------------------------------------------------------ | -| `import { A2AClient } from '@a2a-js/sdk/client'` | Removed -- use `ClientFactory` + `Client` | -| `import { TextPart, FilePart, DataPart } from '@a2a-js/sdk'` | Removed -- use `Part` | -| `import { MessageSendParams } from '@a2a-js/sdk'` | `import { SendMessageRequest } from '@a2a-js/sdk'` | -| `import { TaskQueryParams } from '@a2a-js/sdk'` | `import { GetTaskRequest } from '@a2a-js/sdk'` | -| `import { TaskIdParams } from '@a2a-js/sdk'` | `import { CancelTaskRequest } from '@a2a-js/sdk'` | -| `import { A2AError } from '@a2a-js/sdk/server'` | `import { TaskNotFoundError, ... } from '@a2a-js/sdk'` | +| v0.3 Import | v1.0 Import | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | +| `import { A2AClient } from '@a2a-js/sdk/client'` | Removed -- use `ClientFactory` + `Client` | +| `import { TextPart, FilePart, DataPart } from '@a2a-js/sdk'` | Removed -- use `Part` | +| `import { MessageSendParams } from '@a2a-js/sdk'` | `import { SendMessageRequest } from '@a2a-js/sdk'` | +| `import { TaskQueryParams } from '@a2a-js/sdk'` | `import { GetTaskRequest } from '@a2a-js/sdk'` | +| `import { TaskIdParams } from '@a2a-js/sdk'` | `import { CancelTaskRequest } from '@a2a-js/sdk'` | +| `import { A2AError } from '@a2a-js/sdk/server'` | `import { TaskNotFoundError, ... } from '@a2a-js/sdk/errors'` (or `@a2a-js/sdk/errors/grpc` for gRPC helpers) | diff --git a/package.json b/package.json index 1373819d..3b6ecea9 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,12 @@ "type": "module", "typesVersions": { "*": { + "errors": [ + "./dist/errors/index.d.ts" + ], + "errors/grpc": [ + "./dist/errors/grpc/index.d.ts" + ], "server": [ "./dist/server/index.d.ts" ], @@ -56,6 +62,16 @@ "import": "./dist/index.js", "require": "./dist/index.cjs" }, + "./errors": { + "types": "./dist/errors/index.d.ts", + "import": "./dist/errors/index.js", + "require": "./dist/errors/index.cjs" + }, + "./errors/grpc": { + "types": "./dist/errors/grpc/index.d.ts", + "import": "./dist/errors/grpc/index.js", + "require": "./dist/errors/grpc/index.cjs" + }, "./server": { "types": "./dist/server/index.d.ts", "import": "./dist/server/index.js", @@ -154,7 +170,7 @@ "coverage": "vitest run --coverage", "generate": "curl https://raw.githubusercontent.com/google-a2a/A2A/refs/heads/main/specification/json/a2a.json > spec.json && node scripts/generateTypes.js && rm spec.json", "generate:compat": "curl https://raw.githubusercontent.com/a2aproject/A2A/v0.3.0/specification/json/a2a.json > compat_spec.json && node scripts/generateCompatTypes.js && rm compat_spec.json", - "test-build": "esbuild ./dist/client/index.js ./dist/server/index.js ./dist/index.js ./dist/compat/v0_3/index.js ./dist/compat/v0_3/client/index.js ./dist/compat/v0_3/server/index.js --bundle --platform=neutral --outdir=dist/tmp-checks --outbase=./dist", + "test-build": "esbuild ./dist/client/index.js ./dist/server/index.js ./dist/index.js ./dist/errors/index.js ./dist/errors/grpc/index.js ./dist/compat/v0_3/index.js ./dist/compat/v0_3/client/index.js ./dist/compat/v0_3/server/index.js --bundle --platform=neutral --outdir=dist/tmp-checks --outbase=./dist", "itk-agent": "tsx itk/itk_agent.ts" }, "dependencies": { diff --git a/src/client/transports/grpc/grpc_transport.ts b/src/client/transports/grpc/grpc_transport.ts index 8baa4e4d..0501e1c7 100644 --- a/src/client/transports/grpc/grpc_transport.ts +++ b/src/client/transports/grpc/grpc_transport.ts @@ -24,7 +24,7 @@ import { RequestOptions } from '../../multitransport-client.js'; import { Transport, TransportFactory } from '../transport.js'; import { FromProto } from '../../../types/converters/from_proto.js'; -import { fromGrpcError } from '../../../errors/index.js'; +import { fromGrpcError } from '../../../errors/grpc/index.js'; import { LegacyGrpcTransport } from '../../../compat/v0_3/client/transports/grpc/index.js'; import { isLegacyVersion } from '../../../version_utils.js'; import { pickMatchingInterface } from '../pick_interface.js'; diff --git a/src/compat/v0_3/client/transports/grpc/grpc_transport.ts b/src/compat/v0_3/client/transports/grpc/grpc_transport.ts index f77d95aa..19fc6e3a 100644 --- a/src/compat/v0_3/client/transports/grpc/grpc_transport.ts +++ b/src/compat/v0_3/client/transports/grpc/grpc_transport.ts @@ -17,7 +17,8 @@ import * as grpc from '@grpc/grpc-js'; import { TransportProtocolName } from '../../../../../core.js'; -import { fromGrpcError, UnsupportedOperationError } from '../../../../../errors/index.js'; +import { UnsupportedOperationError } from '../../../../../errors/index.js'; +import { fromGrpcError } from '../../../../../errors/grpc/index.js'; import { A2A_LEGACY_PROTOCOL_VERSION } from '../../../../../constants.js'; import type { SendMessageResult } from '../../../../../index.js'; import type { RequestOptions } from '../../../../../client/multitransport-client.js'; diff --git a/src/compat/v0_3/server/grpc/grpc_service.ts b/src/compat/v0_3/server/grpc/grpc_service.ts index c72e4283..16c68cb2 100644 --- a/src/compat/v0_3/server/grpc/grpc_service.ts +++ b/src/compat/v0_3/server/grpc/grpc_service.ts @@ -39,13 +39,12 @@ import { UserBuilder } from './common.js'; import { A2A_VERSION_HEADER, HTTP_EXTENSION_HEADER } from '../../../../constants.js'; import { LEGACY_HTTP_EXTENSION_HEADER } from '../../constants.js'; import { + A2A_STATUS_CODE, A2AError, - buildGrpcErrorMetadata, - GRPC_STATUS, - grpcStatusFor, InvalidAgentResponseError, isJsonRpcError, } from '../../../../errors/index.js'; +import { buildGrpcErrorMetadata, grpcStatusFor } from '../../../../errors/grpc/index.js'; import { validateVersion } from '../../../../server/version.js'; import { FromProto } from '../../types/converters/from_proto.js'; import { ToProto } from '../../types/converters/to_proto.js'; @@ -448,7 +447,7 @@ const mapToError = (error: unknown): Partial => { } else if (error instanceof A2AError) { code = grpcStatusFor(error); } else { - code = GRPC_STATUS.UNKNOWN; + code = A2A_STATUS_CODE.UNKNOWN; } const message = error instanceof Error ? error.message : 'Internal server error'; const result: Partial = { code, details: message }; diff --git a/src/errors/base.ts b/src/errors/base.ts index 1679cc97..6e7ac111 100644 --- a/src/errors/base.ts +++ b/src/errors/base.ts @@ -45,8 +45,14 @@ export const HTTP_STATUS = { NOT_IMPLEMENTED: 501, } as const; -/** gRPC status codes referenced by the registry. Mirrors `@grpc/grpc-js`'s `status` enum. */ -export const GRPC_STATUS = { +/** + * Canonical numeric status codes used by the semantic-error registry + * to describe the spec-mapped status per §5.4. The numeric namespace + * matches gRPC's `status` enum (so the gRPC transport can emit them + * directly) and the string form (see {@link A2A_STATUS_NAME}) fills + * the REST body's `status` field per §11.6. + */ +export const A2A_STATUS_CODE = { OK: 0, CANCELLED: 1, UNKNOWN: 2, @@ -66,25 +72,28 @@ export const GRPC_STATUS = { UNAUTHENTICATED: 16, } as const; -/** Names of the gRPC status codes, for `google.rpc.Status.status`. */ -export const GRPC_STATUS_NAME: Readonly> = { - [GRPC_STATUS.OK]: 'OK', - [GRPC_STATUS.CANCELLED]: 'CANCELLED', - [GRPC_STATUS.UNKNOWN]: 'UNKNOWN', - [GRPC_STATUS.INVALID_ARGUMENT]: 'INVALID_ARGUMENT', - [GRPC_STATUS.DEADLINE_EXCEEDED]: 'DEADLINE_EXCEEDED', - [GRPC_STATUS.NOT_FOUND]: 'NOT_FOUND', - [GRPC_STATUS.ALREADY_EXISTS]: 'ALREADY_EXISTS', - [GRPC_STATUS.PERMISSION_DENIED]: 'PERMISSION_DENIED', - [GRPC_STATUS.RESOURCE_EXHAUSTED]: 'RESOURCE_EXHAUSTED', - [GRPC_STATUS.FAILED_PRECONDITION]: 'FAILED_PRECONDITION', - [GRPC_STATUS.ABORTED]: 'ABORTED', - [GRPC_STATUS.OUT_OF_RANGE]: 'OUT_OF_RANGE', - [GRPC_STATUS.UNIMPLEMENTED]: 'UNIMPLEMENTED', - [GRPC_STATUS.INTERNAL]: 'INTERNAL', - [GRPC_STATUS.UNAVAILABLE]: 'UNAVAILABLE', - [GRPC_STATUS.DATA_LOSS]: 'DATA_LOSS', - [GRPC_STATUS.UNAUTHENTICATED]: 'UNAUTHENTICATED', +/** + * String form of {@link A2A_STATUS_CODE}, used in the REST body's + * `status` field per §11.6. Names follow the gRPC enum form. + */ +export const A2A_STATUS_NAME: Readonly> = { + [A2A_STATUS_CODE.OK]: 'OK', + [A2A_STATUS_CODE.CANCELLED]: 'CANCELLED', + [A2A_STATUS_CODE.UNKNOWN]: 'UNKNOWN', + [A2A_STATUS_CODE.INVALID_ARGUMENT]: 'INVALID_ARGUMENT', + [A2A_STATUS_CODE.DEADLINE_EXCEEDED]: 'DEADLINE_EXCEEDED', + [A2A_STATUS_CODE.NOT_FOUND]: 'NOT_FOUND', + [A2A_STATUS_CODE.ALREADY_EXISTS]: 'ALREADY_EXISTS', + [A2A_STATUS_CODE.PERMISSION_DENIED]: 'PERMISSION_DENIED', + [A2A_STATUS_CODE.RESOURCE_EXHAUSTED]: 'RESOURCE_EXHAUSTED', + [A2A_STATUS_CODE.FAILED_PRECONDITION]: 'FAILED_PRECONDITION', + [A2A_STATUS_CODE.ABORTED]: 'ABORTED', + [A2A_STATUS_CODE.OUT_OF_RANGE]: 'OUT_OF_RANGE', + [A2A_STATUS_CODE.UNIMPLEMENTED]: 'UNIMPLEMENTED', + [A2A_STATUS_CODE.INTERNAL]: 'INTERNAL', + [A2A_STATUS_CODE.UNAVAILABLE]: 'UNAVAILABLE', + [A2A_STATUS_CODE.DATA_LOSS]: 'DATA_LOSS', + [A2A_STATUS_CODE.UNAUTHENTICATED]: 'UNAUTHENTICATED', }; /** A structured detail object included in error responses. */ @@ -177,7 +186,7 @@ const specs: A2AErrorSpec[] = [ name: 'TaskNotFoundError', reason: 'TASK_NOT_FOUND', code: A2A_ERROR_CODE.TASK_NOT_FOUND, - grpcStatus: GRPC_STATUS.NOT_FOUND, + grpcStatus: A2A_STATUS_CODE.NOT_FOUND, httpStatus: HTTP_STATUS.NOT_FOUND, defaultMessage: 'Task not found', }, @@ -185,7 +194,7 @@ const specs: A2AErrorSpec[] = [ name: 'TaskNotCancelableError', reason: 'TASK_NOT_CANCELABLE', code: A2A_ERROR_CODE.TASK_NOT_CANCELABLE, - grpcStatus: GRPC_STATUS.FAILED_PRECONDITION, + grpcStatus: A2A_STATUS_CODE.FAILED_PRECONDITION, httpStatus: HTTP_STATUS.BAD_REQUEST, defaultMessage: 'Task cannot be canceled', }, @@ -193,7 +202,7 @@ const specs: A2AErrorSpec[] = [ name: 'PushNotificationNotSupportedError', reason: 'PUSH_NOTIFICATION_NOT_SUPPORTED', code: A2A_ERROR_CODE.PUSH_NOTIFICATION_NOT_SUPPORTED, - grpcStatus: GRPC_STATUS.FAILED_PRECONDITION, + grpcStatus: A2A_STATUS_CODE.FAILED_PRECONDITION, httpStatus: HTTP_STATUS.BAD_REQUEST, defaultMessage: 'Push Notification is not supported', }, @@ -201,7 +210,7 @@ const specs: A2AErrorSpec[] = [ name: 'UnsupportedOperationError', reason: 'UNSUPPORTED_OPERATION', code: A2A_ERROR_CODE.UNSUPPORTED_OPERATION, - grpcStatus: GRPC_STATUS.FAILED_PRECONDITION, + grpcStatus: A2A_STATUS_CODE.FAILED_PRECONDITION, httpStatus: HTTP_STATUS.BAD_REQUEST, defaultMessage: 'This operation is not supported', }, @@ -209,7 +218,7 @@ const specs: A2AErrorSpec[] = [ name: 'ContentTypeNotSupportedError', reason: 'CONTENT_TYPE_NOT_SUPPORTED', code: A2A_ERROR_CODE.CONTENT_TYPE_NOT_SUPPORTED, - grpcStatus: GRPC_STATUS.INVALID_ARGUMENT, + grpcStatus: A2A_STATUS_CODE.INVALID_ARGUMENT, httpStatus: HTTP_STATUS.BAD_REQUEST, defaultMessage: 'Incompatible content types', }, @@ -217,7 +226,7 @@ const specs: A2AErrorSpec[] = [ name: 'InvalidAgentResponseError', reason: 'INVALID_AGENT_RESPONSE', code: A2A_ERROR_CODE.INVALID_AGENT_RESPONSE, - grpcStatus: GRPC_STATUS.INTERNAL, + grpcStatus: A2A_STATUS_CODE.INTERNAL, httpStatus: HTTP_STATUS.INTERNAL_SERVER_ERROR, defaultMessage: 'Invalid agent response type', }, @@ -225,7 +234,7 @@ const specs: A2AErrorSpec[] = [ name: 'ExtendedAgentCardNotConfiguredError', reason: 'EXTENDED_AGENT_CARD_NOT_CONFIGURED', code: A2A_ERROR_CODE.EXTENDED_CARD_NOT_CONFIGURED, - grpcStatus: GRPC_STATUS.FAILED_PRECONDITION, + grpcStatus: A2A_STATUS_CODE.FAILED_PRECONDITION, httpStatus: HTTP_STATUS.BAD_REQUEST, defaultMessage: 'Extended Agent Card not configured', }, @@ -233,7 +242,7 @@ const specs: A2AErrorSpec[] = [ name: 'ExtensionSupportRequiredError', reason: 'EXTENSION_SUPPORT_REQUIRED', code: A2A_ERROR_CODE.EXTENSION_SUPPORT_REQUIRED, - grpcStatus: GRPC_STATUS.FAILED_PRECONDITION, + grpcStatus: A2A_STATUS_CODE.FAILED_PRECONDITION, httpStatus: HTTP_STATUS.BAD_REQUEST, defaultMessage: 'Extension support required', }, @@ -241,7 +250,7 @@ const specs: A2AErrorSpec[] = [ name: 'VersionNotSupportedError', reason: 'VERSION_NOT_SUPPORTED', code: A2A_ERROR_CODE.VERSION_NOT_SUPPORTED, - grpcStatus: GRPC_STATUS.FAILED_PRECONDITION, + grpcStatus: A2A_STATUS_CODE.FAILED_PRECONDITION, httpStatus: HTTP_STATUS.BAD_REQUEST, defaultMessage: 'Version not supported', }, @@ -249,7 +258,7 @@ const specs: A2AErrorSpec[] = [ name: 'RequestMalformedError', reason: 'INVALID_PARAMS', code: A2A_ERROR_CODE.INVALID_PARAMS, - grpcStatus: GRPC_STATUS.INVALID_ARGUMENT, + grpcStatus: A2A_STATUS_CODE.INVALID_ARGUMENT, httpStatus: HTTP_STATUS.BAD_REQUEST, defaultMessage: 'Request malformed', }, @@ -257,7 +266,7 @@ const specs: A2AErrorSpec[] = [ name: 'GenericError', reason: 'INTERNAL_ERROR', code: A2A_ERROR_CODE.INTERNAL_ERROR, - grpcStatus: GRPC_STATUS.INTERNAL, + grpcStatus: A2A_STATUS_CODE.INTERNAL, httpStatus: HTTP_STATUS.INTERNAL_SERVER_ERROR, defaultMessage: 'An unexpected error occurred.', }, diff --git a/src/errors/grpc.ts b/src/errors/grpc/index.ts similarity index 94% rename from src/errors/grpc.ts rename to src/errors/grpc/index.ts index d8ca4da2..17af10db 100644 --- a/src/errors/grpc.ts +++ b/src/errors/grpc/index.ts @@ -13,14 +13,14 @@ import { A2A_ERROR_DOMAIN, A2A_ERROR_SPECS, A2A_ERROR_SPECS_BY_REASON, + A2A_STATUS_CODE, A2AError, type A2AErrorOptions, ERROR_INFO_TYPE, - GRPC_STATUS, -} from './base.js'; -import { Any } from '../grpc/pb/google/protobuf/any.js'; -import { ErrorInfo } from '../grpc/pb/google/rpc/error_details.js'; -import { Status } from '../grpc/pb/google/rpc/status.js'; +} from '../base.js'; +import { Any } from '../../grpc/pb/google/protobuf/any.js'; +import { ErrorInfo } from '../../grpc/pb/google/rpc/error_details.js'; +import { Status } from '../../grpc/pb/google/rpc/status.js'; /** Trailing metadata key for `google.rpc.Status`. */ export const GRPC_STATUS_DETAILS_BIN = 'grpc-status-details-bin'; @@ -129,8 +129,8 @@ export const GRPC_ERROR_CLASSES: Readonly< export function grpcStatusFor(error: unknown): number { if (isGrpcError(error)) return error.status; if (error instanceof A2AError) - return A2A_ERROR_SPECS[error.name]?.grpcStatus ?? GRPC_STATUS.UNKNOWN; - return GRPC_STATUS.UNKNOWN; + return A2A_ERROR_SPECS[error.name]?.grpcStatus ?? A2A_STATUS_CODE.UNKNOWN; + return A2A_STATUS_CODE.UNKNOWN; } /** Encodes a `google.rpc.Status` + `ErrorInfo` blob for `grpc-status-details-bin`. */ @@ -229,9 +229,9 @@ export function fromGrpcError(error: grpc.ServiceError, method?: string): GrpcA2 const suffix = method ? ` for ${method}` : ''; return new GrpcGenericError({ message: - `gRPC error${suffix}: ${error.code ?? GRPC_STATUS.UNKNOWN} ${error.details ?? ''}`.trim(), + `gRPC error${suffix}: ${error.code ?? A2A_STATUS_CODE.UNKNOWN} ${error.details ?? ''}`.trim(), cause: error, - status: error.code ?? GRPC_STATUS.UNKNOWN, + status: error.code ?? A2A_STATUS_CODE.UNKNOWN, statusDetailsBin, }); } diff --git a/src/errors/index.ts b/src/errors/index.ts index bce38aac..23ca66af 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -1,6 +1,12 @@ -/** Barrel for the transport-agnostic A2A error hierarchy. */ +/** + * Transport-agnostic A2A error hierarchy: base class, semantic + * subclasses, plus the REST and JSON-RPC transport variants (both + * Workers-safe, no pb runtime dependency). + * + * gRPC errors live at `@a2a-js/sdk/errors/grpc` because their + * encode/decode helpers pull `@bufbuild/protobuf`. + */ export * from './base.js'; export * from './rest.js'; -export * from './grpc.js'; export * from './json_rpc.js'; diff --git a/src/errors/rest.ts b/src/errors/rest.ts index a0b373fb..b0dded45 100644 --- a/src/errors/rest.ts +++ b/src/errors/rest.ts @@ -15,7 +15,7 @@ import { type A2AErrorOptions, ERROR_INFO_TYPE, type ErrorDetail, - GRPC_STATUS_NAME, + A2A_STATUS_NAME, HTTP_STATUS, type RestErrorBody, } from './base.js'; @@ -150,7 +150,7 @@ export function toRestErrorBody(error: unknown, httpStatus: number): RestErrorBo if (error instanceof A2AError) { details.push(error.toErrorInfo()); const spec = A2A_ERROR_SPECS[error.name]; - statusName = spec ? (GRPC_STATUS_NAME[spec.grpcStatus] ?? 'UNKNOWN') : 'UNKNOWN'; + statusName = spec ? (A2A_STATUS_NAME[spec.grpcStatus] ?? 'UNKNOWN') : 'UNKNOWN'; } else if (httpStatus === HTTP_STATUS.NOT_FOUND) statusName = 'NOT_FOUND'; else if (httpStatus === HTTP_STATUS.INTERNAL_SERVER_ERROR) statusName = 'INTERNAL'; else if (httpStatus === HTTP_STATUS.BAD_REQUEST) statusName = 'INVALID_ARGUMENT'; diff --git a/src/index.ts b/src/index.ts index 0a53450b..e80ae3f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,8 +33,3 @@ export { parseSseStream, type SseEvent, } from './sse_utils.js'; - -// Transport-agnostic error hierarchy: base class, semantic subclasses, -// per-transport variants (Rest*/Grpc*/JsonRpc*), and `is*Error` type -// guards. -export * from './errors/index.js'; diff --git a/src/server/grpc/grpc_service.ts b/src/server/grpc/grpc_service.ts index 5b7245fa..9ce2ed72 100644 --- a/src/server/grpc/grpc_service.ts +++ b/src/server/grpc/grpc_service.ts @@ -29,12 +29,8 @@ import { import { Extensions } from '../../extensions.js'; import { UserBuilder } from './common.js'; import { A2A_VERSION_HEADER, HTTP_EXTENSION_HEADER } from '../../constants.js'; -import { - A2AError, - buildGrpcErrorMetadata, - GRPC_STATUS, - grpcStatusFor, -} from '../../errors/index.js'; +import { A2A_STATUS_CODE, A2AError } from '../../errors/index.js'; +import { buildGrpcErrorMetadata, grpcStatusFor } from '../../errors/grpc/index.js'; import { validateVersion } from '../version.js'; /** Options for configuring the gRPC handler. */ @@ -222,7 +218,7 @@ export function grpcService(options: GrpcServiceOptions): A2AServiceServer { * (`grpcStatusFor`), so user-defined subclasses inherit the base status. */ const mapToError = (error: unknown): Partial => { - const code = error instanceof A2AError ? grpcStatusFor(error) : GRPC_STATUS.UNKNOWN; + const code = error instanceof A2AError ? grpcStatusFor(error) : A2A_STATUS_CODE.UNKNOWN; const message = error instanceof Error ? error.message : 'Internal server error'; const result: Partial = { code, details: message }; const md = buildGrpcErrorMetadata(grpc.Metadata, error); diff --git a/test/client/transports/grpc_transport.spec.ts b/test/client/transports/grpc_transport.spec.ts index 587810e0..85e844ec 100644 --- a/test/client/transports/grpc_transport.spec.ts +++ b/test/client/transports/grpc_transport.spec.ts @@ -10,7 +10,7 @@ import { TaskNotCancelableError, PushNotificationNotSupportedError, } from '../../../src/errors/index.js'; -import { buildGrpcErrorMetadata } from '../../../src/errors/index.js'; +import { buildGrpcErrorMetadata } from '../../../src/errors/grpc/index.js'; import { createMessageParams, createMockAgentCard, diff --git a/test/client/util.ts b/test/client/util.ts index 5de3b97b..a941bffd 100644 --- a/test/client/util.ts +++ b/test/client/util.ts @@ -6,7 +6,7 @@ import { A2A_ERROR_DOMAIN, A2A_ERROR_SPECS_BY_CODE, ERROR_INFO_TYPE, - GRPC_STATUS_NAME, + A2A_STATUS_NAME, } from '../../src/errors/index.js'; export function extractRequestId(options?: RequestInit): number { @@ -358,7 +358,7 @@ export function createRestResponse( function resolveErrorCode(code: number): { reason: string; grpcStatus: string } | undefined { const spec = A2A_ERROR_SPECS_BY_CODE[code]; if (!spec) return undefined; - return { reason: spec.reason, grpcStatus: GRPC_STATUS_NAME[spec.grpcStatus] ?? 'UNKNOWN' }; + return { reason: spec.reason, grpcStatus: A2A_STATUS_NAME[spec.grpcStatus] ?? 'UNKNOWN' }; } // Creates a REST error response in the google.rpc.Status JSON format. diff --git a/test/compat/v0_3/client/transports/grpc/grpc_transport.spec.ts b/test/compat/v0_3/client/transports/grpc/grpc_transport.spec.ts index 47fb387a..c75fe0ca 100644 --- a/test/compat/v0_3/client/transports/grpc/grpc_transport.spec.ts +++ b/test/compat/v0_3/client/transports/grpc/grpc_transport.spec.ts @@ -9,7 +9,7 @@ import { TaskNotFoundError, UnsupportedOperationError, } from '../../../../../../src/errors/index.js'; -import { buildGrpcErrorMetadata } from '../../../../../../src/errors/index.js'; +import { buildGrpcErrorMetadata } from '../../../../../../src/errors/grpc/index.js'; import { Role as V1Role, TaskState as V1TaskState, diff --git a/test/compat/v0_3/server/grpc/grpc_service.spec.ts b/test/compat/v0_3/server/grpc/grpc_service.spec.ts index 435439c8..460e6c5e 100644 --- a/test/compat/v0_3/server/grpc/grpc_service.spec.ts +++ b/test/compat/v0_3/server/grpc/grpc_service.spec.ts @@ -15,7 +15,7 @@ import { type AgentCard as V1AgentCard, type Task as V1Task, } from '../../../../../src/types/pb/a2a.js'; -import { decodeErrorInfo, decodeStatus } from '../../../../../src/errors/index.js'; +import { decodeErrorInfo, decodeStatus } from '../../../../../src/errors/grpc/index.js'; // v0.3 GRPC interface so validateVersion accepts the defaulted '0.3'. const testAgentCard: V1AgentCard = { diff --git a/test/errors.spec.ts b/test/errors.spec.ts index d873159c..f4322966 100644 --- a/test/errors.spec.ts +++ b/test/errors.spec.ts @@ -15,12 +15,9 @@ import { fromJsonRpcErrorResponse as mapJsonRpcErrorToSdkError, fromRestErrorBody, GenericError, - GRPC_STATUS, - GrpcTaskNotFoundError, - grpcStatusFor, + A2A_STATUS_CODE, HTTP_STATUS, InvalidAgentResponseError, - isGrpcError, isJsonRpcError, isRestError, JsonRpcRequestMalformedError, @@ -37,6 +34,7 @@ import { UnsupportedOperationError, VersionNotSupportedError, } from '../src/errors/index.js'; +import { GrpcTaskNotFoundError, grpcStatusFor, isGrpcError } from '../src/errors/grpc/index.js'; /** Thin wrapper that matches the removed `mapA2aErrorToSdkError` shape. */ function mapA2aErrorToSdkError( @@ -206,7 +204,7 @@ describe('A2AError hierarchy', () => { }); it('GrpcTaskNotFoundError is-a TaskNotFoundError is-a A2AError', () => { - const e = new GrpcTaskNotFoundError({ status: GRPC_STATUS.NOT_FOUND }); + const e = new GrpcTaskNotFoundError({ status: A2A_STATUS_CODE.NOT_FOUND }); expect(e).toBeInstanceOf(GrpcTaskNotFoundError); expect(e).toBeInstanceOf(TaskNotFoundError); expect(e).toBeInstanceOf(A2AError); @@ -242,7 +240,9 @@ describe('transport type guards', () => { }); it('isGrpcError narrows only GrpcA2AError instances', () => { - expect(isGrpcError(new GrpcTaskNotFoundError({ status: GRPC_STATUS.NOT_FOUND }))).toBe(true); + expect(isGrpcError(new GrpcTaskNotFoundError({ status: A2A_STATUS_CODE.NOT_FOUND }))).toBe( + true + ); expect(isGrpcError(new RestTaskNotFoundError())).toBe(false); expect(isGrpcError(new JsonRpcTaskNotFoundError())).toBe(false); expect(isGrpcError(new TaskNotFoundError())).toBe(false); @@ -259,7 +259,7 @@ describe('transport type guards', () => { it('transports are mutually exclusive: exactly one guard matches per instance', () => { const rest = new RestTaskNotFoundError({ statusCode: 404 }); - const grpc = new GrpcTaskNotFoundError({ status: GRPC_STATUS.NOT_FOUND }); + const grpc = new GrpcTaskNotFoundError({ status: A2A_STATUS_CODE.NOT_FOUND }); const json = new JsonRpcTaskNotFoundError({ envelopeCode: -32001 }); expect([isRestError(rest), isGrpcError(rest), isJsonRpcError(rest)]).toEqual([ @@ -383,20 +383,22 @@ describe('restStatusFor', () => { describe('grpcStatusFor', () => { it('returns the instance-level status when it is a GrpcA2AError', () => { - expect(grpcStatusFor(new GrpcTaskNotFoundError({ status: GRPC_STATUS.CANCELLED }))).toBe( - GRPC_STATUS.CANCELLED + expect(grpcStatusFor(new GrpcTaskNotFoundError({ status: A2A_STATUS_CODE.CANCELLED }))).toBe( + A2A_STATUS_CODE.CANCELLED ); }); it('falls back to the spec grpcStatus for a plain semantic error', () => { - expect(grpcStatusFor(new TaskNotFoundError())).toBe(GRPC_STATUS.NOT_FOUND); - expect(grpcStatusFor(new ContentTypeNotSupportedError())).toBe(GRPC_STATUS.INVALID_ARGUMENT); - expect(grpcStatusFor(new InvalidAgentResponseError())).toBe(GRPC_STATUS.INTERNAL); + expect(grpcStatusFor(new TaskNotFoundError())).toBe(A2A_STATUS_CODE.NOT_FOUND); + expect(grpcStatusFor(new ContentTypeNotSupportedError())).toBe( + A2A_STATUS_CODE.INVALID_ARGUMENT + ); + expect(grpcStatusFor(new InvalidAgentResponseError())).toBe(A2A_STATUS_CODE.INTERNAL); }); it('returns UNKNOWN for non-A2A throwables', () => { - expect(grpcStatusFor(new Error('unrelated'))).toBe(GRPC_STATUS.UNKNOWN); - expect(grpcStatusFor(null)).toBe(GRPC_STATUS.UNKNOWN); + expect(grpcStatusFor(new Error('unrelated'))).toBe(A2A_STATUS_CODE.UNKNOWN); + expect(grpcStatusFor(null)).toBe(A2A_STATUS_CODE.UNKNOWN); }); }); diff --git a/test/errors_grpc.spec.ts b/test/errors_grpc.spec.ts index 458e990a..c104ff87 100644 --- a/test/errors_grpc.spec.ts +++ b/test/errors_grpc.spec.ts @@ -7,14 +7,13 @@ import { Metadata, status as grpcStatus, type ServiceError } from '@grpc/grpc-js'; import { describe, it, expect } from 'vitest'; +import { GenericError, TaskNotFoundError } from '../src/errors/index.js'; import { buildGrpcErrorMetadata, fromGrpcError, - GenericError, GrpcTaskNotFoundError, isGrpcError, - TaskNotFoundError, -} from '../src/errors/index.js'; +} from '../src/errors/grpc/index.js'; describe('gRPC roundtrip', () => { it('semantic error survives buildGrpcErrorMetadata -> fromGrpcError', () => { diff --git a/tsup.config.ts b/tsup.config.ts index 58f04556..6899d43e 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -3,6 +3,8 @@ import { defineConfig } from 'tsup'; export default defineConfig({ entry: [ 'src/index.ts', + 'src/errors/index.ts', + 'src/errors/grpc/index.ts', 'src/server/index.ts', 'src/server/express/index.ts', 'src/server/grpc/index.ts', From ebd52aa4f1812ca4fc592b0e36618e58d98ad2d1 Mon Sep 17 00:00:00 2001 From: JakubWorek Date: Mon, 20 Jul 2026 10:35:23 +0000 Subject: [PATCH 06/11] feat: implement Workers-safe dependency validation --- package.json | 2 +- scripts/checkWorkersSafeBundles.js | 48 ++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 scripts/checkWorkersSafeBundles.js diff --git a/package.json b/package.json index 3b6ecea9..92dbd3d0 100644 --- a/package.json +++ b/package.json @@ -170,7 +170,7 @@ "coverage": "vitest run --coverage", "generate": "curl https://raw.githubusercontent.com/google-a2a/A2A/refs/heads/main/specification/json/a2a.json > spec.json && node scripts/generateTypes.js && rm spec.json", "generate:compat": "curl https://raw.githubusercontent.com/a2aproject/A2A/v0.3.0/specification/json/a2a.json > compat_spec.json && node scripts/generateCompatTypes.js && rm compat_spec.json", - "test-build": "esbuild ./dist/client/index.js ./dist/server/index.js ./dist/index.js ./dist/errors/index.js ./dist/errors/grpc/index.js ./dist/compat/v0_3/index.js ./dist/compat/v0_3/client/index.js ./dist/compat/v0_3/server/index.js --bundle --platform=neutral --outdir=dist/tmp-checks --outbase=./dist", + "test-build": "esbuild ./dist/client/index.js ./dist/server/index.js ./dist/index.js ./dist/errors/index.js ./dist/errors/grpc/index.js ./dist/compat/v0_3/index.js ./dist/compat/v0_3/client/index.js ./dist/compat/v0_3/server/index.js --bundle --platform=neutral --outdir=dist/tmp-checks --outbase=./dist && node scripts/checkWorkersSafeBundles.js", "itk-agent": "tsx itk/itk_agent.ts" }, "dependencies": { diff --git a/scripts/checkWorkersSafeBundles.js b/scripts/checkWorkersSafeBundles.js new file mode 100644 index 00000000..0de5d183 --- /dev/null +++ b/scripts/checkWorkersSafeBundles.js @@ -0,0 +1,48 @@ +// Verifies that entrypoints declared Workers-safe never pull in +// Node-only peer deps (`@grpc/grpc-js`) or the pb-generated code +// path (`@bufbuild/protobuf`). Any hit fails the build. +// +// Run after `npm run build && npm run test-build`. Consumed by the +// `test-build` script and the `Run Build Tests` CI workflow. + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +// Bundles that MUST NOT reference gRPC or pb at all. +const WORKERS_SAFE_BUNDLES = [ + 'dist/tmp-checks/index.js', + 'dist/tmp-checks/errors/index.js', + 'dist/tmp-checks/client/index.js', + 'dist/tmp-checks/server/index.js', + 'dist/tmp-checks/compat/v0_3/index.js', + 'dist/tmp-checks/compat/v0_3/client/index.js', + 'dist/tmp-checks/compat/v0_3/server/index.js', +]; + +const FORBIDDEN = ['@grpc/grpc-js', '@bufbuild/protobuf']; + +let failed = false; +for (const rel of WORKERS_SAFE_BUNDLES) { + const abs = resolve(rel); + let content; + try { + content = readFileSync(abs, 'utf8'); + } catch (e) { + console.error( + `FAIL: cannot read ${rel} — did you run \`npm run build && esbuild\`? (${e.message})` + ); + failed = true; + continue; + } + const hits = FORBIDDEN.filter((needle) => content.includes(needle)); + if (hits.length > 0) { + console.error( + `FAIL: ${rel} contains ${hits.join(', ')} — Workers-safe entrypoint must not pull Node-only deps` + ); + failed = true; + } else { + console.log(`ok: ${rel} — no Node-only deps`); + } +} + +if (failed) process.exit(1); From 65be198e93c5a8029f651ddd93f6b7d9f60c10e6 Mon Sep 17 00:00:00 2001 From: JakubWorek Date: Mon, 20 Jul 2026 12:28:57 +0000 Subject: [PATCH 07/11] refactor: robust peer dependency isolation tests --- .github/workflows/build-tests.yml | 38 +++++++++++++++-------- package.json | 4 ++- scripts/checkWorkersSafeBundles.js | 48 ------------------------------ tsup.config.ts | 1 + 4 files changed, 29 insertions(+), 62 deletions(-) delete mode 100644 scripts/checkWorkersSafeBundles.js diff --git a/.github/workflows/build-tests.yml b/.github/workflows/build-tests.yml index 01a22d70..bcdb349c 100644 --- a/.github/workflows/build-tests.yml +++ b/.github/workflows/build-tests.yml @@ -1,12 +1,18 @@ -# Ensures no accidential Node.js API usage in browser and Edge compatible entrypoints. +# Ensures no accidential Node.js API usage in browser and Edge compatible +# entrypoints, and that gRPC-only helpers stay isolated from the pb-free +# entrypoints. The two `test-build:*` steps run in DIFFERENT environments: +# 1. workers-safe: no @grpc/grpc-js, no @bufbuild/protobuf installed. +# esbuild fails natively if any Workers-safe entry pulls them in. +# Mirrors what a real Workers / REST-only consumer gets. +# 2. grpc: pb + grpc-js reinstalled; the gRPC entrypoint must resolve. name: Run Build Tests on: push: - branches: [ "main", "epic/**" ] + branches: ['main', 'epic/**'] pull_request: - branches: [ "main", "epic/**" ] + branches: ['main', 'epic/**'] paths-ignore: - '**.md' - 'LICENSE' @@ -15,16 +21,22 @@ on: jobs: test: - runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - registry-url: 'https://registry.npmjs.org' - cache: 'npm' - - run: npm ci - - run: npm run build - - run: npm run test-build + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: 'https://registry.npmjs.org' + cache: 'npm' + - run: npm ci + - run: npm run build + # Strip the optional peer deps so esbuild sees the same module graph + # a non-gRPC consumer would. Any Workers-safe entrypoint that leaks + # a gRPC / pb import fails to resolve here. + - run: npm uninstall --no-save @grpc/grpc-js @bufbuild/protobuf + - run: npm run test-build:workers-safe + # Reinstall the peer deps and verify the gRPC entrypoint still bundles. + - run: npm install --no-save @grpc/grpc-js @bufbuild/protobuf + - run: npm run test-build:grpc diff --git a/package.json b/package.json index 92dbd3d0..3a761ed4 100644 --- a/package.json +++ b/package.json @@ -170,7 +170,9 @@ "coverage": "vitest run --coverage", "generate": "curl https://raw.githubusercontent.com/google-a2a/A2A/refs/heads/main/specification/json/a2a.json > spec.json && node scripts/generateTypes.js && rm spec.json", "generate:compat": "curl https://raw.githubusercontent.com/a2aproject/A2A/v0.3.0/specification/json/a2a.json > compat_spec.json && node scripts/generateCompatTypes.js && rm compat_spec.json", - "test-build": "esbuild ./dist/client/index.js ./dist/server/index.js ./dist/index.js ./dist/errors/index.js ./dist/errors/grpc/index.js ./dist/compat/v0_3/index.js ./dist/compat/v0_3/client/index.js ./dist/compat/v0_3/server/index.js --bundle --platform=neutral --outdir=dist/tmp-checks --outbase=./dist && node scripts/checkWorkersSafeBundles.js", + "test-build:workers-safe": "esbuild ./dist/index.js ./dist/errors/index.js ./dist/client/index.js ./dist/server/index.js ./dist/compat/v0_3/index.js ./dist/compat/v0_3/client/index.js ./dist/compat/v0_3/server/index.js --bundle --platform=neutral --outdir=dist/tmp-checks --outbase=./dist", + "test-build:grpc": "esbuild ./dist/errors/grpc/index.js --bundle --platform=neutral --outdir=dist/tmp-checks --outbase=./dist", + "test-build": "npm run test-build:workers-safe && npm run test-build:grpc", "itk-agent": "tsx itk/itk_agent.ts" }, "dependencies": { diff --git a/scripts/checkWorkersSafeBundles.js b/scripts/checkWorkersSafeBundles.js deleted file mode 100644 index 0de5d183..00000000 --- a/scripts/checkWorkersSafeBundles.js +++ /dev/null @@ -1,48 +0,0 @@ -// Verifies that entrypoints declared Workers-safe never pull in -// Node-only peer deps (`@grpc/grpc-js`) or the pb-generated code -// path (`@bufbuild/protobuf`). Any hit fails the build. -// -// Run after `npm run build && npm run test-build`. Consumed by the -// `test-build` script and the `Run Build Tests` CI workflow. - -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; - -// Bundles that MUST NOT reference gRPC or pb at all. -const WORKERS_SAFE_BUNDLES = [ - 'dist/tmp-checks/index.js', - 'dist/tmp-checks/errors/index.js', - 'dist/tmp-checks/client/index.js', - 'dist/tmp-checks/server/index.js', - 'dist/tmp-checks/compat/v0_3/index.js', - 'dist/tmp-checks/compat/v0_3/client/index.js', - 'dist/tmp-checks/compat/v0_3/server/index.js', -]; - -const FORBIDDEN = ['@grpc/grpc-js', '@bufbuild/protobuf']; - -let failed = false; -for (const rel of WORKERS_SAFE_BUNDLES) { - const abs = resolve(rel); - let content; - try { - content = readFileSync(abs, 'utf8'); - } catch (e) { - console.error( - `FAIL: cannot read ${rel} — did you run \`npm run build && esbuild\`? (${e.message})` - ); - failed = true; - continue; - } - const hits = FORBIDDEN.filter((needle) => content.includes(needle)); - if (hits.length > 0) { - console.error( - `FAIL: ${rel} contains ${hits.join(', ')} — Workers-safe entrypoint must not pull Node-only deps` - ); - failed = true; - } else { - console.log(`ok: ${rel} — no Node-only deps`); - } -} - -if (failed) process.exit(1); diff --git a/tsup.config.ts b/tsup.config.ts index 6899d43e..a1a9f047 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -21,4 +21,5 @@ export default defineConfig({ format: ['esm', 'cjs'], dts: true, clean: true, + splitting: false, }); From 2b1308a6408158a63f2dabf5ecde917e16089589 Mon Sep 17 00:00:00 2001 From: JakubWorek Date: Tue, 21 Jul 2026 08:47:15 +0000 Subject: [PATCH 08/11] refactor: replace GenericError with A2AError and unify error hierarchy across transports --- .github/workflows/build-tests.yml | 21 +- README.md | 2 +- docs/migration-guide.md | 4 +- .../v0_3/client/transports/rest_transport.ts | 6 +- src/compat/v0_3/server/error.ts | 57 +++-- src/compat/v0_3/server/grpc/grpc_service.ts | 14 +- src/compat/v0_3/translate/errors.ts | 6 +- src/errors/base.ts | 214 +++--------------- src/errors/grpc/index.ts | 91 ++++++-- src/errors/index.ts | 86 ++++++- src/errors/json_rpc.ts | 90 +++++--- src/errors/rest.ts | 144 ++++++++++-- src/server/grpc/grpc_service.ts | 10 +- .../default_request_handler.ts | 10 +- src/types/converters/from_proto.ts | 4 +- src/types/converters/to_proto.ts | 4 +- test/client/util.ts | 10 +- test/compat/v0_3/constants.spec.ts | 13 +- .../jsonrpc/jsonrpc_transport_handler.spec.ts | 4 +- .../rest/rest_transport_handler.spec.ts | 20 +- test/compat/v0_3/translate/errors.spec.ts | 4 +- test/compat/v0_3/translate/messages.spec.ts | 3 +- test/errors.spec.ts | 68 +++--- test/errors_grpc.spec.ts | 8 +- test/server/express/express_app.spec.ts | 6 +- test/server/express/rest_handler.spec.ts | 6 +- test/server/grpc/from_proto.spec.ts | 18 +- test/server/jsonrpc_transport_handler.spec.ts | 18 +- 28 files changed, 545 insertions(+), 396 deletions(-) diff --git a/.github/workflows/build-tests.yml b/.github/workflows/build-tests.yml index bcdb349c..f5ab6793 100644 --- a/.github/workflows/build-tests.yml +++ b/.github/workflows/build-tests.yml @@ -1,10 +1,11 @@ -# Ensures no accidential Node.js API usage in browser and Edge compatible +# Ensures no accidental Node.js API usage in browser and Edge compatible # entrypoints, and that gRPC-only helpers stay isolated from the pb-free # entrypoints. The two `test-build:*` steps run in DIFFERENT environments: -# 1. workers-safe: no @grpc/grpc-js, no @bufbuild/protobuf installed. -# esbuild fails natively if any Workers-safe entry pulls them in. -# Mirrors what a real Workers / REST-only consumer gets. -# 2. grpc: pb + grpc-js reinstalled; the gRPC entrypoint must resolve. +# 1. workers-safe: NO optional peer deps installed (no @grpc/grpc-js, +# @bufbuild/protobuf, or express). esbuild fails natively if any +# Workers-safe entry pulls them in. Mirrors what a real Workers / +# REST-only consumer gets. +# 2. grpc: peer deps reinstalled; the gRPC entrypoint must resolve. name: Run Build Tests @@ -32,11 +33,11 @@ jobs: cache: 'npm' - run: npm ci - run: npm run build - # Strip the optional peer deps so esbuild sees the same module graph - # a non-gRPC consumer would. Any Workers-safe entrypoint that leaks - # a gRPC / pb import fails to resolve here. - - run: npm uninstall --no-save @grpc/grpc-js @bufbuild/protobuf + # Strip ALL three optional peer deps so esbuild sees the same + # module graph a non-gRPC, non-Express consumer would. Any + # Workers-safe entrypoint that leaks one of them fails to resolve. + - run: npm uninstall --no-save @grpc/grpc-js @bufbuild/protobuf express - run: npm run test-build:workers-safe # Reinstall the peer deps and verify the gRPC entrypoint still bundles. - - run: npm install --no-save @grpc/grpc-js @bufbuild/protobuf + - run: npm install --no-save @grpc/grpc-js @bufbuild/protobuf express - run: npm run test-build:grpc diff --git a/README.md b/README.md index d997d51a..0b5cb702 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ npm install express ### For gRPC Usage -If you plan to use the GRPC transport (imports from `@a2a-js/sdk/server/grpc` or `@a2a-js/sdk/client/grpc`), you must install the required peer dependencies: +If you plan to use the GRPC transport (imports from `@a2a-js/sdk/server/grpc`, `@a2a-js/sdk/client/grpc`, or the gRPC-specific error helpers in `@a2a-js/sdk/errors/grpc`), you must install the required peer dependencies: ```bash npm install @grpc/grpc-js @bufbuild/protobuf diff --git a/docs/migration-guide.md b/docs/migration-guide.md index d7a32e1f..01160d6f 100644 --- a/docs/migration-guide.md +++ b/docs/migration-guide.md @@ -290,7 +290,9 @@ Semantic classes: `TaskNotFoundError`, `TaskNotCancelableError`, `RequestMalformedError`, `UnsupportedOperationError`, `PushNotificationNotSupportedError`, `ContentTypeNotSupportedError`, `InvalidAgentResponseError`, `ExtendedAgentCardNotConfiguredError`, -`ExtensionSupportRequiredError`, `VersionNotSupportedError`, `GenericError`. +`ExtensionSupportRequiredError`, `VersionNotSupportedError`. `A2AError` +itself is the concrete fallback — instantiate it directly +(`new A2AError('...')`) when no semantic class fits. Per-transport variants (`RestTaskNotFoundError`, `GrpcTaskNotFoundError`, `JsonRpcTaskNotFoundError`, …) carry transport-native context; narrow via the diff --git a/src/compat/v0_3/client/transports/rest_transport.ts b/src/compat/v0_3/client/transports/rest_transport.ts index e8bc69d9..64cd172a 100644 --- a/src/compat/v0_3/client/transports/rest_transport.ts +++ b/src/compat/v0_3/client/transports/rest_transport.ts @@ -17,8 +17,8 @@ import { A2A_ERROR_CLASSES, - A2A_ERROR_SPECS_BY_CODE, InvalidAgentResponseError, + JSON_RPC_CODE_TO_ERROR, UnsupportedOperationError, } from '../../../../errors/index.js'; import type { TransportProtocolName } from '../../../../core.js'; @@ -462,8 +462,8 @@ export class LegacyRestTransport implements Transport { * in the message for debugging. */ private static _errorFromLegacyBody(body: LegacyRestErrorBody): Error { - const spec = A2A_ERROR_SPECS_BY_CODE[body.code]; - if (spec) return new A2A_ERROR_CLASSES[spec.name]({ message: body.message }); + const name = JSON_RPC_CODE_TO_ERROR[body.code]; + if (name) return new A2A_ERROR_CLASSES[name]({ message: body.message }); const dataSuffix = body.data ? ` Data: ${JSON.stringify(body.data)}` : ''; return new Error(`REST error: ${body.message} (Code: ${body.code})${dataSuffix}`); } diff --git a/src/compat/v0_3/server/error.ts b/src/compat/v0_3/server/error.ts index ea497f5b..6d7f23a4 100644 --- a/src/compat/v0_3/server/error.ts +++ b/src/compat/v0_3/server/error.ts @@ -7,15 +7,14 @@ */ import { - A2A_ERROR_CODE, A2A_ERROR_CLASSES, - A2A_ERROR_SPECS_BY_CODE, + A2A_ERROR_CODE, A2AError as BaseA2AError, ExtendedAgentCardNotConfiguredError, - GenericError, + JSON_RPC_CODE_TO_ERROR, JSON_RPC_ERROR_CLASSES, - JsonRpcGenericError, JsonRpcRequestMalformedError, + JsonRpcTransportError, JsonRpcUnsupportedOperationError, PushNotificationNotSupportedError, RequestMalformedError, @@ -50,17 +49,18 @@ interface A2AErrorFacade { } function makeA2AError(code: number, message: string, data?: Record): BaseA2AError { - const spec = A2A_ERROR_SPECS_BY_CODE[code]; - if (spec) { + // Codes with a semantic twin (§5.4). + const name = JSON_RPC_CODE_TO_ERROR[code]; + if (name) { return data === undefined - ? new A2A_ERROR_CLASSES[spec.name]({ message }) - : new JSON_RPC_ERROR_CLASSES[spec.name]({ - message, - envelopeCode: code, - data: data as never, - }); + ? new A2A_ERROR_CLASSES[name]({ message }) + : new JSON_RPC_ERROR_CLASSES[name]({ message, envelopeCode: code, data: data as never }); } - + // Wire-only reserved codes without a semantic twin (§8.1). Route + // METHOD_NOT_FOUND to UnsupportedOperation (semantically closer: + // "operation not supported"); PARSE_ERROR / INVALID_REQUEST stay in + // the RequestMalformed bucket ("wire not well-formed"). Others fall + // back to a raw envelope-preserving JsonRpcTransportError. if (code === A2A_ERROR_CODE.METHOD_NOT_FOUND) { return new JsonRpcUnsupportedOperationError({ message, @@ -68,10 +68,17 @@ function makeA2AError(code: number, message: string, data?: Record }); A2AError.internalError = (message, data) => - data === undefined - ? new GenericError({ message }) - : new JsonRpcGenericError({ - message, - envelopeCode: A2A_ERROR_CODE.INTERNAL_ERROR, - data: data as never, - }); + new JsonRpcTransportError({ + jsonrpc: '2.0', + id: null, + error: { + code: A2A_ERROR_CODE.INTERNAL_ERROR, + message, + ...(data !== undefined ? { data } : {}), + }, + }); A2AError.taskNotFound = (taskId) => new TaskNotFoundError({ message: `Task not found: ${taskId}` }); diff --git a/src/compat/v0_3/server/grpc/grpc_service.ts b/src/compat/v0_3/server/grpc/grpc_service.ts index 16c68cb2..6e0e5569 100644 --- a/src/compat/v0_3/server/grpc/grpc_service.ts +++ b/src/compat/v0_3/server/grpc/grpc_service.ts @@ -38,13 +38,12 @@ import { Extensions } from '../../../../extensions.js'; import { UserBuilder } from './common.js'; import { A2A_VERSION_HEADER, HTTP_EXTENSION_HEADER } from '../../../../constants.js'; import { LEGACY_HTTP_EXTENSION_HEADER } from '../../constants.js'; +import { A2AError, InvalidAgentResponseError, isJsonRpcError } from '../../../../errors/index.js'; import { - A2A_STATUS_CODE, - A2AError, - InvalidAgentResponseError, - isJsonRpcError, -} from '../../../../errors/index.js'; -import { buildGrpcErrorMetadata, grpcStatusFor } from '../../../../errors/grpc/index.js'; + buildGrpcErrorMetadata, + GRPC_STATUS_CODE, + grpcStatusFor, +} from '../../../../errors/grpc/index.js'; import { validateVersion } from '../../../../server/version.js'; import { FromProto } from '../../types/converters/from_proto.js'; import { ToProto } from '../../types/converters/to_proto.js'; @@ -438,6 +437,7 @@ const LEGACY_CODE_TO_GRPC_STATUS: Readonly> = { [-32700]: grpc.status.INVALID_ARGUMENT, // Parse error [-32600]: grpc.status.INVALID_ARGUMENT, // Invalid Request [-32601]: grpc.status.UNIMPLEMENTED, // Method not found + [-32603]: grpc.status.INTERNAL, // Internal error }; const mapToError = (error: unknown): Partial => { @@ -447,7 +447,7 @@ const mapToError = (error: unknown): Partial => { } else if (error instanceof A2AError) { code = grpcStatusFor(error); } else { - code = A2A_STATUS_CODE.UNKNOWN; + code = GRPC_STATUS_CODE.UNKNOWN; } const message = error instanceof Error ? error.message : 'Internal server error'; const result: Partial = { code, details: message }; diff --git a/src/compat/v0_3/translate/errors.ts b/src/compat/v0_3/translate/errors.ts index 611443bd..5e0bb7c1 100644 --- a/src/compat/v0_3/translate/errors.ts +++ b/src/compat/v0_3/translate/errors.ts @@ -16,9 +16,9 @@ import { A2A_ERROR_CODE, - A2A_ERROR_SPECS, A2AError, isJsonRpcError, + JSON_RPC_ERROR_CODE, } from '../../../errors/index.js'; import type { JSONRPCError } from '../types/types.js'; @@ -44,8 +44,8 @@ export function toCompatErrorBody(error: unknown): JSONRPCError | LegacyRestErro }; } if (error instanceof A2AError) { - const spec = A2A_ERROR_SPECS[error.name]; - return { code: spec?.code ?? A2A_ERROR_CODE.INTERNAL_ERROR, message: error.message }; + const code = JSON_RPC_ERROR_CODE[error.name] ?? A2A_ERROR_CODE.INTERNAL_ERROR; + return { code, message: error.message }; } const message = (error instanceof Error && error.message) || 'An unexpected error occurred.'; return { code: A2A_ERROR_CODE.INTERNAL_ERROR, message }; diff --git a/src/errors/base.ts b/src/errors/base.ts index 6e7ac111..0b56e308 100644 --- a/src/errors/base.ts +++ b/src/errors/base.ts @@ -5,97 +5,19 @@ * (in `./rest`, `./grpc`, `./json_rpc`) mix in a transport-context * interface; catch sites narrow via `instanceof ` and * `isRestError` / `isGrpcError` / `isJsonRpcError` type guards. + * + * Only the spec-defined fields (§3.3.2 "A2A-Specific Errors") live + * here: `name`, `reason` (`ErrorInfo.reason`), and a default message. + * Per-transport code/status mappings (§5.4) live in the corresponding + * transport files. */ -/** JSON-RPC 2.0 error codes reserved for A2A. */ -export const A2A_ERROR_CODE = { - PARSE_ERROR: -32700, - INVALID_REQUEST: -32600, - METHOD_NOT_FOUND: -32601, - INVALID_PARAMS: -32602, - INTERNAL_ERROR: -32603, - TASK_NOT_FOUND: -32001, - TASK_NOT_CANCELABLE: -32002, - PUSH_NOTIFICATION_NOT_SUPPORTED: -32003, - UNSUPPORTED_OPERATION: -32004, - CONTENT_TYPE_NOT_SUPPORTED: -32005, - INVALID_AGENT_RESPONSE: -32006, - EXTENDED_CARD_NOT_CONFIGURED: -32007, - EXTENSION_SUPPORT_REQUIRED: -32008, - VERSION_NOT_SUPPORTED: -32009, -} as const; - /** Domain for `google.rpc.ErrorInfo.domain`. */ export const A2A_ERROR_DOMAIN = 'a2a-protocol.org'; /** `@type`/`typeUrl` for `google.rpc.ErrorInfo` in ProtoJSON `Any`. */ export const ERROR_INFO_TYPE = 'type.googleapis.com/google.rpc.ErrorInfo'; -/** HTTP status codes used in REST responses. */ -export const HTTP_STATUS = { - OK: 200, - CREATED: 201, - ACCEPTED: 202, - NO_CONTENT: 204, - BAD_REQUEST: 400, - UNAUTHORIZED: 401, - NOT_FOUND: 404, - CONFLICT: 409, - INTERNAL_SERVER_ERROR: 500, - NOT_IMPLEMENTED: 501, -} as const; - -/** - * Canonical numeric status codes used by the semantic-error registry - * to describe the spec-mapped status per §5.4. The numeric namespace - * matches gRPC's `status` enum (so the gRPC transport can emit them - * directly) and the string form (see {@link A2A_STATUS_NAME}) fills - * the REST body's `status` field per §11.6. - */ -export const A2A_STATUS_CODE = { - OK: 0, - CANCELLED: 1, - UNKNOWN: 2, - INVALID_ARGUMENT: 3, - DEADLINE_EXCEEDED: 4, - NOT_FOUND: 5, - ALREADY_EXISTS: 6, - PERMISSION_DENIED: 7, - RESOURCE_EXHAUSTED: 8, - FAILED_PRECONDITION: 9, - ABORTED: 10, - OUT_OF_RANGE: 11, - UNIMPLEMENTED: 12, - INTERNAL: 13, - UNAVAILABLE: 14, - DATA_LOSS: 15, - UNAUTHENTICATED: 16, -} as const; - -/** - * String form of {@link A2A_STATUS_CODE}, used in the REST body's - * `status` field per §11.6. Names follow the gRPC enum form. - */ -export const A2A_STATUS_NAME: Readonly> = { - [A2A_STATUS_CODE.OK]: 'OK', - [A2A_STATUS_CODE.CANCELLED]: 'CANCELLED', - [A2A_STATUS_CODE.UNKNOWN]: 'UNKNOWN', - [A2A_STATUS_CODE.INVALID_ARGUMENT]: 'INVALID_ARGUMENT', - [A2A_STATUS_CODE.DEADLINE_EXCEEDED]: 'DEADLINE_EXCEEDED', - [A2A_STATUS_CODE.NOT_FOUND]: 'NOT_FOUND', - [A2A_STATUS_CODE.ALREADY_EXISTS]: 'ALREADY_EXISTS', - [A2A_STATUS_CODE.PERMISSION_DENIED]: 'PERMISSION_DENIED', - [A2A_STATUS_CODE.RESOURCE_EXHAUSTED]: 'RESOURCE_EXHAUSTED', - [A2A_STATUS_CODE.FAILED_PRECONDITION]: 'FAILED_PRECONDITION', - [A2A_STATUS_CODE.ABORTED]: 'ABORTED', - [A2A_STATUS_CODE.OUT_OF_RANGE]: 'OUT_OF_RANGE', - [A2A_STATUS_CODE.UNIMPLEMENTED]: 'UNIMPLEMENTED', - [A2A_STATUS_CODE.INTERNAL]: 'INTERNAL', - [A2A_STATUS_CODE.UNAVAILABLE]: 'UNAVAILABLE', - [A2A_STATUS_CODE.DATA_LOSS]: 'DATA_LOSS', - [A2A_STATUS_CODE.UNAUTHENTICATED]: 'UNAUTHENTICATED', -}; - /** A structured detail object included in error responses. */ export interface ErrorDetail { '@type': string; @@ -110,16 +32,6 @@ export interface A2AErrorInfo extends ErrorDetail { metadata?: Record; } -/** REST error body (`google.rpc.Status` JSON). */ -export interface RestErrorBody { - error: { - code: number; - status: string; - message: string; - details: ErrorDetail[]; - }; -} - /** Options accepted by every `A2AError` constructor. */ export interface A2AErrorOptions { /** Human-readable message. If omitted, the per-class default is used. */ @@ -131,29 +43,31 @@ export interface A2AErrorOptions { } /** - * Base class for every SDK error. Carries the spec-aligned `reason`, - * numeric `code`, and structured `metadata`. Concrete semantic classes - * (below) fill in `reason` / `code` in their constructor. Transport - * subclasses in `./rest`, `./grpc`, `./json_rpc` add wire context via - * the {@link RestA2AError} / {@link GrpcA2AError} / {@link JsonRpcA2AError} - * interfaces. + * Base class for every SDK error and the concrete fallback used when + * no semantic subclass matches (e.g. an unknown wire code). Carries + * the spec-aligned `reason`, structured `metadata`, and a stable + * `error.name` of `'A2AError'`. Transport subclasses in `./rest`, + * `./grpc`, `./json_rpc` add wire context via the + * {@link import('./rest.js').RestA2AError} / {@link import('./grpc/index.js').GrpcA2AError} + * / {@link import('./json_rpc.js').JsonRpcA2AError} interfaces. + * + * Accepts either a bare message string or an options object. */ export class A2AError extends Error { - /** UPPER_SNAKE_CASE reason from `google.rpc.ErrorInfo`. */ + /** UPPER_SNAKE_CASE reason from `google.rpc.ErrorInfo` (§10.6 / §11.6). */ public readonly reason: string = 'INTERNAL_ERROR'; - /** Numeric JSON-RPC / A2A error code. */ - public readonly code: number = A2A_ERROR_CODE.INTERNAL_ERROR; /** Optional `google.rpc.ErrorInfo.metadata`. */ public readonly metadata?: Record; - constructor(defaultMessage: string, options?: A2AErrorOptions) { + constructor(options?: A2AErrorOptions | string) { + const opts = typeof options === 'string' ? { message: options } : options; super( - options?.message ?? defaultMessage, - options?.cause !== undefined ? { cause: options.cause } : undefined + opts?.message ?? 'An unexpected error occurred.', + opts?.cause !== undefined ? { cause: opts.cause } : undefined ); this.name = new.target.name; - if (options?.metadata && Object.keys(options.metadata).length > 0) { - this.metadata = options.metadata; + if (opts?.metadata && Object.keys(opts.metadata).length > 0) { + this.metadata = opts.metadata; } } @@ -169,124 +83,79 @@ export class A2AError extends Error { } /** - * Registry row for one semantic error class. Adding a new error means - * adding one row here; all wire mappings derive from it. + * Registry row for one semantic error class. Only holds the fields + * that appear in the transport-agnostic §3.3.2 "A2A-Specific Errors" + * table: name (also used as `error.name`), reason (ErrorInfo string), + * and a default human-readable message. Per-transport codes/statuses + * live in the corresponding transport files. */ export interface A2AErrorSpec { name: string; reason: string; - code: number; - grpcStatus: number; - httpStatus: number; defaultMessage: string; } const specs: A2AErrorSpec[] = [ - { - name: 'TaskNotFoundError', - reason: 'TASK_NOT_FOUND', - code: A2A_ERROR_CODE.TASK_NOT_FOUND, - grpcStatus: A2A_STATUS_CODE.NOT_FOUND, - httpStatus: HTTP_STATUS.NOT_FOUND, - defaultMessage: 'Task not found', - }, + { name: 'TaskNotFoundError', reason: 'TASK_NOT_FOUND', defaultMessage: 'Task not found' }, { name: 'TaskNotCancelableError', reason: 'TASK_NOT_CANCELABLE', - code: A2A_ERROR_CODE.TASK_NOT_CANCELABLE, - grpcStatus: A2A_STATUS_CODE.FAILED_PRECONDITION, - httpStatus: HTTP_STATUS.BAD_REQUEST, defaultMessage: 'Task cannot be canceled', }, { name: 'PushNotificationNotSupportedError', reason: 'PUSH_NOTIFICATION_NOT_SUPPORTED', - code: A2A_ERROR_CODE.PUSH_NOTIFICATION_NOT_SUPPORTED, - grpcStatus: A2A_STATUS_CODE.FAILED_PRECONDITION, - httpStatus: HTTP_STATUS.BAD_REQUEST, defaultMessage: 'Push Notification is not supported', }, { name: 'UnsupportedOperationError', reason: 'UNSUPPORTED_OPERATION', - code: A2A_ERROR_CODE.UNSUPPORTED_OPERATION, - grpcStatus: A2A_STATUS_CODE.FAILED_PRECONDITION, - httpStatus: HTTP_STATUS.BAD_REQUEST, defaultMessage: 'This operation is not supported', }, { name: 'ContentTypeNotSupportedError', reason: 'CONTENT_TYPE_NOT_SUPPORTED', - code: A2A_ERROR_CODE.CONTENT_TYPE_NOT_SUPPORTED, - grpcStatus: A2A_STATUS_CODE.INVALID_ARGUMENT, - httpStatus: HTTP_STATUS.BAD_REQUEST, defaultMessage: 'Incompatible content types', }, { name: 'InvalidAgentResponseError', reason: 'INVALID_AGENT_RESPONSE', - code: A2A_ERROR_CODE.INVALID_AGENT_RESPONSE, - grpcStatus: A2A_STATUS_CODE.INTERNAL, - httpStatus: HTTP_STATUS.INTERNAL_SERVER_ERROR, defaultMessage: 'Invalid agent response type', }, { name: 'ExtendedAgentCardNotConfiguredError', reason: 'EXTENDED_AGENT_CARD_NOT_CONFIGURED', - code: A2A_ERROR_CODE.EXTENDED_CARD_NOT_CONFIGURED, - grpcStatus: A2A_STATUS_CODE.FAILED_PRECONDITION, - httpStatus: HTTP_STATUS.BAD_REQUEST, defaultMessage: 'Extended Agent Card not configured', }, { name: 'ExtensionSupportRequiredError', reason: 'EXTENSION_SUPPORT_REQUIRED', - code: A2A_ERROR_CODE.EXTENSION_SUPPORT_REQUIRED, - grpcStatus: A2A_STATUS_CODE.FAILED_PRECONDITION, - httpStatus: HTTP_STATUS.BAD_REQUEST, defaultMessage: 'Extension support required', }, { name: 'VersionNotSupportedError', reason: 'VERSION_NOT_SUPPORTED', - code: A2A_ERROR_CODE.VERSION_NOT_SUPPORTED, - grpcStatus: A2A_STATUS_CODE.FAILED_PRECONDITION, - httpStatus: HTTP_STATUS.BAD_REQUEST, defaultMessage: 'Version not supported', }, { name: 'RequestMalformedError', reason: 'INVALID_PARAMS', - code: A2A_ERROR_CODE.INVALID_PARAMS, - grpcStatus: A2A_STATUS_CODE.INVALID_ARGUMENT, - httpStatus: HTTP_STATUS.BAD_REQUEST, defaultMessage: 'Request malformed', }, - { - name: 'GenericError', - reason: 'INTERNAL_ERROR', - code: A2A_ERROR_CODE.INTERNAL_ERROR, - grpcStatus: A2A_STATUS_CODE.INTERNAL, - httpStatus: HTTP_STATUS.INTERNAL_SERVER_ERROR, - defaultMessage: 'An unexpected error occurred.', - }, ]; -/** Registry lookups keyed on the various identifiers used across wires. */ +/** Registry lookups keyed on the identifiers used across wires. */ export const A2A_ERROR_SPECS: Readonly> = Object.freeze( Object.fromEntries(specs.map((s) => [s.name, s])) ); export const A2A_ERROR_SPECS_BY_REASON: Readonly> = Object.freeze( Object.fromEntries(specs.map((s) => [s.reason, s])) ); -export const A2A_ERROR_SPECS_BY_CODE: Readonly> = Object.freeze( - Object.fromEntries(specs.map((s) => [s.code, s])) -); /** * Concrete semantic error classes. One per {@link A2AErrorSpec} row. * Generated by {@link makeSemantic} so adding a new row automatically - * produces a class with the right `name`, `reason`, `code`, and default + * produces a class with the right `name`, `reason`, and default * message. Transport variants are declared in `./rest`, `./grpc`, * `./json_rpc`. */ @@ -295,9 +164,12 @@ function makeSemantic(spec: A2AErrorSpec): new (options?: A2AErrorOptions | stri const cls = { [spec.name]: class extends A2AError { public override readonly reason = spec.reason; - public override readonly code = spec.code; constructor(options?: A2AErrorOptions | string) { - super(spec.defaultMessage, typeof options === 'string' ? { message: options } : options); + // Apply the spec's default message when the caller didn't + // supply one. + if (options === undefined) super({ message: spec.defaultMessage }); + else if (typeof options === 'string') super({ message: options }); + else super({ message: spec.defaultMessage, ...options }); } }, }[spec.name]; @@ -346,9 +218,6 @@ export type VersionNotSupportedError = InstanceType; -export const GenericError = makeSemantic(A2A_ERROR_SPECS.GenericError); -export type GenericError = InstanceType; - /** Constructor type of a semantic {@link A2AError} subclass. */ export type A2AErrorClass = new (options?: A2AErrorOptions | string) => A2AError; @@ -364,7 +233,6 @@ export const A2A_ERROR_CLASSES: Readonly> = Object ExtensionSupportRequiredError, VersionNotSupportedError, RequestMalformedError, - GenericError, }); /** @@ -384,20 +252,10 @@ export function extractErrorMessage(err: unknown): string { /** * Looks up the {@link A2AErrorSpec} matching an `Error` instance by - * class name. Returns `undefined` for non-A2A errors. + * class name. Returns `undefined` for non-A2A errors (including the + * concrete `A2AError` fallback which has no semantic registry entry). */ export function specForError(error: unknown): A2AErrorSpec | undefined { if (!(error instanceof Error)) return undefined; return A2A_ERROR_SPECS[error.name]; } - -/** - * Backward-compat shim retained for callers that still pass a `{ code, - * message }` pair. Use {@link A2A_ERROR_SPECS_BY_CODE} plus a class - * constructor directly when writing new code. - */ -export function errorForCode(code: number, message: string): A2AError { - const spec = A2A_ERROR_SPECS_BY_CODE[code]; - const cls = spec ? A2A_ERROR_CLASSES[spec.name] : A2A_ERROR_CLASSES.GenericError; - return new cls({ message }); -} diff --git a/src/errors/grpc/index.ts b/src/errors/grpc/index.ts index 17af10db..6a681af4 100644 --- a/src/errors/grpc/index.ts +++ b/src/errors/grpc/index.ts @@ -2,6 +2,10 @@ * gRPC transport error subclasses and wire helpers. Absorbs the * previous `src/server/grpc/error_details.ts`. * + * Owns the gRPC status enum ({@link GRPC_STATUS_CODE}) and the + * per-error status mapping (§5.4). `../base.ts` intentionally carries + * only §3.3.2 fields; codes are transport-specific. + * * gRPC does NOT carry a `cause` on the wire, so the transport context * exposes status code + the `grpc-status-details-bin` trailing * metadata blob only. @@ -13,7 +17,6 @@ import { A2A_ERROR_DOMAIN, A2A_ERROR_SPECS, A2A_ERROR_SPECS_BY_REASON, - A2A_STATUS_CODE, A2AError, type A2AErrorOptions, ERROR_INFO_TYPE, @@ -25,6 +28,47 @@ import { Status } from '../../grpc/pb/google/rpc/status.js'; /** Trailing metadata key for `google.rpc.Status`. */ export const GRPC_STATUS_DETAILS_BIN = 'grpc-status-details-bin'; +/** + * Numeric gRPC status codes. Mirrors `@grpc/grpc-js`'s `status` enum + * so callers can use these values interchangeably with the grpc-js + * runtime enum. Declared locally so `./base.ts` stays pb/grpc-free. + */ +export const GRPC_STATUS_CODE = { + OK: 0, + CANCELLED: 1, + UNKNOWN: 2, + INVALID_ARGUMENT: 3, + DEADLINE_EXCEEDED: 4, + NOT_FOUND: 5, + ALREADY_EXISTS: 6, + PERMISSION_DENIED: 7, + RESOURCE_EXHAUSTED: 8, + FAILED_PRECONDITION: 9, + ABORTED: 10, + OUT_OF_RANGE: 11, + UNIMPLEMENTED: 12, + INTERNAL: 13, + UNAVAILABLE: 14, + DATA_LOSS: 15, + UNAUTHENTICATED: 16, +} as const; + +/** + * Per-error gRPC status (§5.4). Semantic class name -> status code. + */ +export const GRPC_ERROR_STATUS: Readonly> = Object.freeze({ + TaskNotFoundError: GRPC_STATUS_CODE.NOT_FOUND, + TaskNotCancelableError: GRPC_STATUS_CODE.FAILED_PRECONDITION, + PushNotificationNotSupportedError: GRPC_STATUS_CODE.FAILED_PRECONDITION, + UnsupportedOperationError: GRPC_STATUS_CODE.FAILED_PRECONDITION, + ContentTypeNotSupportedError: GRPC_STATUS_CODE.INVALID_ARGUMENT, + InvalidAgentResponseError: GRPC_STATUS_CODE.INTERNAL, + ExtendedAgentCardNotConfiguredError: GRPC_STATUS_CODE.FAILED_PRECONDITION, + ExtensionSupportRequiredError: GRPC_STATUS_CODE.FAILED_PRECONDITION, + VersionNotSupportedError: GRPC_STATUS_CODE.FAILED_PRECONDITION, + RequestMalformedError: GRPC_STATUS_CODE.INVALID_ARGUMENT, +}); + /** Transport context carried by every `Grpc*Error`. */ export interface GrpcA2AError extends A2AError { readonly transport: 'grpc'; @@ -35,7 +79,7 @@ export interface GrpcA2AError extends A2AError { /** Options accepted by every `Grpc*Error` constructor. */ export interface GrpcA2AErrorOptions extends A2AErrorOptions { - /** gRPC status code. Defaults to the semantic error's spec value. */ + /** gRPC status code. Defaults to the per-error spec value. */ status?: number; /** Raw `grpc-status-details-bin` blob if received on the wire. */ statusDetailsBin?: Buffer; @@ -47,8 +91,8 @@ export function isGrpcError(err: unknown): err is GrpcA2AError { } function makeGrpc(name: string): new (options?: GrpcA2AErrorOptions) => GrpcA2AError { - const spec = A2A_ERROR_SPECS[name]; const Base = A2A_ERROR_CLASSES[name]; + const defaultStatus = GRPC_ERROR_STATUS[name] ?? GRPC_STATUS_CODE.INTERNAL; const cls = { [`Grpc${name}`]: class extends Base { public readonly transport = 'grpc'; @@ -57,7 +101,7 @@ function makeGrpc(name: string): new (options?: GrpcA2AErrorOptions) => GrpcA2AE constructor(options?: GrpcA2AErrorOptions) { super(options); this.name = name; - this.status = options?.status ?? spec.grpcStatus; + this.status = options?.status ?? defaultStatus; if (options?.statusDetailsBin) this.statusDetailsBin = options.statusDetailsBin; } }, @@ -105,9 +149,6 @@ export type GrpcVersionNotSupportedError = InstanceType; -export const GrpcGenericError = makeGrpc('GenericError'); -export type GrpcGenericError = InstanceType; - /** gRPC twins indexed by their semantic parent's name. */ export const GRPC_ERROR_CLASSES: Readonly< Record GrpcA2AError> @@ -122,15 +163,13 @@ export const GRPC_ERROR_CLASSES: Readonly< ExtensionSupportRequiredError: GrpcExtensionSupportRequiredError, VersionNotSupportedError: GrpcVersionNotSupportedError, RequestMalformedError: GrpcRequestMalformedError, - GenericError: GrpcGenericError, }); /** Returns the gRPC status the server should send for a given error. */ export function grpcStatusFor(error: unknown): number { if (isGrpcError(error)) return error.status; - if (error instanceof A2AError) - return A2A_ERROR_SPECS[error.name]?.grpcStatus ?? A2A_STATUS_CODE.UNKNOWN; - return A2A_STATUS_CODE.UNKNOWN; + if (error instanceof A2AError) return GRPC_ERROR_STATUS[error.name] ?? GRPC_STATUS_CODE.UNKNOWN; + return GRPC_STATUS_CODE.UNKNOWN; } /** Encodes a `google.rpc.Status` + `ErrorInfo` blob for `grpc-status-details-bin`. */ @@ -197,9 +236,10 @@ export function decodeErrorInfo(buffer: Buffer): DecodedErrorInfo { /** * Rebuilds a gRPC-specific SDK error from a `grpc.ServiceError`. Reads - * `grpc-status-details-bin` for `ErrorInfo.reason`; falls back to - * {@link GrpcGenericError} carrying the raw status and details string. - * `method` is included in the fallback message for debuggability. + * `grpc-status-details-bin` for `ErrorInfo.reason`; falls back to a + * gRPC-scoped concrete {@link A2AError} carrying the raw status and + * details string. `method` is included in the fallback message for + * debuggability. */ export function fromGrpcError(error: grpc.ServiceError, method?: string): GrpcA2AError { const bin = error.metadata?.get(GRPC_STATUS_DETAILS_BIN); @@ -227,11 +267,28 @@ export function fromGrpcError(error: grpc.ServiceError, method?: string): GrpcA2 } } const suffix = method ? ` for ${method}` : ''; - return new GrpcGenericError({ + return new GrpcA2AErrorImpl({ message: - `gRPC error${suffix}: ${error.code ?? A2A_STATUS_CODE.UNKNOWN} ${error.details ?? ''}`.trim(), + `gRPC error${suffix}: ${error.code ?? GRPC_STATUS_CODE.UNKNOWN} ${error.details ?? ''}`.trim(), cause: error, - status: error.code ?? A2A_STATUS_CODE.UNKNOWN, + status: error.code ?? GRPC_STATUS_CODE.UNKNOWN, statusDetailsBin, }); } + +/** + * Concrete gRPC-scoped {@link A2AError} used when the wire has no + * `ErrorInfo` detail. Kept private because callers shouldn't be + * constructing it directly. + */ +class GrpcA2AErrorImpl extends A2AError implements GrpcA2AError { + public readonly transport = 'grpc'; + public readonly status: number; + public readonly statusDetailsBin?: Buffer; + constructor(options?: GrpcA2AErrorOptions) { + super(options); + this.name = 'A2AError'; + this.status = options?.status ?? GRPC_STATUS_CODE.UNKNOWN; + if (options?.statusDetailsBin) this.statusDetailsBin = options.statusDetailsBin; + } +} diff --git a/src/errors/index.ts b/src/errors/index.ts index 23ca66af..e3090b32 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -1,12 +1,86 @@ /** - * Transport-agnostic A2A error hierarchy: base class, semantic - * subclasses, plus the REST and JSON-RPC transport variants (both - * Workers-safe, no pb runtime dependency). + * Public entrypoint for the transport-agnostic A2A error hierarchy. + * Explicit named re-exports so this file defines the API contract of + * `@a2a-js/sdk/errors`; a `base.ts` helper added tomorrow does NOT + * accidentally become public API. * * gRPC errors live at `@a2a-js/sdk/errors/grpc` because their * encode/decode helpers pull `@bufbuild/protobuf`. */ -export * from './base.js'; -export * from './rest.js'; -export * from './json_rpc.js'; +// --- base --- +export { + A2A_ERROR_DOMAIN, + A2A_ERROR_CLASSES, + A2A_ERROR_SPECS, + A2A_ERROR_SPECS_BY_REASON, + A2AError, + type A2AErrorClass, + type A2AErrorInfo, + type A2AErrorOptions, + type A2AErrorSpec, + ContentTypeNotSupportedError, + ERROR_INFO_TYPE, + type ErrorDetail, + ExtendedAgentCardNotConfiguredError, + ExtensionSupportRequiredError, + extractErrorMessage, + InvalidAgentResponseError, + PushNotificationNotSupportedError, + RequestMalformedError, + specForError, + TaskNotCancelableError, + TaskNotFoundError, + UnsupportedOperationError, + VersionNotSupportedError, +} from './base.js'; + +// --- REST transport --- +export { + HTTP_STATUS, + isRestError, + REST_ERROR_CLASSES, + REST_ERROR_HTTP_STATUS, + REST_ERROR_STATUS_NAME, + REST_STATUS_NAME, + type RestA2AError, + type RestA2AErrorOptions, + type RestErrorBody, + RestContentTypeNotSupportedError, + RestExtendedAgentCardNotConfiguredError, + RestExtensionSupportRequiredError, + RestInvalidAgentResponseError, + RestPushNotificationNotSupportedError, + RestRequestMalformedError, + RestTaskNotCancelableError, + RestTaskNotFoundError, + RestUnsupportedOperationError, + RestVersionNotSupportedError, + fromRestErrorBody, + restStatusFor, + toRestErrorBody, +} from './rest.js'; + +// --- JSON-RPC transport --- +export { + A2A_ERROR_CODE, + fromJsonRpcErrorResponse, + isJsonRpcError, + JSON_RPC_CODE_TO_ERROR, + JSON_RPC_ERROR_CLASSES, + JSON_RPC_ERROR_CODE, + type JsonRpcA2AError, + type JsonRpcA2AErrorOptions, + JsonRpcContentTypeNotSupportedError, + JsonRpcExtendedAgentCardNotConfiguredError, + JsonRpcExtensionSupportRequiredError, + JsonRpcInvalidAgentResponseError, + JsonRpcPushNotificationNotSupportedError, + JsonRpcRequestMalformedError, + JsonRpcTaskNotCancelableError, + JsonRpcTaskNotFoundError, + JsonRpcTransportError, + JsonRpcUnsupportedOperationError, + JsonRpcVersionNotSupportedError, + toJsonRpcError, +} from './json_rpc.js'; diff --git a/src/errors/json_rpc.ts b/src/errors/json_rpc.ts index 088b79a4..25475e2e 100644 --- a/src/errors/json_rpc.ts +++ b/src/errors/json_rpc.ts @@ -1,18 +1,59 @@ /** * JSON-RPC transport error subclasses and envelope helpers. + * + * Owns the JSON-RPC 2.0 code namespace ({@link A2A_ERROR_CODE}) and + * the per-error code mapping (§5.4). `./base.ts` intentionally + * carries only §3.3.2 fields; codes are transport-specific. */ import type { JSONRPCError, JSONRPCErrorResponse } from '../core.js'; import { A2A_ERROR_CLASSES, - A2A_ERROR_CODE, A2A_ERROR_SPECS, - A2A_ERROR_SPECS_BY_CODE, A2AError, type A2AErrorOptions, type ErrorDetail, } from './base.js'; +/** JSON-RPC 2.0 error codes reserved for A2A. */ +export const A2A_ERROR_CODE = { + PARSE_ERROR: -32700, + INVALID_REQUEST: -32600, + METHOD_NOT_FOUND: -32601, + INVALID_PARAMS: -32602, + INTERNAL_ERROR: -32603, + TASK_NOT_FOUND: -32001, + TASK_NOT_CANCELABLE: -32002, + PUSH_NOTIFICATION_NOT_SUPPORTED: -32003, + UNSUPPORTED_OPERATION: -32004, + CONTENT_TYPE_NOT_SUPPORTED: -32005, + INVALID_AGENT_RESPONSE: -32006, + EXTENDED_CARD_NOT_CONFIGURED: -32007, + EXTENSION_SUPPORT_REQUIRED: -32008, + VERSION_NOT_SUPPORTED: -32009, +} as const; + +/** + * Per-error JSON-RPC envelope code (§5.4). Semantic class name -> code. + */ +export const JSON_RPC_ERROR_CODE: Readonly> = Object.freeze({ + TaskNotFoundError: A2A_ERROR_CODE.TASK_NOT_FOUND, + TaskNotCancelableError: A2A_ERROR_CODE.TASK_NOT_CANCELABLE, + PushNotificationNotSupportedError: A2A_ERROR_CODE.PUSH_NOTIFICATION_NOT_SUPPORTED, + UnsupportedOperationError: A2A_ERROR_CODE.UNSUPPORTED_OPERATION, + ContentTypeNotSupportedError: A2A_ERROR_CODE.CONTENT_TYPE_NOT_SUPPORTED, + InvalidAgentResponseError: A2A_ERROR_CODE.INVALID_AGENT_RESPONSE, + ExtendedAgentCardNotConfiguredError: A2A_ERROR_CODE.EXTENDED_CARD_NOT_CONFIGURED, + ExtensionSupportRequiredError: A2A_ERROR_CODE.EXTENSION_SUPPORT_REQUIRED, + VersionNotSupportedError: A2A_ERROR_CODE.VERSION_NOT_SUPPORTED, + RequestMalformedError: A2A_ERROR_CODE.INVALID_PARAMS, +}); + +/** Reverse of {@link JSON_RPC_ERROR_CODE}: envelope code -> class name. */ +export const JSON_RPC_CODE_TO_ERROR: Readonly> = Object.freeze( + Object.fromEntries(Object.entries(JSON_RPC_ERROR_CODE).map(([name, code]) => [code, name])) +); + /** Transport context carried by every `JsonRpc*Error`. */ export interface JsonRpcA2AError extends A2AError { readonly transport: 'jsonrpc'; @@ -22,7 +63,7 @@ export interface JsonRpcA2AError extends A2AError { /** Options accepted by every `JsonRpc*Error` constructor. */ export interface JsonRpcA2AErrorOptions extends A2AErrorOptions { - /** Envelope `error.code`. Defaults to the semantic error's spec value. */ + /** Envelope `error.code`. Defaults to the per-error spec value. */ envelopeCode?: number; /** Envelope `error.data`, if any. */ data?: JSONRPCError['data']; @@ -34,8 +75,8 @@ export function isJsonRpcError(err: unknown): err is JsonRpcA2AError { } function makeJsonRpc(name: string): new (options?: JsonRpcA2AErrorOptions) => JsonRpcA2AError { - const spec = A2A_ERROR_SPECS[name]; const Base = A2A_ERROR_CLASSES[name]; + const defaultCode = JSON_RPC_ERROR_CODE[name] ?? A2A_ERROR_CODE.INTERNAL_ERROR; const cls = { [`JsonRpc${name}`]: class extends Base { public readonly transport = 'jsonrpc'; @@ -44,7 +85,7 @@ function makeJsonRpc(name: string): new (options?: JsonRpcA2AErrorOptions) => Js constructor(options?: JsonRpcA2AErrorOptions) { super(options); this.name = name; - this.envelopeCode = options?.envelopeCode ?? spec.code; + this.envelopeCode = options?.envelopeCode ?? defaultCode; if (options?.data !== undefined) this.data = options.data; } }, @@ -98,9 +139,6 @@ export type JsonRpcVersionNotSupportedError = InstanceType; -export const JsonRpcGenericError = makeJsonRpc('GenericError'); -export type JsonRpcGenericError = InstanceType; - /** JSON-RPC twins indexed by their semantic parent's name. */ export const JSON_RPC_ERROR_CLASSES: Readonly< Record JsonRpcA2AError> @@ -115,26 +153,24 @@ export const JSON_RPC_ERROR_CLASSES: Readonly< ExtensionSupportRequiredError: JsonRpcExtensionSupportRequiredError, VersionNotSupportedError: JsonRpcVersionNotSupportedError, RequestMalformedError: JsonRpcRequestMalformedError, - GenericError: JsonRpcGenericError, }); /** * Envelope for a JSON-RPC error not covered by any semantic code (e.g. - * `METHOD_NOT_FOUND`, `PARSE_ERROR`, or a custom vendor code). Extends - * {@link JsonRpcGenericError} so it still satisfies the transport - * interface and `instanceof A2AError` checks. + * `METHOD_NOT_FOUND`, `PARSE_ERROR`, or a custom vendor code). Concrete + * {@link A2AError} carrying the full envelope; satisfies the + * {@link JsonRpcA2AError} interface for `isJsonRpcError` narrowing. */ -export class JsonRpcTransportError extends JsonRpcGenericError { +export class JsonRpcTransportError extends A2AError implements JsonRpcA2AError { + public readonly transport = 'jsonrpc'; + public readonly envelopeCode: number; + public readonly data?: JSONRPCError['data']; public readonly errorResponse: JSONRPCErrorResponse; constructor(envelope: JSONRPCErrorResponse) { - super({ - message: `JSON-RPC error: ${envelope.error.message} (Code: ${envelope.error.code}) Data: ${JSON.stringify(envelope.error.data || {})}`, - envelopeCode: envelope.error.code, - data: envelope.error.data, - }); - // Override the parent's `this.name = 'GenericError'` so - // `error.name === 'JsonRpcTransportError'` for catch-site consumers. + super({ message: envelope.error.message }); this.name = 'JsonRpcTransportError'; + this.envelopeCode = envelope.error.code; + if (envelope.error.data !== undefined) this.data = envelope.error.data; this.errorResponse = envelope; } } @@ -161,14 +197,8 @@ export function toJsonRpcError(error: unknown): { }; } if (error instanceof A2AError) { - const spec = A2A_ERROR_SPECS[error.name]; - if (spec) { - return { - code: spec.code, - message: error.message, - data: [error.toErrorInfo()], - }; - } + const code = JSON_RPC_ERROR_CODE[error.name] ?? A2A_ERROR_CODE.INTERNAL_ERROR; + return { code, message: error.message, data: [error.toErrorInfo()] }; } const message = (error instanceof Error && error.message) || 'An unexpected error occurred.'; return { code: A2A_ERROR_CODE.INTERNAL_ERROR, message }; @@ -190,8 +220,8 @@ const RESERVED_CODE_TO_SEMANTIC: Readonly> = { * codes yield a {@link JsonRpcTransportError} carrying the full envelope. */ export function fromJsonRpcErrorResponse(response: JSONRPCErrorResponse): JsonRpcA2AError { - const spec = A2A_ERROR_SPECS_BY_CODE[response.error.code]; - const semanticName = spec?.name ?? RESERVED_CODE_TO_SEMANTIC[response.error.code]; + const semanticName = + JSON_RPC_CODE_TO_ERROR[response.error.code] ?? RESERVED_CODE_TO_SEMANTIC[response.error.code]; if (semanticName) { return new JSON_RPC_ERROR_CLASSES[semanticName]({ message: response.error.message, diff --git a/src/errors/rest.ts b/src/errors/rest.ts index b0dded45..c42b8dec 100644 --- a/src/errors/rest.ts +++ b/src/errors/rest.ts @@ -4,22 +4,105 @@ * Every semantic error has a REST twin (e.g. `RestTaskNotFoundError`) * that carries HTTP status, response headers, and `cause`. All REST * twins satisfy {@link RestA2AError}; narrow via {@link isRestError}. + * + * The per-error HTTP status mapping (§5.4) and the `status` string + * enum used in the REST body (§11.6) live here rather than in + * `./base.ts` so `base.ts` carries only spec-defined §3.3.2 fields. */ import { A2A_ERROR_CLASSES, A2A_ERROR_DOMAIN, - A2A_ERROR_SPECS, A2A_ERROR_SPECS_BY_REASON, A2AError, type A2AErrorOptions, ERROR_INFO_TYPE, type ErrorDetail, - A2A_STATUS_NAME, - HTTP_STATUS, - type RestErrorBody, } from './base.js'; +/** HTTP status codes used in REST responses. */ +export const HTTP_STATUS = { + OK: 200, + CREATED: 201, + ACCEPTED: 202, + NO_CONTENT: 204, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + NOT_FOUND: 404, + CONFLICT: 409, + INTERNAL_SERVER_ERROR: 500, + NOT_IMPLEMENTED: 501, +} as const; + +/** + * `status` string used in the REST body per §11.6. Names follow the + * gRPC enum; values are the same numeric codes. + */ +export const REST_STATUS_NAME = { + OK: 'OK', + CANCELLED: 'CANCELLED', + UNKNOWN: 'UNKNOWN', + INVALID_ARGUMENT: 'INVALID_ARGUMENT', + DEADLINE_EXCEEDED: 'DEADLINE_EXCEEDED', + NOT_FOUND: 'NOT_FOUND', + ALREADY_EXISTS: 'ALREADY_EXISTS', + PERMISSION_DENIED: 'PERMISSION_DENIED', + RESOURCE_EXHAUSTED: 'RESOURCE_EXHAUSTED', + FAILED_PRECONDITION: 'FAILED_PRECONDITION', + ABORTED: 'ABORTED', + OUT_OF_RANGE: 'OUT_OF_RANGE', + UNIMPLEMENTED: 'UNIMPLEMENTED', + INTERNAL: 'INTERNAL', + UNAVAILABLE: 'UNAVAILABLE', + DATA_LOSS: 'DATA_LOSS', + UNAUTHENTICATED: 'UNAUTHENTICATED', +} as const; + +/** REST error body (`google.rpc.Status` JSON). */ +export interface RestErrorBody { + error: { + code: number; + status: string; + message: string; + details: ErrorDetail[]; + }; +} + +/** + * Per-error HTTP status mapping (§5.4). Semantic class name -> status. + * Errors that also want to advertise a stringy `status` in the REST + * body use {@link REST_ERROR_STATUS_NAME}. + */ +export const REST_ERROR_HTTP_STATUS: Readonly> = Object.freeze({ + TaskNotFoundError: HTTP_STATUS.NOT_FOUND, + TaskNotCancelableError: HTTP_STATUS.BAD_REQUEST, + PushNotificationNotSupportedError: HTTP_STATUS.BAD_REQUEST, + UnsupportedOperationError: HTTP_STATUS.BAD_REQUEST, + ContentTypeNotSupportedError: HTTP_STATUS.BAD_REQUEST, + InvalidAgentResponseError: HTTP_STATUS.INTERNAL_SERVER_ERROR, + ExtendedAgentCardNotConfiguredError: HTTP_STATUS.BAD_REQUEST, + ExtensionSupportRequiredError: HTTP_STATUS.BAD_REQUEST, + VersionNotSupportedError: HTTP_STATUS.BAD_REQUEST, + RequestMalformedError: HTTP_STATUS.BAD_REQUEST, +}); + +/** + * Per-error `status` string for the REST body (§11.6). Semantic class + * name -> status name (from {@link REST_STATUS_NAME}). + */ +export const REST_ERROR_STATUS_NAME: Readonly> = Object.freeze({ + TaskNotFoundError: REST_STATUS_NAME.NOT_FOUND, + TaskNotCancelableError: REST_STATUS_NAME.FAILED_PRECONDITION, + PushNotificationNotSupportedError: REST_STATUS_NAME.FAILED_PRECONDITION, + UnsupportedOperationError: REST_STATUS_NAME.FAILED_PRECONDITION, + ContentTypeNotSupportedError: REST_STATUS_NAME.INVALID_ARGUMENT, + InvalidAgentResponseError: REST_STATUS_NAME.INTERNAL, + ExtendedAgentCardNotConfiguredError: REST_STATUS_NAME.FAILED_PRECONDITION, + ExtensionSupportRequiredError: REST_STATUS_NAME.FAILED_PRECONDITION, + VersionNotSupportedError: REST_STATUS_NAME.FAILED_PRECONDITION, + RequestMalformedError: REST_STATUS_NAME.INVALID_ARGUMENT, +}); + /** Transport context carried by every `Rest*Error`. */ export interface RestA2AError extends A2AError { readonly transport: 'rest'; @@ -45,8 +128,8 @@ export function isRestError(err: unknown): err is RestA2AError { /** Builds the REST twin of a semantic error class. */ function makeRest(name: string): new (options?: RestA2AErrorOptions) => RestA2AError { - const spec = A2A_ERROR_SPECS[name]; const Base = A2A_ERROR_CLASSES[name]; + const defaultStatus = REST_ERROR_HTTP_STATUS[name] ?? HTTP_STATUS.INTERNAL_SERVER_ERROR; const cls = { [`Rest${name}`]: class extends Base { public readonly transport = 'rest'; @@ -57,7 +140,7 @@ function makeRest(name: string): new (options?: RestA2AErrorOptions) => RestA2AE // Keep `error.name` aligned with the semantic class so // `error.name === 'TaskNotFoundError'` still holds. this.name = name; - this.statusCode = options?.statusCode ?? spec.httpStatus; + this.statusCode = options?.statusCode ?? defaultStatus; if (options?.headers) this.headers = options.headers; } }, @@ -107,9 +190,6 @@ export type RestVersionNotSupportedError = InstanceType; -export const RestGenericError = makeRest('GenericError'); -export type RestGenericError = InstanceType; - /** REST twins indexed by their semantic parent's name. */ export const REST_ERROR_CLASSES: Readonly< Record RestA2AError> @@ -124,36 +204,35 @@ export const REST_ERROR_CLASSES: Readonly< ExtensionSupportRequiredError: RestExtensionSupportRequiredError, VersionNotSupportedError: RestVersionNotSupportedError, RequestMalformedError: RestRequestMalformedError, - GenericError: RestGenericError, }); /** * Returns the HTTP status the server should send for a given error. - * Uses the semantic spec; falls back to 500 for unknown throwables. + * Uses the semantic mapping; falls back to 500 for unknown throwables. */ export function restStatusFor(error: unknown): number { if (isRestError(error)) return error.statusCode; - if (error instanceof A2AError) return A2A_ERROR_SPECS[error.name]?.httpStatus ?? 500; + if (error instanceof A2AError) return REST_ERROR_HTTP_STATUS[error.name] ?? 500; return HTTP_STATUS.INTERNAL_SERVER_ERROR; } /** - * Serializes an error as a `google.rpc.Status` JSON body. - * The `status` field uses the gRPC status name (from the semantic - * spec if available, otherwise inferred from `httpStatus`). + * Serializes an error as a `google.rpc.Status` JSON body (§11.6). + * The `status` field uses the per-error name from + * {@link REST_ERROR_STATUS_NAME}; falls back to a coarse HTTP-status + * mapping for non-A2A throwables. */ export function toRestErrorBody(error: unknown, httpStatus: number): RestErrorBody { const message = error instanceof Error ? error.message : 'An unexpected error occurred.'; const details: ErrorDetail[] = []; - let statusName = 'UNKNOWN'; + let statusName: string = REST_STATUS_NAME.UNKNOWN; if (error instanceof A2AError) { details.push(error.toErrorInfo()); - const spec = A2A_ERROR_SPECS[error.name]; - statusName = spec ? (A2A_STATUS_NAME[spec.grpcStatus] ?? 'UNKNOWN') : 'UNKNOWN'; - } else if (httpStatus === HTTP_STATUS.NOT_FOUND) statusName = 'NOT_FOUND'; - else if (httpStatus === HTTP_STATUS.INTERNAL_SERVER_ERROR) statusName = 'INTERNAL'; - else if (httpStatus === HTTP_STATUS.BAD_REQUEST) statusName = 'INVALID_ARGUMENT'; + statusName = REST_ERROR_STATUS_NAME[error.name] ?? REST_STATUS_NAME.UNKNOWN; + } else if (httpStatus === HTTP_STATUS.NOT_FOUND) statusName = REST_STATUS_NAME.NOT_FOUND; + else if (httpStatus === HTTP_STATUS.INTERNAL_SERVER_ERROR) statusName = REST_STATUS_NAME.INTERNAL; + else if (httpStatus === HTTP_STATUS.BAD_REQUEST) statusName = REST_STATUS_NAME.INVALID_ARGUMENT; return { error: { code: httpStatus, status: statusName, message, details } }; } @@ -161,8 +240,8 @@ export function toRestErrorBody(error: unknown, httpStatus: number): RestErrorBo /** * Rebuilds a REST-specific SDK error from a parsed error body. * `details[]` is scanned for `ErrorInfo`; if found, its `reason` - * selects the semantic twin. Otherwise falls back to - * {@link RestGenericError} with the raw message. + * selects the semantic twin. Otherwise falls back to a REST-scoped + * concrete {@link A2AError} carrying the raw message. */ export function fromRestErrorBody( body: { @@ -189,5 +268,22 @@ export function fromRestErrorBody( } } } - return new RestGenericError({ message, ...transportCtx }); + return new RestA2AErrorImpl({ message, ...transportCtx }); +} + +/** + * Concrete REST-scoped {@link A2AError} used when the wire has no + * `ErrorInfo` detail (the "unknown" bucket). Kept private because + * callers shouldn't be constructing it directly. + */ +class RestA2AErrorImpl extends A2AError implements RestA2AError { + public readonly transport = 'rest'; + public readonly statusCode: number; + public readonly headers?: Record; + constructor(options?: RestA2AErrorOptions) { + super(options); + this.name = 'A2AError'; + this.statusCode = options?.statusCode ?? HTTP_STATUS.INTERNAL_SERVER_ERROR; + if (options?.headers) this.headers = options.headers; + } } diff --git a/src/server/grpc/grpc_service.ts b/src/server/grpc/grpc_service.ts index 9ce2ed72..f5e1266e 100644 --- a/src/server/grpc/grpc_service.ts +++ b/src/server/grpc/grpc_service.ts @@ -29,8 +29,12 @@ import { import { Extensions } from '../../extensions.js'; import { UserBuilder } from './common.js'; import { A2A_VERSION_HEADER, HTTP_EXTENSION_HEADER } from '../../constants.js'; -import { A2A_STATUS_CODE, A2AError } from '../../errors/index.js'; -import { buildGrpcErrorMetadata, grpcStatusFor } from '../../errors/grpc/index.js'; +import { A2AError } from '../../errors/index.js'; +import { + buildGrpcErrorMetadata, + GRPC_STATUS_CODE, + grpcStatusFor, +} from '../../errors/grpc/index.js'; import { validateVersion } from '../version.js'; /** Options for configuring the gRPC handler. */ @@ -218,7 +222,7 @@ export function grpcService(options: GrpcServiceOptions): A2AServiceServer { * (`grpcStatusFor`), so user-defined subclasses inherit the base status. */ const mapToError = (error: unknown): Partial => { - const code = error instanceof A2AError ? grpcStatusFor(error) : A2A_STATUS_CODE.UNKNOWN; + const code = error instanceof A2AError ? grpcStatusFor(error) : GRPC_STATUS_CODE.UNKNOWN; const message = error instanceof Error ? error.message : 'Internal server error'; const result: Partial = { code, details: message }; const md = buildGrpcErrorMetadata(grpc.Metadata, error); diff --git a/src/server/request_handler/default_request_handler.ts b/src/server/request_handler/default_request_handler.ts index 62222c35..4d34c435 100644 --- a/src/server/request_handler/default_request_handler.ts +++ b/src/server/request_handler/default_request_handler.ts @@ -1,9 +1,9 @@ import { v4 as uuidv4 } from 'uuid'; import { + A2AError, ExtendedAgentCardNotConfiguredError, ExtensionSupportRequiredError, - GenericError, PushNotificationNotSupportedError, RequestMalformedError, TaskNotCancelableError, @@ -625,7 +625,7 @@ export class DefaultRequestHandler implements A2ARequestHandler { const finalResult = resultManager.getFinalResult(); if (!finalResult) { reject( - new GenericError( + new A2AError( 'Agent execution finished without a result, and no task context found.' ) ); @@ -806,7 +806,7 @@ export class DefaultRequestHandler implements A2ARequestHandler { const latestTask = await this.taskStore.load(taskId, context); if (!latestTask) { - throw new GenericError(`Task ${params.id} not found after cancellation.`); + throw new A2AError(`Task ${params.id} not found after cancellation.`); } if (latestTask.status!.state != TaskState.TASK_STATE_CANCELED) { throw new TaskNotCancelableError(`Task not cancelable: ${params.id}`); @@ -846,13 +846,13 @@ export class DefaultRequestHandler implements A2ARequestHandler { const configs = (await this.pushNotificationStore?.load(taskId, context)) || []; if (configs.length === 0) { - throw new GenericError(`Push notification config not found for task ${taskId}.`); + throw new A2AError(`Push notification config not found for task ${taskId}.`); } const config = configs.find((c) => c.id === params.id); if (!config) { - throw new GenericError( + throw new A2AError( `Push notification config with id '${params.id}' not found for task ${taskId}.` ); } diff --git a/src/types/converters/from_proto.ts b/src/types/converters/from_proto.ts index 5ff74a16..e62dc959 100644 --- a/src/types/converters/from_proto.ts +++ b/src/types/converters/from_proto.ts @@ -1,4 +1,4 @@ -import { GenericError } from '../../errors/index.js'; +import { A2AError } from '../../errors/index.js'; import { Message, SendMessageResponse, Task } from '../pb/a2a.js'; /** @@ -11,6 +11,6 @@ export class FromProto { if (response.payload?.$case === 'task' || response.payload?.$case === 'message') { return response.payload.value; } - throw new GenericError('Invalid SendMessageResponse: missing result'); + throw new A2AError('Invalid SendMessageResponse: missing result'); } } diff --git a/src/types/converters/to_proto.ts b/src/types/converters/to_proto.ts index 85bf2ceb..61ca8a57 100644 --- a/src/types/converters/to_proto.ts +++ b/src/types/converters/to_proto.ts @@ -1,4 +1,4 @@ -import { GenericError } from '../../errors/index.js'; +import { A2AError } from '../../errors/index.js'; import { Message, SendMessageResponse, Task } from '../pb/a2a.js'; export class ToProto { @@ -18,6 +18,6 @@ export class ToProto { }, }; } - throw new GenericError('Invalid SendMessageResult type'); + throw new A2AError('Invalid SendMessageResult type'); } } diff --git a/test/client/util.ts b/test/client/util.ts index a941bffd..2bf7d4be 100644 --- a/test/client/util.ts +++ b/test/client/util.ts @@ -4,9 +4,10 @@ import { Role, SendMessageResponse, Task, TaskState } from '../../src/types/pb/a import { SendMessageResult } from '../../src/index.js'; import { A2A_ERROR_DOMAIN, - A2A_ERROR_SPECS_BY_CODE, + A2A_ERROR_SPECS, ERROR_INFO_TYPE, - A2A_STATUS_NAME, + JSON_RPC_CODE_TO_ERROR, + REST_ERROR_STATUS_NAME, } from '../../src/errors/index.js'; export function extractRequestId(options?: RequestInit): number { @@ -356,9 +357,10 @@ export function createRestResponse( // Resolves a JSON-RPC error code to (reason, grpcStatusName) via the registry. function resolveErrorCode(code: number): { reason: string; grpcStatus: string } | undefined { - const spec = A2A_ERROR_SPECS_BY_CODE[code]; + const name = JSON_RPC_CODE_TO_ERROR[code]; + const spec = A2A_ERROR_SPECS[name]; if (!spec) return undefined; - return { reason: spec.reason, grpcStatus: A2A_STATUS_NAME[spec.grpcStatus] ?? 'UNKNOWN' }; + return { reason: spec.reason, grpcStatus: REST_ERROR_STATUS_NAME[name] ?? 'UNKNOWN' }; } // Creates a REST error response in the google.rpc.Status JSON format. diff --git a/test/compat/v0_3/constants.spec.ts b/test/compat/v0_3/constants.spec.ts index 78d7a54b..7c754cfd 100644 --- a/test/compat/v0_3/constants.spec.ts +++ b/test/compat/v0_3/constants.spec.ts @@ -29,15 +29,14 @@ import { isLegacyJsonRpcMethod, isV1JsonRpcMethod, } from '../../../src/compat/v0_3/constants.js'; -import { A2AError as _LegacyFacade } from '../../../src/compat/v0_3/server/error.js'; -import { A2AError, isJsonRpcError, UnsupportedOperationError } from '../../../src/errors/index.js'; -void _LegacyFacade; +import { A2AError, isJsonRpcError, JSON_RPC_ERROR_CODE } from '../../../src/errors/index.js'; -/** Reads the wire code from a thrown error: envelopeCode wins over spec.code. */ -function wireCode(err: unknown): number { - return isJsonRpcError(err) ? err.envelopeCode : (err as A2AError).code; +/** Reads the wire code from a thrown error: envelopeCode wins, else per-error map. */ +function wireCode(err: unknown): number | undefined { + if (isJsonRpcError(err)) return err.envelopeCode; + if (err instanceof A2AError) return JSON_RPC_ERROR_CODE[err.name]; + return undefined; } -void UnsupportedOperationError; // silence unused import guard const ALL_LEGACY_METHODS = [ LEGACY_METHOD_MESSAGE_SEND, diff --git a/test/compat/v0_3/server/transports/jsonrpc/jsonrpc_transport_handler.spec.ts b/test/compat/v0_3/server/transports/jsonrpc/jsonrpc_transport_handler.spec.ts index b633154a..a598b26f 100644 --- a/test/compat/v0_3/server/transports/jsonrpc/jsonrpc_transport_handler.spec.ts +++ b/test/compat/v0_3/server/transports/jsonrpc/jsonrpc_transport_handler.spec.ts @@ -5,10 +5,10 @@ import { A2AError as LegacyA2AError } from '../../../../../../src/compat/v0_3/se import { A2ARequestHandler } from '../../../../../../src/server/request_handler/a2a_request_handler.js'; import { ServerCallContext } from '../../../../../../src/server/context.js'; import { + A2AError, ContentTypeNotSupportedError, ExtendedAgentCardNotConfiguredError, ExtensionSupportRequiredError, - GenericError, InvalidAgentResponseError, PushNotificationNotSupportedError, RequestMalformedError, @@ -581,7 +581,7 @@ describe('LegacyJsonRpcTransportHandler', () => { [new ExtensionSupportRequiredError('a'), -32008], [new VersionNotSupportedError('a'), -32009], [new RequestMalformedError('a'), -32602], - [new GenericError('a'), -32603], + [new A2AError('a'), -32603], ]; v1ToLegacyCodeCases.forEach(([err, expectedCode]) => { diff --git a/test/compat/v0_3/server/transports/rest/rest_transport_handler.spec.ts b/test/compat/v0_3/server/transports/rest/rest_transport_handler.spec.ts index 46867219..44cc199f 100644 --- a/test/compat/v0_3/server/transports/rest/rest_transport_handler.spec.ts +++ b/test/compat/v0_3/server/transports/rest/rest_transport_handler.spec.ts @@ -10,10 +10,10 @@ import { A2AError as LegacyA2AError } from '../../../../../../src/compat/v0_3/se import { A2ARequestHandler } from '../../../../../../src/server/request_handler/a2a_request_handler.js'; import { ServerCallContext } from '../../../../../../src/server/context.js'; import { + A2AError, ContentTypeNotSupportedError, ExtendedAgentCardNotConfiguredError, ExtensionSupportRequiredError, - GenericError, InvalidAgentResponseError, PushNotificationNotSupportedError, RequestMalformedError, @@ -218,7 +218,7 @@ describe('LegacyRestTransportHandler', () => { [new ExtensionSupportRequiredError('a'), -32008], [new VersionNotSupportedError('a'), -32009], [new RequestMalformedError('a'), -32602], - [new GenericError('a'), -32603], + [new A2AError('a'), -32603], ]; v1ToLegacyCodeCases.forEach(([err, expectedCode]) => { @@ -312,7 +312,7 @@ describe('LegacyRestTransportHandler', () => { }, defaultContext ) - ).rejects.toMatchObject({ code: -32602 }); + ).rejects.toMatchObject({ name: 'RequestMalformedError' }); }); }); @@ -325,7 +325,7 @@ describe('LegacyRestTransportHandler', () => { (mockRequestHandler.getAgentCard as Mock).mockResolvedValue(nonStreamingAgentCard); await expect( transportHandler.sendMessageStream(sampleLegacyMessageSendParams(), defaultContext) - ).rejects.toMatchObject({ code: -32004 }); + ).rejects.toMatchObject({ name: 'UnsupportedOperationError' }); }); it('translates each v1 StreamResponse event into a v0.3 result payload', async () => { @@ -414,13 +414,13 @@ describe('LegacyRestTransportHandler', () => { it('rejects an invalid historyLength (non-numeric)', async () => { await expect(transportHandler.getTask('t-1', defaultContext, 'abc')).rejects.toMatchObject({ - code: -32602, + name: 'RequestMalformedError', }); }); it('rejects a negative historyLength', async () => { await expect(transportHandler.getTask('t-1', defaultContext, '-2')).rejects.toMatchObject({ - code: -32602, + name: 'RequestMalformedError', }); }); }); @@ -447,7 +447,7 @@ describe('LegacyRestTransportHandler', () => { it('rejects when streaming is not supported', async () => { (mockRequestHandler.getAgentCard as Mock).mockResolvedValue(nonStreamingAgentCard); await expect(transportHandler.resubscribe('t-1', defaultContext)).rejects.toMatchObject({ - code: -32004, + name: 'UnsupportedOperationError', }); }); @@ -481,7 +481,7 @@ describe('LegacyRestTransportHandler', () => { sampleLegacyTaskPushNotificationConfig(), defaultContext ) - ).rejects.toMatchObject({ code: -32003 }); + ).rejects.toMatchObject({ name: 'PushNotificationNotSupportedError' }); }); it('rejects when taskId is missing', async () => { @@ -493,7 +493,7 @@ describe('LegacyRestTransportHandler', () => { }, defaultContext ) - ).rejects.toMatchObject({ code: -32602 }); + ).rejects.toMatchObject({ name: 'RequestMalformedError' }); }); it('rejects when pushNotificationConfig is missing', async () => { @@ -505,7 +505,7 @@ describe('LegacyRestTransportHandler', () => { }, defaultContext ) - ).rejects.toMatchObject({ code: -32602 }); + ).rejects.toMatchObject({ name: 'RequestMalformedError' }); }); it('translates the v0.3 config and returns a v0.3 config', async () => { diff --git a/test/compat/v0_3/translate/errors.spec.ts b/test/compat/v0_3/translate/errors.spec.ts index db180b65..797b53ce 100644 --- a/test/compat/v0_3/translate/errors.spec.ts +++ b/test/compat/v0_3/translate/errors.spec.ts @@ -2,10 +2,10 @@ import { describe, expect, it } from 'vitest'; import { toCompatErrorBody } from '../../../../src/compat/v0_3/translate/errors.js'; import { A2AError as LegacyA2AError } from '../../../../src/compat/v0_3/server/error.js'; import { + A2AError, ContentTypeNotSupportedError, ExtendedAgentCardNotConfiguredError, ExtensionSupportRequiredError, - GenericError, InvalidAgentResponseError, JsonRpcRequestMalformedError, PushNotificationNotSupportedError, @@ -31,7 +31,7 @@ const v1ToLegacyCodeCases: ReadonlyArray Error, number]> = [ // SDK-internal classes (not part of the spec but raised by the SDK // when validating requests / wrapping arbitrary throws). [() => new RequestMalformedError('a'), -32602], - [() => new GenericError('a'), -32603], + [() => new A2AError('a'), -32603], ]; describe('compat/v0_3/translate/errors - toCompatErrorBody', () => { diff --git a/test/compat/v0_3/translate/messages.spec.ts b/test/compat/v0_3/translate/messages.spec.ts index 2f1a5e80..8d3f68c8 100644 --- a/test/compat/v0_3/translate/messages.spec.ts +++ b/test/compat/v0_3/translate/messages.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { toCompatMessage, toCoreMessage } from '../../../../src/compat/v0_3/translate/messages.js'; import { A2AError } from '../../../../src/compat/v0_3/server/error.js'; +import { JSON_RPC_ERROR_CODE } from '../../../../src/errors/index.js'; import { Role } from '../../../../src/types/pb/a2a.js'; import type { Message as V1Message } from '../../../../src/types/pb/a2a.js'; import type * as legacy from '../../../../src/compat/v0_3/types/types.js'; @@ -182,7 +183,7 @@ describe('messages', () => { expect.fail('toCoreMessage should have thrown'); } catch (err) { expect(err).toBeInstanceOf(A2AError); - expect((err as A2AError).code).toBe(-32602); + expect(JSON_RPC_ERROR_CODE[(err as Error).name]).toBe(-32602); } }); }); diff --git a/test/errors.spec.ts b/test/errors.spec.ts index f4322966..be512c3e 100644 --- a/test/errors.spec.ts +++ b/test/errors.spec.ts @@ -5,7 +5,6 @@ import { A2A_ERROR_CODE, A2A_ERROR_DOMAIN, A2A_ERROR_SPECS, - A2A_ERROR_SPECS_BY_CODE, A2AError, ContentTypeNotSupportedError, ERROR_INFO_TYPE, @@ -14,12 +13,12 @@ import { extractErrorMessage, fromJsonRpcErrorResponse as mapJsonRpcErrorToSdkError, fromRestErrorBody, - GenericError, - A2A_STATUS_CODE, HTTP_STATUS, InvalidAgentResponseError, isJsonRpcError, isRestError, + JSON_RPC_CODE_TO_ERROR, + JSON_RPC_ERROR_CODE, JsonRpcRequestMalformedError, JsonRpcTaskNotFoundError, JsonRpcTransportError as JSONRPCTransportError, @@ -34,15 +33,20 @@ import { UnsupportedOperationError, VersionNotSupportedError, } from '../src/errors/index.js'; -import { GrpcTaskNotFoundError, grpcStatusFor, isGrpcError } from '../src/errors/grpc/index.js'; +import { + GRPC_STATUS_CODE, + GrpcTaskNotFoundError, + grpcStatusFor, + isGrpcError, +} from '../src/errors/grpc/index.js'; /** Thin wrapper that matches the removed `mapA2aErrorToSdkError` shape. */ function mapA2aErrorToSdkError( err: { code: number; message: string }, fallback: () => Error ): Error { - const spec = A2A_ERROR_SPECS_BY_CODE[err.code]; - if (spec) return new A2A_ERROR_CLASSES[spec.name]({ message: err.message }); + const name = JSON_RPC_CODE_TO_ERROR[err.code]; + if (name) return new A2A_ERROR_CLASSES[name]({ message: err.message }); return fallback(); } @@ -55,12 +59,13 @@ function makeEnvelope(code: number, message = 'boom'): JSONRPCErrorResponse { } describe('mapJsonRpcErrorToSdkError', () => { + // Codes that resolve to a spec-defined semantic class. The result's + // message is preserved verbatim. it.each([ [A2A_ERROR_CODE.PARSE_ERROR, RequestMalformedError], [A2A_ERROR_CODE.INVALID_REQUEST, RequestMalformedError], [A2A_ERROR_CODE.METHOD_NOT_FOUND, RequestMalformedError], [A2A_ERROR_CODE.INVALID_PARAMS, RequestMalformedError], - [A2A_ERROR_CODE.INTERNAL_ERROR, A2A_ERROR_CLASSES.GenericError], [A2A_ERROR_CODE.TASK_NOT_FOUND, TaskNotFoundError], [A2A_ERROR_CODE.TASK_NOT_CANCELABLE, TaskNotCancelableError], [A2A_ERROR_CODE.PUSH_NOTIFICATION_NOT_SUPPORTED, PushNotificationNotSupportedError], @@ -77,14 +82,22 @@ describe('mapJsonRpcErrorToSdkError', () => { expect(result.message).toBe('specific message'); }); + it('maps -32603 INTERNAL_ERROR to JsonRpcTransportError preserving the envelope code', () => { + const envelope = makeEnvelope(A2A_ERROR_CODE.INTERNAL_ERROR, 'internal boom'); + const result = mapJsonRpcErrorToSdkError(envelope); + expect(result).toBeInstanceOf(JSONRPCTransportError); + expect((result as JSONRPCTransportError).envelopeCode).toBe(A2A_ERROR_CODE.INTERNAL_ERROR); + expect(result.message).toBe('internal boom'); + }); + it('returns JSONRPCTransportError for unknown error codes', () => { const envelope = makeEnvelope(-99999, 'mysterious failure'); const result = mapJsonRpcErrorToSdkError(envelope); expect(result).toBeInstanceOf(JSONRPCTransportError); const transportError = result as JSONRPCTransportError; expect(transportError.errorResponse).toBe(envelope); - expect(transportError.message).toContain('mysterious failure'); - expect(transportError.message).toContain('-99999'); + expect(transportError.message).toBe('mysterious failure'); + expect(transportError.envelopeCode).toBe(-99999); }); it('JsonRpcTransportError sets a stable name for catch/instanceof callers', () => { @@ -191,7 +204,7 @@ describe('A2AError hierarchy', () => { expect(e).toBeInstanceOf(A2AError); expect(e).toBeInstanceOf(Error); expect(e.name).toBe('TaskNotFoundError'); - expect(e.code).toBe(-32001); + expect(JSON_RPC_ERROR_CODE[e.name]).toBe(-32001); expect(e.reason).toBe('TASK_NOT_FOUND'); }); @@ -204,7 +217,7 @@ describe('A2AError hierarchy', () => { }); it('GrpcTaskNotFoundError is-a TaskNotFoundError is-a A2AError', () => { - const e = new GrpcTaskNotFoundError({ status: A2A_STATUS_CODE.NOT_FOUND }); + const e = new GrpcTaskNotFoundError({ status: GRPC_STATUS_CODE.NOT_FOUND }); expect(e).toBeInstanceOf(GrpcTaskNotFoundError); expect(e).toBeInstanceOf(TaskNotFoundError); expect(e).toBeInstanceOf(A2AError); @@ -240,7 +253,7 @@ describe('transport type guards', () => { }); it('isGrpcError narrows only GrpcA2AError instances', () => { - expect(isGrpcError(new GrpcTaskNotFoundError({ status: A2A_STATUS_CODE.NOT_FOUND }))).toBe( + expect(isGrpcError(new GrpcTaskNotFoundError({ status: GRPC_STATUS_CODE.NOT_FOUND }))).toBe( true ); expect(isGrpcError(new RestTaskNotFoundError())).toBe(false); @@ -259,7 +272,7 @@ describe('transport type guards', () => { it('transports are mutually exclusive: exactly one guard matches per instance', () => { const rest = new RestTaskNotFoundError({ statusCode: 404 }); - const grpc = new GrpcTaskNotFoundError({ status: A2A_STATUS_CODE.NOT_FOUND }); + const grpc = new GrpcTaskNotFoundError({ status: GRPC_STATUS_CODE.NOT_FOUND }); const json = new JsonRpcTaskNotFoundError({ envelopeCode: -32001 }); expect([isRestError(rest), isGrpcError(rest), isJsonRpcError(rest)]).toEqual([ @@ -300,7 +313,8 @@ describe('A2AError construction', () => { it('defaults message from spec.defaultMessage when none is passed', () => { expect(new TaskNotFoundError().message).toBe('Task not found'); expect(new UnsupportedOperationError().message).toBe('This operation is not supported'); - expect(new GenericError().message).toBe('An unexpected error occurred.'); + // Concrete A2AError (no spec entry) falls back to a generic message. + expect(new A2AError().message).toBe('An unexpected error occurred.'); }); it('accepts a bare string as the message (legacy call shape)', () => { @@ -333,8 +347,9 @@ describe('A2AError construction', () => { const instance = new Cls(); const spec = A2A_ERROR_SPECS[name]; expect(spec).toBeDefined(); - expect(instance.code).toBe(spec.code); expect(instance.reason).toBe(spec.reason); + // Per-transport code lives in the transport-specific tables. + expect(JSON_RPC_ERROR_CODE[name]).toBeTypeOf('number'); } }); }); @@ -383,22 +398,22 @@ describe('restStatusFor', () => { describe('grpcStatusFor', () => { it('returns the instance-level status when it is a GrpcA2AError', () => { - expect(grpcStatusFor(new GrpcTaskNotFoundError({ status: A2A_STATUS_CODE.CANCELLED }))).toBe( - A2A_STATUS_CODE.CANCELLED + expect(grpcStatusFor(new GrpcTaskNotFoundError({ status: GRPC_STATUS_CODE.CANCELLED }))).toBe( + GRPC_STATUS_CODE.CANCELLED ); }); it('falls back to the spec grpcStatus for a plain semantic error', () => { - expect(grpcStatusFor(new TaskNotFoundError())).toBe(A2A_STATUS_CODE.NOT_FOUND); + expect(grpcStatusFor(new TaskNotFoundError())).toBe(GRPC_STATUS_CODE.NOT_FOUND); expect(grpcStatusFor(new ContentTypeNotSupportedError())).toBe( - A2A_STATUS_CODE.INVALID_ARGUMENT + GRPC_STATUS_CODE.INVALID_ARGUMENT ); - expect(grpcStatusFor(new InvalidAgentResponseError())).toBe(A2A_STATUS_CODE.INTERNAL); + expect(grpcStatusFor(new InvalidAgentResponseError())).toBe(GRPC_STATUS_CODE.INTERNAL); }); it('returns UNKNOWN for non-A2A throwables', () => { - expect(grpcStatusFor(new Error('unrelated'))).toBe(A2A_STATUS_CODE.UNKNOWN); - expect(grpcStatusFor(null)).toBe(A2A_STATUS_CODE.UNKNOWN); + expect(grpcStatusFor(new Error('unrelated'))).toBe(GRPC_STATUS_CODE.UNKNOWN); + expect(grpcStatusFor(null)).toBe(GRPC_STATUS_CODE.UNKNOWN); }); }); @@ -438,16 +453,17 @@ describe('REST roundtrip', () => { expect(rebuilt.message).toBe('task xyz missing'); }); - it('body without ErrorInfo detail becomes RestGenericError', () => { + it('body without ErrorInfo detail becomes a REST-scoped A2AError fallback', () => { const rebuilt = fromRestErrorBody({ message: 'plain 500', details: [] }, { statusCode: 500 }); expect(rebuilt).toBeInstanceOf(A2AError); + expect(rebuilt).not.toBeInstanceOf(TaskNotFoundError); expect(isRestError(rebuilt)).toBe(true); - expect(rebuilt.name).toBe('GenericError'); + expect(rebuilt.name).toBe('A2AError'); expect(rebuilt.message).toBe('plain 500'); expect(rebuilt.statusCode).toBe(500); }); - it('body with unknown ErrorInfo.reason becomes RestGenericError', () => { + it('body with unknown ErrorInfo.reason falls through to the A2AError fallback', () => { const rebuilt = fromRestErrorBody( { message: 'unknown', @@ -455,7 +471,7 @@ describe('REST roundtrip', () => { }, { statusCode: 500 } ); - expect(rebuilt.name).toBe('GenericError'); + expect(rebuilt.name).toBe('A2AError'); }); it('ignores metadata when the ErrorInfo.domain is not a2a-protocol.org', () => { diff --git a/test/errors_grpc.spec.ts b/test/errors_grpc.spec.ts index c104ff87..cd6da18f 100644 --- a/test/errors_grpc.spec.ts +++ b/test/errors_grpc.spec.ts @@ -7,7 +7,7 @@ import { Metadata, status as grpcStatus, type ServiceError } from '@grpc/grpc-js'; import { describe, it, expect } from 'vitest'; -import { GenericError, TaskNotFoundError } from '../src/errors/index.js'; +import { A2AError, TaskNotFoundError } from '../src/errors/index.js'; import { buildGrpcErrorMetadata, fromGrpcError, @@ -46,7 +46,7 @@ describe('gRPC roundtrip', () => { expect(buildGrpcErrorMetadata(Metadata, 'string')).toBeUndefined(); }); - it('service error without grpc-status-details-bin becomes GrpcGenericError', () => { + it('service error without grpc-status-details-bin becomes a gRPC-scoped A2AError', () => { const wireError = { code: grpcStatus.INTERNAL, details: 'boom', @@ -55,9 +55,11 @@ describe('gRPC roundtrip', () => { name: 'ServiceError', } as ServiceError; const rebuilt = fromGrpcError(wireError, 'SendMessage'); - expect(rebuilt).toBeInstanceOf(GenericError); + expect(rebuilt).toBeInstanceOf(A2AError); + expect(rebuilt).not.toBeInstanceOf(TaskNotFoundError); expect(isGrpcError(rebuilt)).toBe(true); expect(rebuilt.status).toBe(grpcStatus.INTERNAL); + expect(rebuilt.name).toBe('A2AError'); // method suffix appears in the fallback message for debuggability. expect(rebuilt.message).toContain('SendMessage'); }); diff --git a/test/server/express/express_app.spec.ts b/test/server/express/express_app.spec.ts index 26e71945..0ae80be8 100644 --- a/test/server/express/express_app.spec.ts +++ b/test/server/express/express_app.spec.ts @@ -32,7 +32,7 @@ import { LegacyJsonRpcTransportHandler } from '../../../src/compat/v0_3/server/i import { AgentCard } from '../../../src/index.js'; import { JSONRPCErrorResponse } from '../../../src/core.js'; import { AGENT_CARD_PATH, HTTP_EXTENSION_HEADER } from '../../../src/constants.js'; -import { A2A_ERROR_CODE, GenericError, RequestMalformedError } from '../../../src/errors/index.js'; +import { A2A_ERROR_CODE, A2AError, RequestMalformedError } from '../../../src/errors/index.js'; import { ServerCallContext } from '../../../src/server/context.js'; import { User, UnauthenticatedUser } from '../../../src/server/authentication/user.js'; @@ -272,7 +272,7 @@ describe('A2AExpressApp', () => { }); it('should handle general processing error', async () => { - const error = new GenericError('Processing error'); + const error = new A2AError('Processing error'); handleStub.mockRejectedValue(error); const requestBody = createRpcRequest('error-test'); @@ -811,7 +811,7 @@ describe('A2AExpressApp', () => { }); it('uses the legacy error mapper (omits data field) on legacy-path errors', async () => { - legacyHandleStub.mockRejectedValue(new GenericError('legacy boom')); + legacyHandleStub.mockRejectedValue(new A2AError('legacy boom')); const response = await request(expressApp) .post('/') diff --git a/test/server/express/rest_handler.spec.ts b/test/server/express/rest_handler.spec.ts index 5c90b3df..7f344de0 100644 --- a/test/server/express/rest_handler.spec.ts +++ b/test/server/express/rest_handler.spec.ts @@ -16,10 +16,10 @@ import { restHandler, UserBuilder } from '../../../src/server/express/index.js'; import { A2ARequestHandler } from '../../../src/server/request_handler/a2a_request_handler.js'; import { AgentCard, Task, Message, TaskState, Role } from '../../../src/index.js'; import { - GenericError, + A2AError, RequestMalformedError, - TaskNotFoundError, TaskNotCancelableError, + TaskNotFoundError, } from '../../../src/errors/index.js'; import { ListTaskPushNotificationConfigsResponse, @@ -1146,7 +1146,7 @@ describe('restHandler', () => { }); it('uses the legacy error mapper (bare body, no details[]) on legacy-path errors', async () => { - legacySendMessageStub.mockRejectedValue(new GenericError('legacy boom')); + legacySendMessageStub.mockRejectedValue(new A2AError('legacy boom')); const response = await request(dualApp) .post('/v1/message:send') diff --git a/test/server/grpc/from_proto.spec.ts b/test/server/grpc/from_proto.spec.ts index 8dd3e6eb..ad08aec4 100644 --- a/test/server/grpc/from_proto.spec.ts +++ b/test/server/grpc/from_proto.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { FromProto } from '../../../src/types/converters/from_proto.js'; import * as proto from '../../../src/types/pb/a2a.js'; -import { GenericError } from '../../../src/errors/index.js'; +import { A2AError } from '../../../src/errors/index.js'; describe('FromProto', () => { describe('sendMessageResult', () => { @@ -41,29 +41,29 @@ describe('FromProto', () => { expect(FromProto.sendMessageResult(response)).toEqual(msg); }); - it('should throw GenericError if payload is missing', () => { + it('should throw A2AError if payload is missing', () => { const response: proto.SendMessageResponse = {}; - let err: GenericError | undefined; + let err: A2AError | undefined; try { FromProto.sendMessageResult(response); } catch (error) { - err = error as GenericError; + err = error as A2AError; } - expect(err).toBeInstanceOf(GenericError); + expect(err).toBeInstanceOf(A2AError); expect(err?.message).toContain('Invalid SendMessageResponse: missing result'); }); - it('should throw GenericError if payload case is invalid', () => { + it('should throw A2AError if payload case is invalid', () => { const response = { payload: { $case: 'streamError', value: undefined as any }, } as unknown as proto.SendMessageResponse; - let err: GenericError | undefined; + let err: A2AError | undefined; try { FromProto.sendMessageResult(response); } catch (error) { - err = error as GenericError; + err = error as A2AError; } - expect(err).toBeInstanceOf(GenericError); + expect(err).toBeInstanceOf(A2AError); expect(err?.message).toContain('Invalid SendMessageResponse: missing result'); }); }); diff --git a/test/server/jsonrpc_transport_handler.spec.ts b/test/server/jsonrpc_transport_handler.spec.ts index df38c020..d35f4fc6 100644 --- a/test/server/jsonrpc_transport_handler.spec.ts +++ b/test/server/jsonrpc_transport_handler.spec.ts @@ -7,14 +7,14 @@ import { ServerCallContext } from '../../src/server/context.js'; import { RequestMalformedError, TaskNotFoundError, - TaskNotCancelableError, - PushNotificationNotSupportedError, - UnsupportedOperationError, - ContentTypeNotSupportedError, - InvalidAgentResponseError, A2A_ERROR_CODE, - GenericError, + A2AError, + ContentTypeNotSupportedError, ExtendedAgentCardNotConfiguredError, + InvalidAgentResponseError, + PushNotificationNotSupportedError, + TaskNotCancelableError, + UnsupportedOperationError, } from '../../src/errors/index.js'; describe('JsonRpcTransportHandler', () => { @@ -294,10 +294,8 @@ describe('JsonRpcTransportHandler', () => { expect(mappedError.message).to.equal('Request Malformed'); }); - it('should map GenericError to code and message', async () => { - const mappedError = JsonRpcTransportHandler.mapToJSONRPCError( - new GenericError('Generic Error') - ); + it('should map A2AError to code and message', async () => { + const mappedError = JsonRpcTransportHandler.mapToJSONRPCError(new A2AError('Generic Error')); expect(mappedError.code).to.equal(A2A_ERROR_CODE.INTERNAL_ERROR); expect(mappedError.message).to.equal('Generic Error'); }); From 9528989f2b99b9480c932f30be91c8269fd80f61 Mon Sep 17 00:00:00 2001 From: JakubWorek Date: Tue, 21 Jul 2026 09:42:08 +0000 Subject: [PATCH 09/11] refactor: update build test matrix to validate transport-specific entrypoints --- .github/workflows/build-tests.yml | 17 ++++++++++++----- package.json | 5 +++-- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-tests.yml b/.github/workflows/build-tests.yml index f5ab6793..8aadff67 100644 --- a/.github/workflows/build-tests.yml +++ b/.github/workflows/build-tests.yml @@ -1,11 +1,14 @@ # Ensures no accidental Node.js API usage in browser and Edge compatible -# entrypoints, and that gRPC-only helpers stay isolated from the pb-free -# entrypoints. The two `test-build:*` steps run in DIFFERENT environments: +# entrypoints, and that transport-scoped entrypoints resolve only their +# declared peers. Each `test-build:*` step runs in a DIFFERENT environment: # 1. workers-safe: NO optional peer deps installed (no @grpc/grpc-js, # @bufbuild/protobuf, or express). esbuild fails natively if any # Workers-safe entry pulls them in. Mirrors what a real Workers / # REST-only consumer gets. -# 2. grpc: peer deps reinstalled; the gRPC entrypoint must resolve. +# 2. grpc: @grpc/grpc-js + @bufbuild/protobuf reinstalled (still no +# express); every gRPC entry must resolve. +# 3. express: express reinstalled on top; every express entry must +# resolve. name: Run Build Tests @@ -38,6 +41,10 @@ jobs: # Workers-safe entrypoint that leaks one of them fails to resolve. - run: npm uninstall --no-save @grpc/grpc-js @bufbuild/protobuf express - run: npm run test-build:workers-safe - # Reinstall the peer deps and verify the gRPC entrypoint still bundles. - - run: npm install --no-save @grpc/grpc-js @bufbuild/protobuf express + # Reinstall only the gRPC peers; verify every gRPC entry resolves + # (and would fail here if any of them silently required express). + - run: npm install --no-save @grpc/grpc-js @bufbuild/protobuf - run: npm run test-build:grpc + # Reinstall express; verify every express entry resolves. + - run: npm install --no-save express + - run: npm run test-build:express diff --git a/package.json b/package.json index 3a761ed4..df0b315f 100644 --- a/package.json +++ b/package.json @@ -171,8 +171,9 @@ "generate": "curl https://raw.githubusercontent.com/google-a2a/A2A/refs/heads/main/specification/json/a2a.json > spec.json && node scripts/generateTypes.js && rm spec.json", "generate:compat": "curl https://raw.githubusercontent.com/a2aproject/A2A/v0.3.0/specification/json/a2a.json > compat_spec.json && node scripts/generateCompatTypes.js && rm compat_spec.json", "test-build:workers-safe": "esbuild ./dist/index.js ./dist/errors/index.js ./dist/client/index.js ./dist/server/index.js ./dist/compat/v0_3/index.js ./dist/compat/v0_3/client/index.js ./dist/compat/v0_3/server/index.js --bundle --platform=neutral --outdir=dist/tmp-checks --outbase=./dist", - "test-build:grpc": "esbuild ./dist/errors/grpc/index.js --bundle --platform=neutral --outdir=dist/tmp-checks --outbase=./dist", - "test-build": "npm run test-build:workers-safe && npm run test-build:grpc", + "test-build:grpc": "esbuild ./dist/errors/grpc/index.js ./dist/server/grpc/index.js ./dist/client/transports/grpc/index.js ./dist/compat/v0_3/server/grpc/index.js ./dist/compat/v0_3/client/transports/grpc/index.js --bundle --platform=node --outdir=dist/tmp-checks --outbase=./dist", + "test-build:express": "esbuild ./dist/server/express/index.js ./dist/compat/v0_3/server/express/index.js --bundle --platform=node --outdir=dist/tmp-checks --outbase=./dist", + "test-build": "npm run test-build:workers-safe && npm run test-build:grpc && npm run test-build:express", "itk-agent": "tsx itk/itk_agent.ts" }, "dependencies": { From cbfc1632e33879c27a43a19a57055cd1ac167cec Mon Sep 17 00:00:00 2001 From: JakubWorek Date: Tue, 21 Jul 2026 10:16:33 +0000 Subject: [PATCH 10/11] restore workflow file indentation --- .github/workflows/build-tests.yml | 45 ++++++++++++++++--------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/.github/workflows/build-tests.yml b/.github/workflows/build-tests.yml index 8aadff67..4716f501 100644 --- a/.github/workflows/build-tests.yml +++ b/.github/workflows/build-tests.yml @@ -14,9 +14,9 @@ name: Run Build Tests on: push: - branches: ['main', 'epic/**'] + branches: [ "main", "epic/**" ] pull_request: - branches: ['main', 'epic/**'] + branches: [ "main", "epic/**" ] paths-ignore: - '**.md' - 'LICENSE' @@ -25,26 +25,27 @@ on: jobs: test: + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - registry-url: 'https://registry.npmjs.org' - cache: 'npm' - - run: npm ci - - run: npm run build - # Strip ALL three optional peer deps so esbuild sees the same - # module graph a non-gRPC, non-Express consumer would. Any - # Workers-safe entrypoint that leaks one of them fails to resolve. - - run: npm uninstall --no-save @grpc/grpc-js @bufbuild/protobuf express - - run: npm run test-build:workers-safe - # Reinstall only the gRPC peers; verify every gRPC entry resolves - # (and would fail here if any of them silently required express). - - run: npm install --no-save @grpc/grpc-js @bufbuild/protobuf - - run: npm run test-build:grpc - # Reinstall express; verify every express entry resolves. - - run: npm install --no-save express - - run: npm run test-build:express + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: 'https://registry.npmjs.org' + cache: 'npm' + - run: npm ci + - run: npm run build + # Strip ALL three optional peer deps so esbuild sees the same + # module graph a non-gRPC, non-Express consumer would. Any + # Workers-safe entrypoint that leaks one of them fails to resolve. + - run: npm uninstall --no-save @grpc/grpc-js @bufbuild/protobuf express + - run: npm run test-build:workers-safe + # Reinstall only the gRPC peers; verify every gRPC entry resolves + # (and would fail here if any of them silently required express). + - run: npm install --no-save @grpc/grpc-js @bufbuild/protobuf + - run: npm run test-build:grpc + # Reinstall express; verify every express entry resolves. + - run: npm install --no-save express + - run: npm run test-build:express From 050c47746ffd05f75839a9c40d449d4b1e56010e Mon Sep 17 00:00:00 2001 From: JakubWorek Date: Wed, 22 Jul 2026 09:08:11 +0000 Subject: [PATCH 11/11] refactor: limit public api, refactor mapping methods, restructure grpc errors dir --- src/client/transports/grpc/grpc_transport.ts | 14 +- src/client/transports/rest_transport.ts | 2 +- .../v0_3/client/transports/rest_transport.ts | 9 +- src/compat/v0_3/server/error.ts | 7 +- src/compat/v0_3/translate/errors.ts | 8 +- src/errors/grpc/grpc.ts | 292 ++++++++++++++++ src/errors/grpc/index.ts | 319 ++---------------- src/errors/index.ts | 27 +- test/client/util.ts | 10 +- test/compat/v0_3/constants.spec.ts | 3 +- test/compat/v0_3/translate/messages.spec.ts | 2 +- test/errors.spec.ts | 6 +- 12 files changed, 348 insertions(+), 351 deletions(-) create mode 100644 src/errors/grpc/grpc.ts diff --git a/src/client/transports/grpc/grpc_transport.ts b/src/client/transports/grpc/grpc_transport.ts index 0501e1c7..eb4b4a4d 100644 --- a/src/client/transports/grpc/grpc_transport.ts +++ b/src/client/transports/grpc/grpc_transport.ts @@ -231,7 +231,7 @@ export class GrpcTransport implements Transport { options.signal.removeEventListener('abort', onAbort); } if (error) { - return reject(GrpcTransport.mapToError(error, method)); + return reject(fromGrpcError(error, method)); } resolve(converter(response)); } @@ -281,7 +281,7 @@ export class GrpcTransport implements Transport { } } catch (error) { if (this.isServiceError(error)) { - throw GrpcTransport.mapToError(error, method); + throw fromGrpcError(error, method); } else { throw new Error(`GRPC error for ${String(method)}!`, { cause: error, @@ -308,16 +308,6 @@ export class GrpcTransport implements Transport { } return metadata; } - - /** - * Maps a gRPC `ServiceError` to a semantic {@link import('../../../errors/index.js').GrpcA2AError} - * via `google.rpc.ErrorInfo` from `grpc-status-details-bin`. Falls - * back to `GrpcGenericError` preserving the gRPC status and details - * when no ErrorInfo is present. - */ - private static mapToError(error: grpc.ServiceError, method?: keyof A2AServiceClient): Error { - return fromGrpcError(error, method ? String(method) : undefined); - } } export class GrpcTransportFactoryOptions { diff --git a/src/client/transports/rest_transport.ts b/src/client/transports/rest_transport.ts index 67e2c1ba..cf932aae 100644 --- a/src/client/transports/rest_transport.ts +++ b/src/client/transports/rest_transport.ts @@ -326,7 +326,7 @@ export class RestTransport implements Transport { } } } catch { - // Body wasn't JSON — fall through to a generic error. + // Body wasn't JSON — fall through to a REST-scoped A2AError. } const transportCtx = { diff --git a/src/compat/v0_3/client/transports/rest_transport.ts b/src/compat/v0_3/client/transports/rest_transport.ts index 64cd172a..9cb53ecb 100644 --- a/src/compat/v0_3/client/transports/rest_transport.ts +++ b/src/compat/v0_3/client/transports/rest_transport.ts @@ -15,12 +15,9 @@ * `tasks/list`. */ -import { - A2A_ERROR_CLASSES, - InvalidAgentResponseError, - JSON_RPC_CODE_TO_ERROR, - UnsupportedOperationError, -} from '../../../../errors/index.js'; +import { A2A_ERROR_CLASSES } from '../../../../errors/base.js'; +import { JSON_RPC_CODE_TO_ERROR } from '../../../../errors/json_rpc.js'; +import { InvalidAgentResponseError, UnsupportedOperationError } from '../../../../errors/index.js'; import type { TransportProtocolName } from '../../../../core.js'; import type { SendMessageResult } from '../../../../index.js'; import type { RequestOptions } from '../../../../client/multitransport-client.js'; diff --git a/src/compat/v0_3/server/error.ts b/src/compat/v0_3/server/error.ts index 6d7f23a4..eecf51d0 100644 --- a/src/compat/v0_3/server/error.ts +++ b/src/compat/v0_3/server/error.ts @@ -6,16 +6,17 @@ * `METHOD_NOT_FOUND`) are preserved via `JsonRpc*Error.envelopeCode`. */ +import { A2A_ERROR_CLASSES, A2AError as BaseA2AError } from '../../../errors/base.js'; import { - A2A_ERROR_CLASSES, A2A_ERROR_CODE, - A2AError as BaseA2AError, - ExtendedAgentCardNotConfiguredError, JSON_RPC_CODE_TO_ERROR, JSON_RPC_ERROR_CLASSES, JsonRpcRequestMalformedError, JsonRpcTransportError, JsonRpcUnsupportedOperationError, +} from '../../../errors/json_rpc.js'; +import { + ExtendedAgentCardNotConfiguredError, PushNotificationNotSupportedError, RequestMalformedError, TaskNotCancelableError, diff --git a/src/compat/v0_3/translate/errors.ts b/src/compat/v0_3/translate/errors.ts index 5e0bb7c1..430e9389 100644 --- a/src/compat/v0_3/translate/errors.ts +++ b/src/compat/v0_3/translate/errors.ts @@ -14,12 +14,8 @@ * clients (see `server/grpc/grpc_service.ts`). */ -import { - A2A_ERROR_CODE, - A2AError, - isJsonRpcError, - JSON_RPC_ERROR_CODE, -} from '../../../errors/index.js'; +import { A2A_ERROR_CODE, isJsonRpcError, JSON_RPC_ERROR_CODE } from '../../../errors/json_rpc.js'; +import { A2AError } from '../../../errors/index.js'; import type { JSONRPCError } from '../types/types.js'; /** v0.3 REST error body: bare `{ code, message, data? }`. */ diff --git a/src/errors/grpc/grpc.ts b/src/errors/grpc/grpc.ts new file mode 100644 index 00000000..52193384 --- /dev/null +++ b/src/errors/grpc/grpc.ts @@ -0,0 +1,292 @@ +/** + * gRPC transport error subclasses and wire helpers. Absorbs the + * previous `src/server/grpc/error_details.ts`. + * + * Owns the gRPC status enum ({@link GRPC_STATUS_CODE}) and the + * per-error status mapping (§5.4). `../base.ts` intentionally carries + * only §3.3.2 fields; codes are transport-specific. + * + * gRPC does NOT carry a `cause` on the wire, so the transport context + * exposes status code + the `grpc-status-details-bin` trailing + * metadata blob only. + */ + +import type * as grpc from '@grpc/grpc-js'; +import { + A2A_ERROR_CLASSES, + A2A_ERROR_DOMAIN, + A2A_ERROR_SPECS, + A2A_ERROR_SPECS_BY_REASON, + A2AError, + type A2AErrorOptions, + ERROR_INFO_TYPE, +} from '../base.js'; +import { Any } from '../../grpc/pb/google/protobuf/any.js'; +import { ErrorInfo } from '../../grpc/pb/google/rpc/error_details.js'; +import { Status } from '../../grpc/pb/google/rpc/status.js'; + +/** Trailing metadata key for `google.rpc.Status`. */ +export const GRPC_STATUS_DETAILS_BIN = 'grpc-status-details-bin'; + +/** + * Numeric gRPC status codes. Mirrors `@grpc/grpc-js`'s `status` enum + * so callers can use these values interchangeably with the grpc-js + * runtime enum. Declared locally so `./base.ts` stays pb/grpc-free. + */ +export const GRPC_STATUS_CODE = { + OK: 0, + CANCELLED: 1, + UNKNOWN: 2, + INVALID_ARGUMENT: 3, + DEADLINE_EXCEEDED: 4, + NOT_FOUND: 5, + ALREADY_EXISTS: 6, + PERMISSION_DENIED: 7, + RESOURCE_EXHAUSTED: 8, + FAILED_PRECONDITION: 9, + ABORTED: 10, + OUT_OF_RANGE: 11, + UNIMPLEMENTED: 12, + INTERNAL: 13, + UNAVAILABLE: 14, + DATA_LOSS: 15, + UNAUTHENTICATED: 16, +} as const; + +/** Per-error gRPC status (§5.4). Semantic class name -> status code. */ +const GRPC_ERROR_STATUS: Readonly> = Object.freeze({ + TaskNotFoundError: GRPC_STATUS_CODE.NOT_FOUND, + TaskNotCancelableError: GRPC_STATUS_CODE.FAILED_PRECONDITION, + PushNotificationNotSupportedError: GRPC_STATUS_CODE.FAILED_PRECONDITION, + UnsupportedOperationError: GRPC_STATUS_CODE.FAILED_PRECONDITION, + ContentTypeNotSupportedError: GRPC_STATUS_CODE.INVALID_ARGUMENT, + InvalidAgentResponseError: GRPC_STATUS_CODE.INTERNAL, + ExtendedAgentCardNotConfiguredError: GRPC_STATUS_CODE.FAILED_PRECONDITION, + ExtensionSupportRequiredError: GRPC_STATUS_CODE.FAILED_PRECONDITION, + VersionNotSupportedError: GRPC_STATUS_CODE.FAILED_PRECONDITION, + RequestMalformedError: GRPC_STATUS_CODE.INVALID_ARGUMENT, +}); + +/** Transport context carried by every `Grpc*Error`. */ +export interface GrpcA2AError extends A2AError { + readonly transport: 'grpc'; + readonly status: number; + /** Raw `grpc-status-details-bin` blob for callers that want to re-encode. */ + readonly statusDetailsBin?: Buffer; +} + +/** Options accepted by every `Grpc*Error` constructor. */ +export interface GrpcA2AErrorOptions extends A2AErrorOptions { + /** gRPC status code. Defaults to the per-error spec value. */ + status?: number; + /** Raw `grpc-status-details-bin` blob if received on the wire. */ + statusDetailsBin?: Buffer; +} + +/** Type guard narrowing an unknown / `A2AError` to {@link GrpcA2AError}. */ +export function isGrpcError(err: unknown): err is GrpcA2AError { + return err instanceof A2AError && (err as { transport?: string }).transport === 'grpc'; +} + +function makeGrpc(name: string): new (options?: GrpcA2AErrorOptions) => GrpcA2AError { + const Base = A2A_ERROR_CLASSES[name]; + const defaultStatus = GRPC_ERROR_STATUS[name] ?? GRPC_STATUS_CODE.INTERNAL; + const cls = { + [`Grpc${name}`]: class extends Base { + public readonly transport = 'grpc'; + public readonly status: number; + public readonly statusDetailsBin?: Buffer; + constructor(options?: GrpcA2AErrorOptions) { + super(options); + this.name = name; + this.status = options?.status ?? defaultStatus; + if (options?.statusDetailsBin) this.statusDetailsBin = options.statusDetailsBin; + } + }, + }[`Grpc${name}`]; + return cls as unknown as new (options?: GrpcA2AErrorOptions) => GrpcA2AError; +} + +export const GrpcTaskNotFoundError = makeGrpc('TaskNotFoundError'); +export type GrpcTaskNotFoundError = InstanceType; + +export const GrpcTaskNotCancelableError = makeGrpc('TaskNotCancelableError'); +export type GrpcTaskNotCancelableError = InstanceType; + +export const GrpcPushNotificationNotSupportedError = makeGrpc('PushNotificationNotSupportedError'); +export type GrpcPushNotificationNotSupportedError = InstanceType< + typeof GrpcPushNotificationNotSupportedError +>; + +export const GrpcUnsupportedOperationError = makeGrpc('UnsupportedOperationError'); +export type GrpcUnsupportedOperationError = InstanceType; + +export const GrpcContentTypeNotSupportedError = makeGrpc('ContentTypeNotSupportedError'); +export type GrpcContentTypeNotSupportedError = InstanceType< + typeof GrpcContentTypeNotSupportedError +>; + +export const GrpcInvalidAgentResponseError = makeGrpc('InvalidAgentResponseError'); +export type GrpcInvalidAgentResponseError = InstanceType; + +export const GrpcExtendedAgentCardNotConfiguredError = makeGrpc( + 'ExtendedAgentCardNotConfiguredError' +); +export type GrpcExtendedAgentCardNotConfiguredError = InstanceType< + typeof GrpcExtendedAgentCardNotConfiguredError +>; + +export const GrpcExtensionSupportRequiredError = makeGrpc('ExtensionSupportRequiredError'); +export type GrpcExtensionSupportRequiredError = InstanceType< + typeof GrpcExtensionSupportRequiredError +>; + +export const GrpcVersionNotSupportedError = makeGrpc('VersionNotSupportedError'); +export type GrpcVersionNotSupportedError = InstanceType; + +export const GrpcRequestMalformedError = makeGrpc('RequestMalformedError'); +export type GrpcRequestMalformedError = InstanceType; + +/** gRPC twins indexed by their semantic parent's name. */ +const GRPC_ERROR_CLASSES: Readonly< + Record GrpcA2AError> +> = Object.freeze({ + TaskNotFoundError: GrpcTaskNotFoundError, + TaskNotCancelableError: GrpcTaskNotCancelableError, + PushNotificationNotSupportedError: GrpcPushNotificationNotSupportedError, + UnsupportedOperationError: GrpcUnsupportedOperationError, + ContentTypeNotSupportedError: GrpcContentTypeNotSupportedError, + InvalidAgentResponseError: GrpcInvalidAgentResponseError, + ExtendedAgentCardNotConfiguredError: GrpcExtendedAgentCardNotConfiguredError, + ExtensionSupportRequiredError: GrpcExtensionSupportRequiredError, + VersionNotSupportedError: GrpcVersionNotSupportedError, + RequestMalformedError: GrpcRequestMalformedError, +}); + +/** Returns the gRPC status the server should send for a given error. */ +export function grpcStatusFor(error: unknown): number { + if (isGrpcError(error)) return error.status; + if (error instanceof A2AError) return GRPC_ERROR_STATUS[error.name] ?? GRPC_STATUS_CODE.UNKNOWN; + return GRPC_STATUS_CODE.UNKNOWN; +} + +/** Encodes a `google.rpc.Status` + `ErrorInfo` blob for `grpc-status-details-bin`. */ +export function encodeGrpcStatusDetails( + status: number, + message: string, + reason: string, + metadata?: Record +): Buffer { + const errorInfoBytes = Buffer.from( + ErrorInfo.encode({ reason, domain: A2A_ERROR_DOMAIN, metadata: metadata ?? {} }).finish() + ); + return Buffer.from( + Status.encode({ + code: status, + message, + details: [{ typeUrl: ERROR_INFO_TYPE, value: errorInfoBytes }], + }).finish() + ); +} + +/** Builds trailing gRPC metadata carrying the encoded status blob, or `undefined` if unknown error. */ +export function buildGrpcErrorMetadata( + Metadata: typeof grpc.Metadata, + error: unknown +): grpc.Metadata | undefined { + if (!(error instanceof A2AError)) return undefined; + const spec = A2A_ERROR_SPECS[error.name]; + if (!spec) return undefined; + const blob = encodeGrpcStatusDetails( + grpcStatusFor(error), + error.message, + spec.reason, + error.metadata + ); + const md = new Metadata(); + md.set(GRPC_STATUS_DETAILS_BIN, blob); + return md; +} + +/** Decoded shape of a `google.rpc.Status`. */ +export interface DecodedStatus { + code: number; + message: string; + details: Any[]; +} + +/** Decoded shape of a `google.rpc.ErrorInfo`. */ +export interface DecodedErrorInfo { + reason: string; + domain: string; + metadata: Record; +} + +/** Decodes `google.rpc.Status` from a `grpc-status-details-bin` buffer. */ +export function decodeStatus(buffer: Buffer): DecodedStatus { + return Status.decode(new Uint8Array(buffer)); +} + +/** Decodes `google.rpc.ErrorInfo` from a buffer. */ +export function decodeErrorInfo(buffer: Buffer): DecodedErrorInfo { + return ErrorInfo.decode(new Uint8Array(buffer)); +} + +/** + * Rebuilds a gRPC-specific SDK error from a `grpc.ServiceError`. Reads + * `grpc-status-details-bin` for `ErrorInfo.reason`; falls back to a + * gRPC-scoped concrete {@link A2AError} carrying the raw status and + * details string. `method` is included in the fallback message for + * debuggability. + */ +export function fromGrpcError(error: grpc.ServiceError, method?: string): GrpcA2AError { + const bin = error.metadata?.get(GRPC_STATUS_DETAILS_BIN); + let statusDetailsBin: Buffer | undefined; + if (bin && bin.length > 0) { + const raw = bin[0]; + const buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(raw, 'binary'); + statusDetailsBin = buffer; + const status = decodeStatus(buffer); + for (const detail of status.details) { + if (detail.typeUrl === ERROR_INFO_TYPE) { + const info = decodeErrorInfo(detail.value); + const spec = A2A_ERROR_SPECS_BY_REASON[info.reason]; + if (spec) { + const metadata = + info.domain === A2A_ERROR_DOMAIN && info.metadata ? info.metadata : undefined; + return new GRPC_ERROR_CLASSES[spec.name]({ + message: error.details || status.message, + metadata, + status: error.code, + statusDetailsBin, + }); + } + } + } + } + const suffix = method ? ` for ${method}` : ''; + return new GrpcA2AErrorImpl({ + message: + `gRPC error${suffix}: ${error.code ?? GRPC_STATUS_CODE.UNKNOWN} ${error.details ?? ''}`.trim(), + cause: error, + status: error.code ?? GRPC_STATUS_CODE.UNKNOWN, + statusDetailsBin, + }); +} + +/** + * Concrete gRPC-scoped {@link A2AError} used when the wire has no + * `ErrorInfo` detail. Kept private because callers shouldn't be + * constructing it directly. + */ +class GrpcA2AErrorImpl extends A2AError implements GrpcA2AError { + public readonly transport = 'grpc'; + public readonly status: number; + public readonly statusDetailsBin?: Buffer; + constructor(options?: GrpcA2AErrorOptions) { + super(options); + this.name = 'A2AError'; + this.status = options?.status ?? GRPC_STATUS_CODE.UNKNOWN; + if (options?.statusDetailsBin) this.statusDetailsBin = options.statusDetailsBin; + } +} diff --git a/src/errors/grpc/index.ts b/src/errors/grpc/index.ts index 6a681af4..c2033486 100644 --- a/src/errors/grpc/index.ts +++ b/src/errors/grpc/index.ts @@ -1,294 +1,31 @@ /** - * gRPC transport error subclasses and wire helpers. Absorbs the - * previous `src/server/grpc/error_details.ts`. - * - * Owns the gRPC status enum ({@link GRPC_STATUS_CODE}) and the - * per-error status mapping (§5.4). `../base.ts` intentionally carries - * only §3.3.2 fields; codes are transport-specific. - * - * gRPC does NOT carry a `cause` on the wire, so the transport context - * exposes status code + the `grpc-status-details-bin` trailing - * metadata blob only. + * Public entrypoint for the gRPC-specific A2A error hierarchy. + * Explicit named re-exports so this file defines the API contract of + * `@a2a-js/sdk/errors/grpc`. Internal registries stay in `./grpc.ts`. */ -import type * as grpc from '@grpc/grpc-js'; -import { - A2A_ERROR_CLASSES, - A2A_ERROR_DOMAIN, - A2A_ERROR_SPECS, - A2A_ERROR_SPECS_BY_REASON, - A2AError, - type A2AErrorOptions, - ERROR_INFO_TYPE, -} from '../base.js'; -import { Any } from '../../grpc/pb/google/protobuf/any.js'; -import { ErrorInfo } from '../../grpc/pb/google/rpc/error_details.js'; -import { Status } from '../../grpc/pb/google/rpc/status.js'; - -/** Trailing metadata key for `google.rpc.Status`. */ -export const GRPC_STATUS_DETAILS_BIN = 'grpc-status-details-bin'; - -/** - * Numeric gRPC status codes. Mirrors `@grpc/grpc-js`'s `status` enum - * so callers can use these values interchangeably with the grpc-js - * runtime enum. Declared locally so `./base.ts` stays pb/grpc-free. - */ -export const GRPC_STATUS_CODE = { - OK: 0, - CANCELLED: 1, - UNKNOWN: 2, - INVALID_ARGUMENT: 3, - DEADLINE_EXCEEDED: 4, - NOT_FOUND: 5, - ALREADY_EXISTS: 6, - PERMISSION_DENIED: 7, - RESOURCE_EXHAUSTED: 8, - FAILED_PRECONDITION: 9, - ABORTED: 10, - OUT_OF_RANGE: 11, - UNIMPLEMENTED: 12, - INTERNAL: 13, - UNAVAILABLE: 14, - DATA_LOSS: 15, - UNAUTHENTICATED: 16, -} as const; - -/** - * Per-error gRPC status (§5.4). Semantic class name -> status code. - */ -export const GRPC_ERROR_STATUS: Readonly> = Object.freeze({ - TaskNotFoundError: GRPC_STATUS_CODE.NOT_FOUND, - TaskNotCancelableError: GRPC_STATUS_CODE.FAILED_PRECONDITION, - PushNotificationNotSupportedError: GRPC_STATUS_CODE.FAILED_PRECONDITION, - UnsupportedOperationError: GRPC_STATUS_CODE.FAILED_PRECONDITION, - ContentTypeNotSupportedError: GRPC_STATUS_CODE.INVALID_ARGUMENT, - InvalidAgentResponseError: GRPC_STATUS_CODE.INTERNAL, - ExtendedAgentCardNotConfiguredError: GRPC_STATUS_CODE.FAILED_PRECONDITION, - ExtensionSupportRequiredError: GRPC_STATUS_CODE.FAILED_PRECONDITION, - VersionNotSupportedError: GRPC_STATUS_CODE.FAILED_PRECONDITION, - RequestMalformedError: GRPC_STATUS_CODE.INVALID_ARGUMENT, -}); - -/** Transport context carried by every `Grpc*Error`. */ -export interface GrpcA2AError extends A2AError { - readonly transport: 'grpc'; - readonly status: number; - /** Raw `grpc-status-details-bin` blob for callers that want to re-encode. */ - readonly statusDetailsBin?: Buffer; -} - -/** Options accepted by every `Grpc*Error` constructor. */ -export interface GrpcA2AErrorOptions extends A2AErrorOptions { - /** gRPC status code. Defaults to the per-error spec value. */ - status?: number; - /** Raw `grpc-status-details-bin` blob if received on the wire. */ - statusDetailsBin?: Buffer; -} - -/** Type guard narrowing an unknown / `A2AError` to {@link GrpcA2AError}. */ -export function isGrpcError(err: unknown): err is GrpcA2AError { - return err instanceof A2AError && (err as { transport?: string }).transport === 'grpc'; -} - -function makeGrpc(name: string): new (options?: GrpcA2AErrorOptions) => GrpcA2AError { - const Base = A2A_ERROR_CLASSES[name]; - const defaultStatus = GRPC_ERROR_STATUS[name] ?? GRPC_STATUS_CODE.INTERNAL; - const cls = { - [`Grpc${name}`]: class extends Base { - public readonly transport = 'grpc'; - public readonly status: number; - public readonly statusDetailsBin?: Buffer; - constructor(options?: GrpcA2AErrorOptions) { - super(options); - this.name = name; - this.status = options?.status ?? defaultStatus; - if (options?.statusDetailsBin) this.statusDetailsBin = options.statusDetailsBin; - } - }, - }[`Grpc${name}`]; - return cls as unknown as new (options?: GrpcA2AErrorOptions) => GrpcA2AError; -} - -export const GrpcTaskNotFoundError = makeGrpc('TaskNotFoundError'); -export type GrpcTaskNotFoundError = InstanceType; - -export const GrpcTaskNotCancelableError = makeGrpc('TaskNotCancelableError'); -export type GrpcTaskNotCancelableError = InstanceType; - -export const GrpcPushNotificationNotSupportedError = makeGrpc('PushNotificationNotSupportedError'); -export type GrpcPushNotificationNotSupportedError = InstanceType< - typeof GrpcPushNotificationNotSupportedError ->; - -export const GrpcUnsupportedOperationError = makeGrpc('UnsupportedOperationError'); -export type GrpcUnsupportedOperationError = InstanceType; - -export const GrpcContentTypeNotSupportedError = makeGrpc('ContentTypeNotSupportedError'); -export type GrpcContentTypeNotSupportedError = InstanceType< - typeof GrpcContentTypeNotSupportedError ->; - -export const GrpcInvalidAgentResponseError = makeGrpc('InvalidAgentResponseError'); -export type GrpcInvalidAgentResponseError = InstanceType; - -export const GrpcExtendedAgentCardNotConfiguredError = makeGrpc( - 'ExtendedAgentCardNotConfiguredError' -); -export type GrpcExtendedAgentCardNotConfiguredError = InstanceType< - typeof GrpcExtendedAgentCardNotConfiguredError ->; - -export const GrpcExtensionSupportRequiredError = makeGrpc('ExtensionSupportRequiredError'); -export type GrpcExtensionSupportRequiredError = InstanceType< - typeof GrpcExtensionSupportRequiredError ->; - -export const GrpcVersionNotSupportedError = makeGrpc('VersionNotSupportedError'); -export type GrpcVersionNotSupportedError = InstanceType; - -export const GrpcRequestMalformedError = makeGrpc('RequestMalformedError'); -export type GrpcRequestMalformedError = InstanceType; - -/** gRPC twins indexed by their semantic parent's name. */ -export const GRPC_ERROR_CLASSES: Readonly< - Record GrpcA2AError> -> = Object.freeze({ - TaskNotFoundError: GrpcTaskNotFoundError, - TaskNotCancelableError: GrpcTaskNotCancelableError, - PushNotificationNotSupportedError: GrpcPushNotificationNotSupportedError, - UnsupportedOperationError: GrpcUnsupportedOperationError, - ContentTypeNotSupportedError: GrpcContentTypeNotSupportedError, - InvalidAgentResponseError: GrpcInvalidAgentResponseError, - ExtendedAgentCardNotConfiguredError: GrpcExtendedAgentCardNotConfiguredError, - ExtensionSupportRequiredError: GrpcExtensionSupportRequiredError, - VersionNotSupportedError: GrpcVersionNotSupportedError, - RequestMalformedError: GrpcRequestMalformedError, -}); - -/** Returns the gRPC status the server should send for a given error. */ -export function grpcStatusFor(error: unknown): number { - if (isGrpcError(error)) return error.status; - if (error instanceof A2AError) return GRPC_ERROR_STATUS[error.name] ?? GRPC_STATUS_CODE.UNKNOWN; - return GRPC_STATUS_CODE.UNKNOWN; -} - -/** Encodes a `google.rpc.Status` + `ErrorInfo` blob for `grpc-status-details-bin`. */ -export function encodeGrpcStatusDetails( - status: number, - message: string, - reason: string, - metadata?: Record -): Buffer { - const errorInfoBytes = Buffer.from( - ErrorInfo.encode({ reason, domain: A2A_ERROR_DOMAIN, metadata: metadata ?? {} }).finish() - ); - return Buffer.from( - Status.encode({ - code: status, - message, - details: [{ typeUrl: ERROR_INFO_TYPE, value: errorInfoBytes }], - }).finish() - ); -} - -/** Builds trailing gRPC metadata carrying the encoded status blob, or `undefined` if unknown error. */ -export function buildGrpcErrorMetadata( - Metadata: typeof grpc.Metadata, - error: unknown -): grpc.Metadata | undefined { - if (!(error instanceof A2AError)) return undefined; - const spec = A2A_ERROR_SPECS[error.name]; - if (!spec) return undefined; - const blob = encodeGrpcStatusDetails( - grpcStatusFor(error), - error.message, - spec.reason, - error.metadata - ); - const md = new Metadata(); - md.set(GRPC_STATUS_DETAILS_BIN, blob); - return md; -} - -/** Decoded shape of a `google.rpc.Status`. */ -export interface DecodedStatus { - code: number; - message: string; - details: Any[]; -} - -/** Decoded shape of a `google.rpc.ErrorInfo`. */ -export interface DecodedErrorInfo { - reason: string; - domain: string; - metadata: Record; -} - -/** Decodes `google.rpc.Status` from a `grpc-status-details-bin` buffer. */ -export function decodeStatus(buffer: Buffer): DecodedStatus { - return Status.decode(new Uint8Array(buffer)); -} - -/** Decodes `google.rpc.ErrorInfo` from a buffer. */ -export function decodeErrorInfo(buffer: Buffer): DecodedErrorInfo { - return ErrorInfo.decode(new Uint8Array(buffer)); -} - -/** - * Rebuilds a gRPC-specific SDK error from a `grpc.ServiceError`. Reads - * `grpc-status-details-bin` for `ErrorInfo.reason`; falls back to a - * gRPC-scoped concrete {@link A2AError} carrying the raw status and - * details string. `method` is included in the fallback message for - * debuggability. - */ -export function fromGrpcError(error: grpc.ServiceError, method?: string): GrpcA2AError { - const bin = error.metadata?.get(GRPC_STATUS_DETAILS_BIN); - let statusDetailsBin: Buffer | undefined; - if (bin && bin.length > 0) { - const raw = bin[0]; - const buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(raw, 'binary'); - statusDetailsBin = buffer; - const status = decodeStatus(buffer); - for (const detail of status.details) { - if (detail.typeUrl === ERROR_INFO_TYPE) { - const info = decodeErrorInfo(detail.value); - const spec = A2A_ERROR_SPECS_BY_REASON[info.reason]; - if (spec) { - const metadata = - info.domain === A2A_ERROR_DOMAIN && info.metadata ? info.metadata : undefined; - return new GRPC_ERROR_CLASSES[spec.name]({ - message: error.details || status.message, - metadata, - status: error.code, - statusDetailsBin, - }); - } - } - } - } - const suffix = method ? ` for ${method}` : ''; - return new GrpcA2AErrorImpl({ - message: - `gRPC error${suffix}: ${error.code ?? GRPC_STATUS_CODE.UNKNOWN} ${error.details ?? ''}`.trim(), - cause: error, - status: error.code ?? GRPC_STATUS_CODE.UNKNOWN, - statusDetailsBin, - }); -} - -/** - * Concrete gRPC-scoped {@link A2AError} used when the wire has no - * `ErrorInfo` detail. Kept private because callers shouldn't be - * constructing it directly. - */ -class GrpcA2AErrorImpl extends A2AError implements GrpcA2AError { - public readonly transport = 'grpc'; - public readonly status: number; - public readonly statusDetailsBin?: Buffer; - constructor(options?: GrpcA2AErrorOptions) { - super(options); - this.name = 'A2AError'; - this.status = options?.status ?? GRPC_STATUS_CODE.UNKNOWN; - if (options?.statusDetailsBin) this.statusDetailsBin = options.statusDetailsBin; - } -} +export { + buildGrpcErrorMetadata, + type DecodedErrorInfo, + type DecodedStatus, + decodeErrorInfo, + decodeStatus, + encodeGrpcStatusDetails, + fromGrpcError, + type GrpcA2AError, + type GrpcA2AErrorOptions, + GrpcContentTypeNotSupportedError, + GrpcExtendedAgentCardNotConfiguredError, + GrpcExtensionSupportRequiredError, + GrpcInvalidAgentResponseError, + GrpcPushNotificationNotSupportedError, + GrpcRequestMalformedError, + grpcStatusFor, + GrpcTaskNotCancelableError, + GrpcTaskNotFoundError, + GrpcUnsupportedOperationError, + GrpcVersionNotSupportedError, + GRPC_STATUS_CODE, + GRPC_STATUS_DETAILS_BIN, + isGrpcError, +} from './grpc.js'; diff --git a/src/errors/index.ts b/src/errors/index.ts index e3090b32..56b2a8bf 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -1,8 +1,10 @@ /** * Public entrypoint for the transport-agnostic A2A error hierarchy. - * Explicit named re-exports so this file defines the API contract of - * `@a2a-js/sdk/errors`; a `base.ts` helper added tomorrow does NOT - * accidentally become public API. + * Contract of `@a2a-js/sdk/errors`: semantic error classes, transport + * variants, type guards, wire helpers, and the constants a caller + * needs to construct or interpret them. Internal registries (spec + * tables, class maps) stay in `./base.ts` / `./rest.ts` / `./json_rpc.ts` + * and are consumed only inside `src/`. * * gRPC errors live at `@a2a-js/sdk/errors/grpc` because their * encode/decode helpers pull `@bufbuild/protobuf`. @@ -11,14 +13,9 @@ // --- base --- export { A2A_ERROR_DOMAIN, - A2A_ERROR_CLASSES, - A2A_ERROR_SPECS, - A2A_ERROR_SPECS_BY_REASON, A2AError, - type A2AErrorClass, type A2AErrorInfo, type A2AErrorOptions, - type A2AErrorSpec, ContentTypeNotSupportedError, ERROR_INFO_TYPE, type ErrorDetail, @@ -28,7 +25,6 @@ export { InvalidAgentResponseError, PushNotificationNotSupportedError, RequestMalformedError, - specForError, TaskNotCancelableError, TaskNotFoundError, UnsupportedOperationError, @@ -37,27 +33,23 @@ export { // --- REST transport --- export { + fromRestErrorBody, HTTP_STATUS, isRestError, - REST_ERROR_CLASSES, - REST_ERROR_HTTP_STATUS, - REST_ERROR_STATUS_NAME, - REST_STATUS_NAME, type RestA2AError, type RestA2AErrorOptions, - type RestErrorBody, RestContentTypeNotSupportedError, + type RestErrorBody, RestExtendedAgentCardNotConfiguredError, RestExtensionSupportRequiredError, RestInvalidAgentResponseError, RestPushNotificationNotSupportedError, RestRequestMalformedError, + restStatusFor, RestTaskNotCancelableError, RestTaskNotFoundError, RestUnsupportedOperationError, RestVersionNotSupportedError, - fromRestErrorBody, - restStatusFor, toRestErrorBody, } from './rest.js'; @@ -66,9 +58,6 @@ export { A2A_ERROR_CODE, fromJsonRpcErrorResponse, isJsonRpcError, - JSON_RPC_CODE_TO_ERROR, - JSON_RPC_ERROR_CLASSES, - JSON_RPC_ERROR_CODE, type JsonRpcA2AError, type JsonRpcA2AErrorOptions, JsonRpcContentTypeNotSupportedError, diff --git a/test/client/util.ts b/test/client/util.ts index 2bf7d4be..4c26c594 100644 --- a/test/client/util.ts +++ b/test/client/util.ts @@ -2,13 +2,9 @@ import { vi, Mock } from 'vitest'; import { AGENT_CARD_PATH, JSON_CONTENT_TYPE } from '../../src/constants.js'; import { Role, SendMessageResponse, Task, TaskState } from '../../src/types/pb/a2a.js'; import { SendMessageResult } from '../../src/index.js'; -import { - A2A_ERROR_DOMAIN, - A2A_ERROR_SPECS, - ERROR_INFO_TYPE, - JSON_RPC_CODE_TO_ERROR, - REST_ERROR_STATUS_NAME, -} from '../../src/errors/index.js'; +import { A2A_ERROR_DOMAIN, A2A_ERROR_SPECS, ERROR_INFO_TYPE } from '../../src/errors/base.js'; +import { JSON_RPC_CODE_TO_ERROR } from '../../src/errors/json_rpc.js'; +import { REST_ERROR_STATUS_NAME } from '../../src/errors/rest.js'; export function extractRequestId(options?: RequestInit): number { if (!options?.body) { diff --git a/test/compat/v0_3/constants.spec.ts b/test/compat/v0_3/constants.spec.ts index 7c754cfd..aaab59eb 100644 --- a/test/compat/v0_3/constants.spec.ts +++ b/test/compat/v0_3/constants.spec.ts @@ -29,7 +29,8 @@ import { isLegacyJsonRpcMethod, isV1JsonRpcMethod, } from '../../../src/compat/v0_3/constants.js'; -import { A2AError, isJsonRpcError, JSON_RPC_ERROR_CODE } from '../../../src/errors/index.js'; +import { A2AError, isJsonRpcError } from '../../../src/errors/index.js'; +import { JSON_RPC_ERROR_CODE } from '../../../src/errors/json_rpc.js'; /** Reads the wire code from a thrown error: envelopeCode wins, else per-error map. */ function wireCode(err: unknown): number | undefined { diff --git a/test/compat/v0_3/translate/messages.spec.ts b/test/compat/v0_3/translate/messages.spec.ts index 8d3f68c8..95df55ca 100644 --- a/test/compat/v0_3/translate/messages.spec.ts +++ b/test/compat/v0_3/translate/messages.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { toCompatMessage, toCoreMessage } from '../../../../src/compat/v0_3/translate/messages.js'; import { A2AError } from '../../../../src/compat/v0_3/server/error.js'; -import { JSON_RPC_ERROR_CODE } from '../../../../src/errors/index.js'; +import { JSON_RPC_ERROR_CODE } from '../../../../src/errors/json_rpc.js'; import { Role } from '../../../../src/types/pb/a2a.js'; import type { Message as V1Message } from '../../../../src/types/pb/a2a.js'; import type * as legacy from '../../../../src/compat/v0_3/types/types.js'; diff --git a/test/errors.spec.ts b/test/errors.spec.ts index be512c3e..0348cfac 100644 --- a/test/errors.spec.ts +++ b/test/errors.spec.ts @@ -1,10 +1,10 @@ import { describe, it, expect, vi } from 'vitest'; import type { JSONRPCErrorResponse } from '../src/core.js'; +import { A2A_ERROR_CLASSES, A2A_ERROR_SPECS } from '../src/errors/base.js'; +import { JSON_RPC_CODE_TO_ERROR, JSON_RPC_ERROR_CODE } from '../src/errors/json_rpc.js'; import { - A2A_ERROR_CLASSES, A2A_ERROR_CODE, A2A_ERROR_DOMAIN, - A2A_ERROR_SPECS, A2AError, ContentTypeNotSupportedError, ERROR_INFO_TYPE, @@ -17,8 +17,6 @@ import { InvalidAgentResponseError, isJsonRpcError, isRestError, - JSON_RPC_CODE_TO_ERROR, - JSON_RPC_ERROR_CODE, JsonRpcRequestMalformedError, JsonRpcTaskNotFoundError, JsonRpcTransportError as JSONRPCTransportError,