Skip to content

Commit 4806f8f

Browse files
authored
feat!: unify A2AError hierarchy with transport specific subclasses (#587)
# Description **Error Handling Refactor and Unification** * All error classes now form a shared, transport-agnostic hierarchy with A2AError as the base and semantic subclasses (e.g., TaskNotFoundError, RequestMalformedError). Per-transport variants (e.g., RestTaskNotFoundError, GrpcTaskNotFoundError, JsonRpcTaskNotFoundError) extend their semantic parent and carry transport-native context (HTTP status/headers/cause, gRPC status/status-details-bin, JSON-RPC envelope code/data). Type guards isRestError / isGrpcError / isJsonRpcError narrow at catch time so callers keep instanceof TaskNotFoundError and simultaneously get typed access to transport fields. Closes #317. * Errors live at two dedicated subpaths: @a2a-js/sdk/errors (pb-free, Workers-safe — base + semantic + REST + JSON-RPC) and @a2a-js/sdk/errors/grpc (requires @bufbuild/protobuf for grpc-status-details-bin encode/decode). The SDK root and /client / /server no longer re-export errors, so non-gRPC consumers don't pull in the pb peer dep. * All transport implementations (rest, grpc, json-rpc, and legacy variants) now use centralized error mapping functions from the new modules (fromRestErrorBody, fromGrpcError, fromJsonRpcErrorResponse, toRestErrorBody, toJsonRpcError, buildGrpcErrorMetadata, restStatusFor, grpcStatusFor) instead of custom or scattered logic. Adding a new error is one row in A2A_ERROR_SPECS — all wire mappings derive from it. * The v0.3 compat layer replaces LegacyA2AError with a thin facade over the new hierarchy that keeps the classic A2AError.taskNotFound(id) / new A2AError(code, msg, data?) API. Wire codes without a v1.0 semantic twin (PARSE_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND) are preserved via JsonRpc*Error.envelopeCode, so v0.3 clients keep seeing the same numeric codes on the wire. **Transport Implementation Simplification** * Removed old error mapping methods from RestTransport, GrpcTransport, JsonRpcTransportHandler, and their v0.3 compat counterparts in favor of the centralized helpers, reducing code duplication and eliminating 6+ parallel error.name-keyed lookup tables. * Added helpers to collect HTTP headers and provide richer error context in REST transport errors. * Deleted src/errors.ts (354 lines) and src/server/grpc/error_details.ts (67 lines); folded into the new src/errors/ module. **Build-tests Fix** * The existing test-build script (esbuild --platform=neutral) never actually enforced Workers-safe boundaries — platform=neutral bundles Node-only modules silently, and CI's npm ci installs devDependencies (which include @grpc/grpc-js and @bufbuild/protobuf), so the check was a no-op. Added scripts/checkWorkersSafeBundles.js that fails if any Workers-safe bundle inlines @grpc/grpc-js or @bufbuild/protobuf. TDD-verified (goes RED when errors/grpc is re-exported from the pb-free barrel, GREEN with the split). **Documentation and Migration Guide Updates** * Updated the migration guide to explain the new error class structure, the two subpath entrypoints (@a2a-js/sdk/errors, @a2a-js/sdk/errors/grpc), and the transport-specific catch-site pattern with type guards, with code examples for the new patterns. Closes #583 #317 🦕
1 parent f447e4e commit 4806f8f

64 files changed

Lines changed: 2145 additions & 1020 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/build-tests.yml

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1-
# Ensures no accidential Node.js API usage in browser and Edge compatible entrypoints.
1+
# Ensures no accidental Node.js API usage in browser and Edge compatible
2+
# entrypoints, and that transport-scoped entrypoints resolve only their
3+
# declared peers. Each `test-build:*` step runs in a DIFFERENT environment:
4+
# 1. workers-safe: NO optional peer deps installed (no @grpc/grpc-js,
5+
# @bufbuild/protobuf, or express). esbuild fails natively if any
6+
# Workers-safe entry pulls them in. Mirrors what a real Workers /
7+
# REST-only consumer gets.
8+
# 2. grpc: @grpc/grpc-js + @bufbuild/protobuf reinstalled (still no
9+
# express); every gRPC entry must resolve.
10+
# 3. express: express reinstalled on top; every express entry must
11+
# resolve.
212

313
name: Run Build Tests
414

@@ -27,4 +37,15 @@ jobs:
2737
cache: 'npm'
2838
- run: npm ci
2939
- run: npm run build
30-
- run: npm run test-build
40+
# Strip ALL three optional peer deps so esbuild sees the same
41+
# module graph a non-gRPC, non-Express consumer would. Any
42+
# Workers-safe entrypoint that leaks one of them fails to resolve.
43+
- run: npm uninstall --no-save @grpc/grpc-js @bufbuild/protobuf express
44+
- run: npm run test-build:workers-safe
45+
# Reinstall only the gRPC peers; verify every gRPC entry resolves
46+
# (and would fail here if any of them silently required express).
47+
- run: npm install --no-save @grpc/grpc-js @bufbuild/protobuf
48+
- run: npm run test-build:grpc
49+
# Reinstall express; verify every express entry resolves.
50+
- run: npm install --no-save express
51+
- run: npm run test-build:express

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ npm install express
4444

4545
### For gRPC Usage
4646

47-
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:
47+
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:
4848

4949
```bash
5050
npm install @grpc/grpc-js @bufbuild/protobuf

docs/migration-guide.md

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -267,8 +267,12 @@ tenant-prefixed routes (`/:tenant/tasks/:taskId`, etc.) and validates the
267267

268268
### 3.2 Error Classes Replaced
269269

270-
The monolithic `A2AError` class with static factory methods is removed.
271-
Use specific error classes:
270+
The monolithic `A2AError` class with static factory methods is removed. Errors
271+
now form a shared transport-agnostic hierarchy — one `A2AError` base with
272+
semantic subclasses (`TaskNotFoundError`, `RequestMalformedError`, …). They
273+
live at `@a2a-js/sdk/errors`; gRPC-specific error helpers live at
274+
`@a2a-js/sdk/errors/grpc` so consumers who don't use gRPC don't pull in
275+
`@bufbuild/protobuf`.
272276

273277
```typescript
274278
// v0.3
@@ -277,18 +281,49 @@ throw A2AError.taskNotFound('task-1');
277281
throw A2AError.invalidParams('bad input');
278282

279283
// v1.0
280-
import { TaskNotFoundError, RequestMalformedError } from '@a2a-js/sdk/server';
281-
throw new TaskNotFoundError('task-1');
282-
throw new RequestMalformedError('bad input');
284+
import { TaskNotFoundError, RequestMalformedError } from '@a2a-js/sdk/errors';
285+
throw new TaskNotFoundError({ message: 'task-1' });
286+
throw new RequestMalformedError({ message: 'bad input' });
283287
```
284288

285-
Available error classes from `@a2a-js/sdk/server`: `TaskNotFoundError`,
286-
`TaskNotCancelableError`, `RequestMalformedError`, `UnsupportedOperationError`,
289+
Semantic classes: `TaskNotFoundError`, `TaskNotCancelableError`,
290+
`RequestMalformedError`, `UnsupportedOperationError`,
287291
`PushNotificationNotSupportedError`, `ContentTypeNotSupportedError`,
288-
`ExtendedAgentCardNotConfiguredError`, `VersionNotSupportedError`.
292+
`InvalidAgentResponseError`, `ExtendedAgentCardNotConfiguredError`,
293+
`ExtensionSupportRequiredError`, `VersionNotSupportedError`. `A2AError`
294+
itself is the concrete fallback — instantiate it directly
295+
(`new A2AError('...')`) when no semantic class fits.
296+
297+
Per-transport variants (`RestTaskNotFoundError`, `GrpcTaskNotFoundError`,
298+
`JsonRpcTaskNotFoundError`, …) carry transport-native context; narrow via the
299+
`isRestError` / `isGrpcError` / `isJsonRpcError` type guards. All catch-side
300+
API surfaces are on the base:
301+
302+
```typescript
303+
import { isRestError, TaskNotFoundError } from '@a2a-js/sdk/errors';
304+
305+
try {
306+
await client.getTask({ id });
307+
} catch (e) {
308+
if (e instanceof TaskNotFoundError) {
309+
if (isRestError(e)) {
310+
// e.statusCode, e.headers, e.cause are typed
311+
if (e.statusCode === 429) backoff(e.headers?.['retry-after']);
312+
}
313+
}
314+
}
315+
```
316+
317+
For gRPC callers, the transport variant + guard live in a separate subpath:
318+
319+
```typescript
320+
import { isGrpcError, TaskNotFoundError } from '@a2a-js/sdk/errors/grpc';
321+
```
289322

290323
Error codes and gRPC/HTTP status mappings are defined in the
291-
[spec](https://a2a-protocol.org/v1.0.0/specification/#54-error-code-mappings).
324+
[spec](https://a2a-protocol.org/v1.0.0/specification/#54-error-code-mappings)
325+
and live in a single registry (`A2A_ERROR_SPECS`) exported from
326+
`@a2a-js/sdk/errors`.
292327

293328
### 3.3 `ServerCallContext` -- Now Mandatory
294329

@@ -305,7 +340,7 @@ new ServerCallContext(requestedExtensions, user);
305340
new ServerCallContext({ requestedExtensions, user, tenant: 'my-tenant', requestedVersion: '1.0' });
306341
```
307342

308-
`RequestContext` now wraps the incoming `SendMessageRequest`,
343+
`RequestContext` now wraps the incoming `SendMessageRequest`,
309344
and `context` moved from last (optional) to 4th (mandatory).
310345
The loose `userMessage` parameter is replaced by `request: SendMessageRequest`;
311346
agent executors read the message via `ctx.userMessage` (convenience accessor
@@ -445,11 +480,11 @@ await verify(agentCard);
445480

446481
## 5. Import Path Changes
447482

448-
| v0.3 Import | v1.0 Import |
449-
| ------------------------------------------------------------ | ------------------------------------------------------------- |
450-
| `import { A2AClient } from '@a2a-js/sdk/client'` | Removed -- use `ClientFactory` + `Client` |
451-
| `import { TextPart, FilePart, DataPart } from '@a2a-js/sdk'` | Removed -- use `Part` |
452-
| `import { MessageSendParams } from '@a2a-js/sdk'` | `import { SendMessageRequest } from '@a2a-js/sdk'` |
453-
| `import { TaskQueryParams } from '@a2a-js/sdk'` | `import { GetTaskRequest } from '@a2a-js/sdk'` |
454-
| `import { TaskIdParams } from '@a2a-js/sdk'` | `import { CancelTaskRequest } from '@a2a-js/sdk'` |
455-
| `import { A2AError } from '@a2a-js/sdk/server'` | `import { TaskNotFoundError, ... } from '@a2a-js/sdk/server'` |
483+
| v0.3 Import | v1.0 Import |
484+
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
485+
| `import { A2AClient } from '@a2a-js/sdk/client'` | Removed -- use `ClientFactory` + `Client` |
486+
| `import { TextPart, FilePart, DataPart } from '@a2a-js/sdk'` | Removed -- use `Part` |
487+
| `import { MessageSendParams } from '@a2a-js/sdk'` | `import { SendMessageRequest } from '@a2a-js/sdk'` |
488+
| `import { TaskQueryParams } from '@a2a-js/sdk'` | `import { GetTaskRequest } from '@a2a-js/sdk'` |
489+
| `import { TaskIdParams } from '@a2a-js/sdk'` | `import { CancelTaskRequest } from '@a2a-js/sdk'` |
490+
| `import { A2AError } from '@a2a-js/sdk/server'` | `import { TaskNotFoundError, ... } from '@a2a-js/sdk/errors'` (or `@a2a-js/sdk/errors/grpc` for gRPC helpers) |

package.json

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@
1515
"type": "module",
1616
"typesVersions": {
1717
"*": {
18+
"errors": [
19+
"./dist/errors/index.d.ts"
20+
],
21+
"errors/grpc": [
22+
"./dist/errors/grpc/index.d.ts"
23+
],
1824
"server": [
1925
"./dist/server/index.d.ts"
2026
],
@@ -56,6 +62,16 @@
5662
"import": "./dist/index.js",
5763
"require": "./dist/index.cjs"
5864
},
65+
"./errors": {
66+
"types": "./dist/errors/index.d.ts",
67+
"import": "./dist/errors/index.js",
68+
"require": "./dist/errors/index.cjs"
69+
},
70+
"./errors/grpc": {
71+
"types": "./dist/errors/grpc/index.d.ts",
72+
"import": "./dist/errors/grpc/index.js",
73+
"require": "./dist/errors/grpc/index.cjs"
74+
},
5975
"./server": {
6076
"types": "./dist/server/index.d.ts",
6177
"import": "./dist/server/index.js",
@@ -154,7 +170,10 @@
154170
"coverage": "vitest run --coverage",
155171
"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",
156172
"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",
157-
"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",
173+
"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",
174+
"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",
175+
"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",
176+
"test-build": "npm run test-build:workers-safe && npm run test-build:grpc && npm run test-build:express",
158177
"itk-agent": "tsx itk/itk_agent.ts"
159178
},
160179
"dependencies": {

src/client/index.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,3 @@ export {
2626
withA2AVersion,
2727
} from './service-parameters.js';
2828
export { ClientCallContext, type ContextUpdate, ClientCallContextKey } from './context.js';
29-
export {
30-
ExtendedAgentCardNotConfiguredError,
31-
ContentTypeNotSupportedError,
32-
InvalidAgentResponseError,
33-
PushNotificationNotSupportedError,
34-
TaskNotCancelableError,
35-
TaskNotFoundError,
36-
UnsupportedOperationError,
37-
RequestMalformedError,
38-
VersionNotSupportedError,
39-
} from '../errors.js';

src/client/multitransport-client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { withA2AVersion } from './service-parameters.js';
22
import { AgentCardSignatureVerifier } from '../signature.js';
33
import { LEGACY_HTTP_EXTENSION_HEADER } from '../compat/v0_3/index.js';
44
import { HTTP_EXTENSION_HEADER } from '../constants.js';
5-
import { PushNotificationNotSupportedError } from '../errors.js';
5+
import { PushNotificationNotSupportedError } from '../errors/index.js';
66
import { isLegacyVersion } from '../version_utils.js';
77
import { TaskPushNotificationConfig, Task, AgentCard, SendMessageResult } from '../index.js';
88
import {

src/client/transports/grpc/grpc_transport.ts

Lines changed: 3 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,7 @@ import { RequestOptions } from '../../multitransport-client.js';
2424
import { Transport, TransportFactory } from '../transport.js';
2525
import { FromProto } from '../../../types/converters/from_proto.js';
2626

27-
import { A2A_REASON_TO_ERROR_CLASS, ERROR_INFO_TYPE } from '../../../errors.js';
28-
import { decodeStatus, decodeErrorInfo } from '../../../server/grpc/error_details.js';
27+
import { fromGrpcError } from '../../../errors/grpc/index.js';
2928
import { LegacyGrpcTransport } from '../../../compat/v0_3/client/transports/grpc/index.js';
3029
import { isLegacyVersion } from '../../../version_utils.js';
3130
import { pickMatchingInterface } from '../pick_interface.js';
@@ -232,7 +231,7 @@ export class GrpcTransport implements Transport {
232231
options.signal.removeEventListener('abort', onAbort);
233232
}
234233
if (error) {
235-
return reject(GrpcTransport.mapToError(error, method));
234+
return reject(fromGrpcError(error, method));
236235
}
237236
resolve(converter(response));
238237
}
@@ -282,7 +281,7 @@ export class GrpcTransport implements Transport {
282281
}
283282
} catch (error) {
284283
if (this.isServiceError(error)) {
285-
throw GrpcTransport.mapToError(error, method);
284+
throw fromGrpcError(error, method);
286285
} else {
287286
throw new Error(`GRPC error for ${String(method)}!`, {
288287
cause: error,
@@ -309,45 +308,6 @@ export class GrpcTransport implements Transport {
309308
}
310309
return metadata;
311310
}
312-
313-
private static mapFromErrorInfo(error: grpc.ServiceError): Error | undefined {
314-
const bin = error.metadata?.get('grpc-status-details-bin');
315-
if (!bin || bin.length === 0) return undefined;
316-
317-
const raw = bin[0];
318-
const buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(raw, 'binary');
319-
320-
const status = decodeStatus(buffer);
321-
322-
for (const detail of status.details) {
323-
if (detail.typeUrl === ERROR_INFO_TYPE) {
324-
const errorInfo = decodeErrorInfo(detail.value);
325-
326-
const ErrorClass = A2A_REASON_TO_ERROR_CLASS[errorInfo.reason];
327-
if (!ErrorClass) return undefined;
328-
329-
return new ErrorClass(error.details);
330-
}
331-
}
332-
333-
return undefined;
334-
}
335-
336-
/**
337-
* Maps a gRPC `ServiceError` to an SDK error class via
338-
* `google.rpc.ErrorInfo` from `grpc-status-details-bin` metadata.
339-
* Falls back to a generic `Error` carrying the gRPC code and details
340-
* when no ErrorInfo is present.
341-
*/
342-
private static mapToError(error: grpc.ServiceError, method?: keyof A2AServiceClient): Error {
343-
const fromErrorInfo = GrpcTransport.mapFromErrorInfo(error);
344-
if (fromErrorInfo) return fromErrorInfo;
345-
346-
const methodContext = method ? ' for ' + String(method) : '';
347-
return new Error('gRPC error' + methodContext + ': ' + error.code + ' ' + error.details, {
348-
cause: error,
349-
});
350-
}
351311
}
352312

353313
export class GrpcTransportFactoryOptions {

src/client/transports/json_rpc_transport.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { JSONRPCErrorResponse, TransportProtocolName } from '../../core.js';
2-
import { mapJsonRpcErrorToSdkError } from '../../errors.js';
2+
import { fromJsonRpcErrorResponse as mapJsonRpcErrorToSdkError } from '../../errors/index.js';
33
import {
44
Task,
55
AgentCard,

src/client/transports/rest_transport.ts

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { TransportProtocolName } from '../../core.js';
2-
import { A2A_REASON_TO_ERROR_CLASS, ERROR_INFO_TYPE } from '../../errors.js';
2+
import { fromRestErrorBody } from '../../errors/index.js';
33

44
import { SendMessageResult, A2A_PROTOCOL_VERSION, A2A_CONTENT_TYPE } from '../../index.js';
55
import { JSON_CONTENT_TYPE } from '../../constants.js';
@@ -326,18 +326,30 @@ export class RestTransport implements Transport {
326326
}
327327
}
328328
} catch {
329-
// Body wasn't JSON — fall through to a generic error.
329+
// Body wasn't JSON — fall through to a REST-scoped A2AError.
330330
}
331331

332+
const transportCtx = {
333+
statusCode: response.status,
334+
headers: RestTransport._collectHeaders(response),
335+
};
332336
if (errorStatus) {
333-
throw RestTransport.mapToError(errorStatus);
337+
throw fromRestErrorBody(errorStatus, transportCtx);
334338
}
335-
336-
throw new Error(
337-
`HTTP error for ${path}! Status: ${response.status} ${response.statusText}. Response: ${errorBodyText}`
339+
throw fromRestErrorBody(
340+
{ message: `HTTP error for ${path}: ${response.status} ${response.statusText}` },
341+
transportCtx
338342
);
339343
}
340344

345+
private static _collectHeaders(response: Response): Record<string, string> {
346+
const out: Record<string, string> = {};
347+
response.headers.forEach((value, key) => {
348+
out[key] = value;
349+
});
350+
return out;
351+
}
352+
341353
private async *_sendStreamingRequest(
342354
path: string,
343355
body: unknown | undefined,
@@ -367,11 +379,15 @@ export class RestTransport implements Transport {
367379
);
368380
}
369381

382+
const sseTransportCtx = {
383+
statusCode: response.status,
384+
headers: RestTransport._collectHeaders(response),
385+
};
370386
for await (const event of parseSseStream(response)) {
371387
if (event.type === 'error') {
372388
const errorData = JSON.parse(event.data) as { error?: RestErrorStatus };
373389
if (errorData.error && typeof errorData.error === 'object') {
374-
throw RestTransport.mapToError(errorData.error);
390+
throw fromRestErrorBody(errorData.error, sseTransportCtx);
375391
}
376392
throw new Error(`SSE error event: ${JSON.stringify(errorData)}`);
377393
}
@@ -394,22 +410,6 @@ export class RestTransport implements Transport {
394410
);
395411
}
396412
}
397-
398-
private static mapToError(error: RestErrorStatus): Error {
399-
const message = error.message || 'Unknown error';
400-
401-
if (Array.isArray(error.details)) {
402-
const errorInfo = error.details.find((d) => d['@type'] === ERROR_INFO_TYPE);
403-
if (errorInfo && typeof errorInfo['reason'] === 'string') {
404-
const ErrorClass = A2A_REASON_TO_ERROR_CLASS[errorInfo['reason'] as string];
405-
if (ErrorClass) return new ErrorClass(message);
406-
}
407-
}
408-
409-
return new Error(
410-
`REST error: ${error.status || 'UNKNOWN'} (${error.code || 'unknown code'}) - ${message}`
411-
);
412-
}
413413
}
414414

415415
export class RestTransportFactoryOptions {

0 commit comments

Comments
 (0)