diff --git a/.github/workflows/build-tests.yml b/.github/workflows/build-tests.yml index 01a22d70..4716f501 100644 --- a/.github/workflows/build-tests.yml +++ b/.github/workflows/build-tests.yml @@ -1,4 +1,14 @@ -# Ensures no accidential Node.js API usage in browser and Edge compatible entrypoints. +# Ensures no accidental Node.js API usage in browser and Edge compatible +# 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: @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 @@ -27,4 +37,15 @@ jobs: cache: 'npm' - run: npm ci - run: npm run build - - run: npm run test-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 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 8c08d963..01160d6f 100644 --- a/docs/migration-guide.md +++ b/docs/migration-guide.md @@ -267,8 +267,12 @@ 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`, …). 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 @@ -277,18 +281,49 @@ 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/errors'; +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`. `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 +`isRestError` / `isGrpcError` / `isJsonRpcError` type guards. All catch-side +API surfaces are on the base: + +```typescript +import { isRestError, TaskNotFoundError } from '@a2a-js/sdk/errors'; + +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']); + } + } +} +``` + +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). +[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/errors`. ### 3.3 `ServerCallContext` -- Now Mandatory @@ -305,7 +340,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 +480,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/errors'` (or `@a2a-js/sdk/errors/grpc` for gRPC helpers) | diff --git a/package.json b/package.json index 1373819d..df0b315f 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,10 @@ "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: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 ./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": { 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..eb4b4a4d 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/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'; @@ -232,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)); } @@ -282,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, @@ -309,45 +308,6 @@ 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 - * 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, - }); - } } export class GrpcTransportFactoryOptions { 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..cf932aae 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'; @@ -326,18 +326,30 @@ 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 = { + 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..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,16 +17,12 @@ 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 { 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'; import type { Transport } from '../../../../../client/transports/transport.js'; -import { decodeErrorInfo, decodeStatus } from '../../../../../server/grpc/error_details.js'; import { A2AServiceClient, type CreateTaskPushNotificationConfigRequest, @@ -331,7 +327,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)); @@ -376,7 +372,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 { @@ -419,42 +415,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 - * a generic `Error` 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; - } } 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..9cb53ecb 100644 --- a/src/compat/v0_3/client/transports/rest_transport.ts +++ b/src/compat/v0_3/client/transports/rest_transport.ts @@ -15,11 +15,9 @@ * `tasks/list`. */ -import { - InvalidAgentResponseError, - mapA2aErrorToSdkError, - UnsupportedOperationError, -} from '../../../../errors.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'; @@ -421,10 +419,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 +445,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 +453,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 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}`); + } + 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..eecf51d0 100644 --- a/src/compat/v0_3/server/error.ts +++ b/src/compat/v0_3/server/error.ts @@ -1,75 +1,146 @@ -import * as schema from '../types/types.js'; - /** - * v0.3 compat-layer error carrying a JSON-RPC error code and optional - * task ID context. + * 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`. */ -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, - }; +import { A2A_ERROR_CLASSES, A2AError as BaseA2AError } from '../../../errors/base.js'; +import { + A2A_ERROR_CODE, + JSON_RPC_CODE_TO_ERROR, + JSON_RPC_ERROR_CLASSES, + JsonRpcRequestMalformedError, + JsonRpcTransportError, + JsonRpcUnsupportedOperationError, +} from '../../../errors/json_rpc.js'; +import { + ExtendedAgentCardNotConfiguredError, + PushNotificationNotSupportedError, + RequestMalformedError, + TaskNotCancelableError, + TaskNotFoundError, + UnsupportedOperationError, +} from '../../../errors/index.js'; - 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); - } +/** + * 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. + */ +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; +} - static pushNotificationNotSupported(): A2AError { - return new A2AError(-32003, 'Push Notification is not supported'); +function makeA2AError(code: number, message: string, data?: Record): BaseA2AError { + // Codes with a semantic twin (§5.4). + const name = JSON_RPC_CODE_TO_ERROR[code]; + if (name) { + return data === undefined + ? new A2A_ERROR_CLASSES[name]({ message }) + : new JSON_RPC_ERROR_CLASSES[name]({ message, envelopeCode: code, data: data as never }); } - - static unsupportedOperation(operation: string): A2AError { - return new A2AError(-32004, `Unsupported operation: ${operation}`); + // 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, + envelopeCode: code, + data: data as never, + }); } - - static authenticatedExtendedCardNotConfigured(): A2AError { - return new A2AError(-32007, `Extended card not configured.`); + if (code === A2A_ERROR_CODE.PARSE_ERROR || code === A2A_ERROR_CODE.INVALID_REQUEST) { + return new JsonRpcRequestMalformedError({ + message, + envelopeCode: code, + data: data as never, + }); } + return new JsonRpcTransportError({ + jsonrpc: '2.0', + id: null, + error: { code, message, data }, + }); } + +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) => + 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}` }); + +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..6e0e5569 100644 --- a/src/compat/v0_3/server/grpc/grpc_service.ts +++ b/src/compat/v0_3/server/grpc/grpc_service.ts @@ -35,27 +35,19 @@ 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 { A2AError, InvalidAgentResponseError, isJsonRpcError } from '../../../../errors/index.js'; import { - ContentTypeNotSupportedError, - ExtendedAgentCardNotConfiguredError, - ExtensionSupportRequiredError, - GenericError, - InvalidAgentResponseError, - PushNotificationNotSupportedError, - RequestMalformedError, - TaskNotCancelableError, - TaskNotFoundError, - UnsupportedOperationError, - VersionNotSupportedError, -} from '../../../../errors.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'; -import { A2AError as LegacyA2AError } from '../error.js'; + import { extractTaskAndPushNotificationConfigId, extractTaskId, @@ -437,58 +429,30 @@ 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_CODE.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..430e9389 100644 --- a/src/compat/v0_3/translate/errors.ts +++ b/src/compat/v0_3/translate/errors.ts @@ -1,20 +1,21 @@ /** - * 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, 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? }`. */ @@ -25,43 +26,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 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 }; } - -/** - * 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..0b56e308 --- /dev/null +++ b/src/errors/base.ts @@ -0,0 +1,261 @@ +/** + * 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. + * + * 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. + */ + +/** 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'; + +/** 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; +} + +/** 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 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` (§10.6 / §11.6). */ + public readonly reason: string = 'INTERNAL_ERROR'; + /** Optional `google.rpc.ErrorInfo.metadata`. */ + public readonly metadata?: Record; + + constructor(options?: A2AErrorOptions | string) { + const opts = typeof options === 'string' ? { message: options } : options; + super( + opts?.message ?? 'An unexpected error occurred.', + opts?.cause !== undefined ? { cause: opts.cause } : undefined + ); + this.name = new.target.name; + if (opts?.metadata && Object.keys(opts.metadata).length > 0) { + this.metadata = opts.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. 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; + defaultMessage: string; +} + +const specs: A2AErrorSpec[] = [ + { name: 'TaskNotFoundError', reason: 'TASK_NOT_FOUND', defaultMessage: 'Task not found' }, + { + name: 'TaskNotCancelableError', + reason: 'TASK_NOT_CANCELABLE', + defaultMessage: 'Task cannot be canceled', + }, + { + name: 'PushNotificationNotSupportedError', + reason: 'PUSH_NOTIFICATION_NOT_SUPPORTED', + defaultMessage: 'Push Notification is not supported', + }, + { + name: 'UnsupportedOperationError', + reason: 'UNSUPPORTED_OPERATION', + defaultMessage: 'This operation is not supported', + }, + { + name: 'ContentTypeNotSupportedError', + reason: 'CONTENT_TYPE_NOT_SUPPORTED', + defaultMessage: 'Incompatible content types', + }, + { + name: 'InvalidAgentResponseError', + reason: 'INVALID_AGENT_RESPONSE', + defaultMessage: 'Invalid agent response type', + }, + { + name: 'ExtendedAgentCardNotConfiguredError', + reason: 'EXTENDED_AGENT_CARD_NOT_CONFIGURED', + defaultMessage: 'Extended Agent Card not configured', + }, + { + name: 'ExtensionSupportRequiredError', + reason: 'EXTENSION_SUPPORT_REQUIRED', + defaultMessage: 'Extension support required', + }, + { + name: 'VersionNotSupportedError', + reason: 'VERSION_NOT_SUPPORTED', + defaultMessage: 'Version not supported', + }, + { + name: 'RequestMalformedError', + reason: 'INVALID_PARAMS', + defaultMessage: 'Request malformed', + }, +]; + +/** 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])) +); + +/** + * 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`, 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; + constructor(options?: A2AErrorOptions | string) { + // 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]; + 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; + +/** 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, +}); + +/** + * 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 (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]; +} 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 new file mode 100644 index 00000000..c2033486 --- /dev/null +++ b/src/errors/grpc/index.ts @@ -0,0 +1,31 @@ +/** + * 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`. + */ + +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 new file mode 100644 index 00000000..56b2a8bf --- /dev/null +++ b/src/errors/index.ts @@ -0,0 +1,75 @@ +/** + * Public entrypoint for the transport-agnostic A2A error hierarchy. + * 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`. + */ + +// --- base --- +export { + A2A_ERROR_DOMAIN, + A2AError, + type A2AErrorInfo, + type A2AErrorOptions, + ContentTypeNotSupportedError, + ERROR_INFO_TYPE, + type ErrorDetail, + ExtendedAgentCardNotConfiguredError, + ExtensionSupportRequiredError, + extractErrorMessage, + InvalidAgentResponseError, + PushNotificationNotSupportedError, + RequestMalformedError, + TaskNotCancelableError, + TaskNotFoundError, + UnsupportedOperationError, + VersionNotSupportedError, +} from './base.js'; + +// --- REST transport --- +export { + fromRestErrorBody, + HTTP_STATUS, + isRestError, + type RestA2AError, + type RestA2AErrorOptions, + RestContentTypeNotSupportedError, + type RestErrorBody, + RestExtendedAgentCardNotConfiguredError, + RestExtensionSupportRequiredError, + RestInvalidAgentResponseError, + RestPushNotificationNotSupportedError, + RestRequestMalformedError, + restStatusFor, + RestTaskNotCancelableError, + RestTaskNotFoundError, + RestUnsupportedOperationError, + RestVersionNotSupportedError, + toRestErrorBody, +} from './rest.js'; + +// --- JSON-RPC transport --- +export { + A2A_ERROR_CODE, + fromJsonRpcErrorResponse, + isJsonRpcError, + 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 new file mode 100644 index 00000000..25475e2e --- /dev/null +++ b/src/errors/json_rpc.ts @@ -0,0 +1,233 @@ +/** + * 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_SPECS, + 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'; + 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 per-error 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 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'; + public readonly envelopeCode: number; + public readonly data?: JSONRPCError['data']; + constructor(options?: JsonRpcA2AErrorOptions) { + super(options); + this.name = name; + this.envelopeCode = options?.envelopeCode ?? defaultCode; + 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; + +/** 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, +}); + +/** + * Envelope for a JSON-RPC error not covered by any semantic code (e.g. + * `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 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: envelope.error.message }); + this.name = 'JsonRpcTransportError'; + this.envelopeCode = envelope.error.code; + if (envelope.error.data !== undefined) this.data = envelope.error.data; + 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 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 }; +} + +/** + * 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 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, + 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..c42b8dec --- /dev/null +++ b/src/errors/rest.ts @@ -0,0 +1,289 @@ +/** + * 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}. + * + * 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_BY_REASON, + A2AError, + type A2AErrorOptions, + ERROR_INFO_TYPE, + type ErrorDetail, +} 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'; + 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 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'; + 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 ?? defaultStatus; + 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; + +/** 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, +}); + +/** + * Returns the HTTP status the server should send for a given error. + * 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 REST_ERROR_HTTP_STATUS[error.name] ?? 500; + return HTTP_STATUS.INTERNAL_SERVER_ERROR; +} + +/** + * 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: string = REST_STATUS_NAME.UNKNOWN; + + if (error instanceof A2AError) { + details.push(error.toErrorInfo()); + 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 } }; +} + +/** + * 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 a REST-scoped + * concrete {@link A2AError} carrying 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 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/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..f5e1266e 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 { A2AError } from '../../errors/index.js'; import { - ContentTypeNotSupportedError, - ExtendedAgentCardNotConfiguredError, - ExtensionSupportRequiredError, - GenericError, - InvalidAgentResponseError, - PushNotificationNotSupportedError, - RequestMalformedError, - TaskNotCancelableError, - TaskNotFoundError, - UnsupportedOperationError, - VersionNotSupportedError, -} from '../../errors.js'; + buildGrpcErrorMetadata, + GRPC_STATUS_CODE, + grpcStatusFor, +} from '../../errors/grpc/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_CODE.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..4d34c435 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, + A2AError, + ExtendedAgentCardNotConfiguredError, + ExtensionSupportRequiredError, 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. @@ -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/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..e62dc959 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 { 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 f088eb53..61ca8a57 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 { 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/transports/grpc_transport.spec.ts b/test/client/transports/grpc_transport.spec.ts index 657e411b..85e844ec 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/grpc/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..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_CODE_TO_CLASS, - A2A_ERROR_DOMAIN, - A2A_ERROR_GRPC_STATUS, - A2A_ERROR_REASON, - ERROR_INFO_TYPE, -} from '../../src/errors.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) { @@ -355,15 +351,12 @@ 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 name = JSON_RPC_CODE_TO_ERROR[code]; + const spec = A2A_ERROR_SPECS[name]; + if (!spec) return undefined; + 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/client/transports/grpc/grpc_transport.spec.ts b/test/compat/v0_3/client/transports/grpc/grpc_transport.spec.ts index a14fdbac..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 @@ -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/grpc/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..aaab59eb 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, 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 { + if (isJsonRpcError(err)) return err.envelopeCode; + if (err instanceof A2AError) return JSON_RPC_ERROR_CODE[err.name]; + return undefined; +} 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..460e6c5e 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/grpc/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..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, @@ -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, @@ -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 f0b53c37..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, @@ -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, @@ -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/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..797b53ce 100644 --- a/test/compat/v0_3/translate/errors.spec.ts +++ b/test/compat/v0_3/translate/errors.spec.ts @@ -2,18 +2,19 @@ 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, RequestMalformedError, TaskNotCancelableError, TaskNotFoundError, UnsupportedOperationError, VersionNotSupportedError, -} from '../../../../src/errors.js'; +} from '../../../../src/errors/index.js'; const v1ToLegacyCodeCases: ReadonlyArray Error, number]> = [ [() => new TaskNotFoundError('a'), -32001], @@ -30,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', () => { @@ -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/compat/v0_3/translate/messages.spec.ts b/test/compat/v0_3/translate/messages.spec.ts index 2f1a5e80..95df55ca 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/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'; @@ -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/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..0348cfac 100644 --- a/test/errors.spec.ts +++ b/test/errors.spec.ts @@ -1,22 +1,52 @@ 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_CODE, + A2A_ERROR_DOMAIN, + A2AError, ContentTypeNotSupportedError, + ERROR_INFO_TYPE, ExtendedAgentCardNotConfiguredError, ExtensionSupportRequiredError, + extractErrorMessage, + fromJsonRpcErrorResponse as mapJsonRpcErrorToSdkError, + fromRestErrorBody, + HTTP_STATUS, InvalidAgentResponseError, - JSONRPCTransportError, + isJsonRpcError, + isRestError, + JsonRpcRequestMalformedError, + JsonRpcTaskNotFoundError, + JsonRpcTransportError as JSONRPCTransportError, PushNotificationNotSupportedError, RequestMalformedError, + RestTaskNotFoundError, + restStatusFor, TaskNotCancelableError, TaskNotFoundError, + toJsonRpcError, + toRestErrorBody, UnsupportedOperationError, VersionNotSupportedError, - mapA2aErrorToSdkError, - mapJsonRpcErrorToSdkError, - extractErrorMessage, -} from '../src/errors.js'; +} from '../src/errors/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 name = JSON_RPC_CODE_TO_ERROR[err.code]; + if (name) return new A2A_ERROR_CLASSES[name]({ message: err.message }); + return fallback(); +} function makeEnvelope(code: number, message = 'boom'): JSONRPCErrorResponse { return { @@ -27,12 +57,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, RequestMalformedError], [A2A_ERROR_CODE.TASK_NOT_FOUND, TaskNotFoundError], [A2A_ERROR_CODE.TASK_NOT_CANCELABLE, TaskNotCancelableError], [A2A_ERROR_CODE.PUSH_NOTIFICATION_NOT_SUPPORTED, PushNotificationNotSupportedError], @@ -49,20 +80,28 @@ 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', () => { + 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', () => { @@ -149,3 +188,360 @@ 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(JSON_RPC_ERROR_CODE[e.name]).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_CODE.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_CODE.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_CODE.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'); + // 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)', () => { + 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.reason).toBe(spec.reason); + // Per-transport code lives in the transport-specific tables. + expect(JSON_RPC_ERROR_CODE[name]).toBeTypeOf('number'); + } + }); +}); + +// --------------------------------------------------------------------------- +// 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_CODE.CANCELLED }))).toBe( + GRPC_STATUS_CODE.CANCELLED + ); + }); + + it('falls back to the spec grpcStatus for a plain semantic error', () => { + expect(grpcStatusFor(new TaskNotFoundError())).toBe(GRPC_STATUS_CODE.NOT_FOUND); + expect(grpcStatusFor(new ContentTypeNotSupportedError())).toBe( + GRPC_STATUS_CODE.INVALID_ARGUMENT + ); + expect(grpcStatusFor(new InvalidAgentResponseError())).toBe(GRPC_STATUS_CODE.INTERNAL); + }); + + it('returns UNKNOWN for non-A2A throwables', () => { + expect(grpcStatusFor(new Error('unrelated'))).toBe(GRPC_STATUS_CODE.UNKNOWN); + expect(grpcStatusFor(null)).toBe(GRPC_STATUS_CODE.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 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('A2AError'); + expect(rebuilt.message).toBe('plain 500'); + expect(rebuilt.statusCode).toBe(500); + }); + + it('body with unknown ErrorInfo.reason falls through to the A2AError fallback', () => { + const rebuilt = fromRestErrorBody( + { + message: 'unknown', + details: [{ '@type': ERROR_INFO_TYPE, reason: 'MADE_UP_REASON', domain: 'nope' }], + }, + { statusCode: 500 } + ); + expect(rebuilt.name).toBe('A2AError'); + }); + + 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..cd6da18f --- /dev/null +++ b/test/errors_grpc.spec.ts @@ -0,0 +1,66 @@ +/** + * 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 { A2AError, TaskNotFoundError } from '../src/errors/index.js'; +import { + buildGrpcErrorMetadata, + fromGrpcError, + GrpcTaskNotFoundError, + isGrpcError, +} from '../src/errors/grpc/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 a gRPC-scoped A2AError', () => { + const wireError = { + code: grpcStatus.INTERNAL, + details: 'boom', + metadata: new Metadata(), + message: 'ignored', + name: 'ServiceError', + } as ServiceError; + const rebuilt = fromGrpcError(wireError, 'SendMessage'); + 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/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..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.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 b6f0dc7b..7f344de0 100644 --- a/test/server/express/rest_handler.spec.ts +++ b/test/server/express/rest_handler.spec.ts @@ -16,11 +16,11 @@ 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, -} from '../../../src/errors.js'; + TaskNotFoundError, +} from '../../../src/errors/index.js'; import { ListTaskPushNotificationConfigsResponse, Message as ProtoMessage, @@ -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 5d8c1a49..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.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/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..d35f4fc6 100644 --- a/test/server/jsonrpc_transport_handler.spec.ts +++ b/test/server/jsonrpc_transport_handler.spec.ts @@ -7,15 +7,15 @@ import { ServerCallContext } from '../../src/server/context.js'; import { RequestMalformedError, TaskNotFoundError, - TaskNotCancelableError, - PushNotificationNotSupportedError, - UnsupportedOperationError, - ContentTypeNotSupportedError, - InvalidAgentResponseError, A2A_ERROR_CODE, - GenericError, + A2AError, + ContentTypeNotSupportedError, ExtendedAgentCardNotConfiguredError, -} from '../../src/errors.js'; + InvalidAgentResponseError, + PushNotificationNotSupportedError, + TaskNotCancelableError, + UnsupportedOperationError, +} from '../../src/errors/index.js'; describe('JsonRpcTransportHandler', () => { let mockRequestHandler: A2ARequestHandler; @@ -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'); }); 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'; diff --git a/tsup.config.ts b/tsup.config.ts index 58f04556..a1a9f047 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', @@ -19,4 +21,5 @@ export default defineConfig({ format: ['esm', 'cjs'], dts: true, clean: true, + splitting: false, }); 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/**',