From 8b60b48123a69649ce146f58811b2f3030602113 Mon Sep 17 00:00:00 2001 From: Emmanuel Hugonnet Date: Mon, 16 Mar 2026 09:05:01 -0700 Subject: [PATCH] feat(errors): add structured error codes and details to A2A error types Replace brittle string-matching error detection in gRPC and REST transports with a structured approach using error codes (A2AErrorCodes) and a details field. The GrpcErrorMapper now extracts ErrorInfo from gRPC status details via a REASON_MAP lookup, and error types carry richer context through a dedicated details field. Signed-off-by: Emmanuel Hugonnet --- .../transport/grpc/GrpcErrorMapper.java | 136 +++++++++----- .../transport/grpc/GrpcErrorMapperTest.java | 103 +++++----- .../transport/jsonrpc/JSONRPCTransport.java | 2 +- .../jsonrpc/JSONRPCTransportTest.java | 2 +- .../transport/jsonrpc/JsonMessages.java | 2 +- .../jsonrpc/JsonStreamingMessages.java | 2 +- .../jsonrpc/sse/SSEEventListenerTest.java | 2 +- .../transport/rest/RestErrorMapper.java | 121 ++++++++---- .../io/a2a/jsonrpc/common/json/JsonUtil.java | 139 +++++--------- .../json/A2AErrorSerializationTest.java | 39 ++-- .../server/apps/quarkus/A2AServerRoutes.java | 2 +- .../rest/quarkus/A2AServerRoutesTest.java | 6 +- .../rest/quarkus/QuarkusA2ARestTest.java | 8 +- .../java/io/a2a/grpc/utils/JSONRPCUtils.java | 81 +++----- spec/src/main/java/io/a2a/spec/A2AError.java | 26 ++- .../main/java/io/a2a/spec/A2AErrorCodes.java | 94 ++++++++-- .../java/io/a2a/spec/A2AProtocolError.java | 38 +--- .../spec/ContentTypeNotSupportedError.java | 12 +- .../ExtendedAgentCardNotConfiguredError.java | 12 +- .../spec/ExtensionSupportRequiredError.java | 12 +- .../main/java/io/a2a/spec/InternalError.java | 11 +- .../a2a/spec/InvalidAgentResponseError.java | 12 +- .../java/io/a2a/spec/InvalidParamsError.java | 11 +- .../java/io/a2a/spec/InvalidRequestError.java | 11 +- .../main/java/io/a2a/spec/JSONParseError.java | 11 +- .../java/io/a2a/spec/MethodNotFoundError.java | 13 +- .../PushNotificationNotSupportedError.java | 12 +- .../io/a2a/spec/TaskNotCancelableError.java | 12 +- .../java/io/a2a/spec/TaskNotFoundError.java | 12 +- .../a2a/spec/UnsupportedOperationError.java | 12 +- .../io/a2a/spec/VersionNotSupportedError.java | 12 +- .../transport/grpc/handler/GrpcHandler.java | 70 +++---- .../grpc/handler/GrpcHandlerTest.java | 15 +- .../transport/rest/handler/RestHandler.java | 177 +++++------------- .../rest/handler/RestHandlerTest.java | 83 +++++--- 35 files changed, 648 insertions(+), 665 deletions(-) diff --git a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcErrorMapper.java b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcErrorMapper.java index b9ad3325b..781242426 100644 --- a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcErrorMapper.java +++ b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcErrorMapper.java @@ -1,7 +1,13 @@ package io.a2a.client.transport.grpc; +import java.util.HashMap; +import java.util.Map; + +import com.google.protobuf.InvalidProtocolBufferException; +import org.jspecify.annotations.Nullable; import io.a2a.common.A2AErrorMessages; import io.a2a.spec.A2AClientException; +import io.a2a.spec.A2AErrorCodes; import io.a2a.spec.ContentTypeNotSupportedError; import io.a2a.spec.ExtendedAgentCardNotConfiguredError; import io.a2a.spec.ExtensionSupportRequiredError; @@ -16,12 +22,33 @@ import io.a2a.spec.UnsupportedOperationError; import io.a2a.spec.VersionNotSupportedError; import io.grpc.Status; +import io.grpc.protobuf.StatusProto; /** - * Utility class to map gRPC exceptions to appropriate A2A error types + * Utility class to map gRPC exceptions to appropriate A2A error types. + *

+ * Extracts {@code google.rpc.ErrorInfo} from gRPC status details to identify the + * specific A2A error type via the {@code reason} field. */ public class GrpcErrorMapper { + private static final Map REASON_MAP = Map.ofEntries( + Map.entry("TASK_NOT_FOUND", A2AErrorCodes.TASK_NOT_FOUND), + Map.entry("TASK_NOT_CANCELABLE", A2AErrorCodes.TASK_NOT_CANCELABLE), + Map.entry("PUSH_NOTIFICATION_NOT_SUPPORTED", A2AErrorCodes.PUSH_NOTIFICATION_NOT_SUPPORTED), + Map.entry("UNSUPPORTED_OPERATION", A2AErrorCodes.UNSUPPORTED_OPERATION), + Map.entry("CONTENT_TYPE_NOT_SUPPORTED", A2AErrorCodes.CONTENT_TYPE_NOT_SUPPORTED), + Map.entry("INVALID_AGENT_RESPONSE", A2AErrorCodes.INVALID_AGENT_RESPONSE), + Map.entry("EXTENDED_AGENT_CARD_NOT_CONFIGURED", A2AErrorCodes.EXTENDED_AGENT_CARD_NOT_CONFIGURED), + Map.entry("EXTENSION_SUPPORT_REQUIRED", A2AErrorCodes.EXTENSION_SUPPORT_REQUIRED), + Map.entry("VERSION_NOT_SUPPORTED", A2AErrorCodes.VERSION_NOT_SUPPORTED), + Map.entry("INVALID_REQUEST", A2AErrorCodes.INVALID_REQUEST), + Map.entry("METHOD_NOT_FOUND", A2AErrorCodes.METHOD_NOT_FOUND), + Map.entry("INVALID_PARAMS", A2AErrorCodes.INVALID_PARAMS), + Map.entry("INTERNAL", A2AErrorCodes.INTERNAL), + Map.entry("JSON_PARSE", A2AErrorCodes.JSON_PARSE) + ); + public static A2AClientException mapGrpcError(Throwable e) { return mapGrpcError(e, "gRPC error: "); } @@ -29,57 +56,68 @@ public static A2AClientException mapGrpcError(Throwable e) { public static A2AClientException mapGrpcError(Throwable e, String errorPrefix) { Status status = Status.fromThrowable(e); Status.Code code = status.getCode(); - String description = status.getDescription(); - - // Extract the actual error type from the description if possible - // (using description because the same code can map to multiple errors - - // see GrpcHandler#handleError) - if (description != null) { - if (description.contains("TaskNotFoundError")) { - return new A2AClientException(errorPrefix + description, new TaskNotFoundError()); - } else if (description.contains("UnsupportedOperationError")) { - return new A2AClientException(errorPrefix + description, new UnsupportedOperationError()); - } else if (description.contains("InvalidParamsError")) { - return new A2AClientException(errorPrefix + description, new InvalidParamsError()); - } else if (description.contains("InvalidRequestError")) { - return new A2AClientException(errorPrefix + description, new InvalidRequestError()); - } else if (description.contains("MethodNotFoundError")) { - return new A2AClientException(errorPrefix + description, new MethodNotFoundError()); - } else if (description.contains("TaskNotCancelableError")) { - return new A2AClientException(errorPrefix + description, new TaskNotCancelableError()); - } else if (description.contains("PushNotificationNotSupportedError")) { - return new A2AClientException(errorPrefix + description, new PushNotificationNotSupportedError()); - } else if (description.contains("JSONParseError")) { - return new A2AClientException(errorPrefix + description, new JSONParseError()); - } else if (description.contains("ContentTypeNotSupportedError")) { - return new A2AClientException(errorPrefix + description, new ContentTypeNotSupportedError(null, description, null)); - } else if (description.contains("InvalidAgentResponseError")) { - return new A2AClientException(errorPrefix + description, new InvalidAgentResponseError(null, description, null)); - } else if (description.contains("ExtendedCardNotConfiguredError")) { - return new A2AClientException(errorPrefix + description, new ExtendedAgentCardNotConfiguredError(null, description, null)); - } else if (description.contains("ExtensionSupportRequiredError")) { - return new A2AClientException(errorPrefix + description, new ExtensionSupportRequiredError(null, description, null)); - } else if (description.contains("VersionNotSupportedError")) { - return new A2AClientException(errorPrefix + description, new VersionNotSupportedError(null, description, null)); + String message = status.getDescription(); + + // Try to extract ErrorInfo from status details + com.google.rpc.@Nullable ErrorInfo errorInfo = extractErrorInfo(e); + if (errorInfo != null) { + A2AErrorCodes errorCode = REASON_MAP.get(errorInfo.getReason()); + if (errorCode != null) { + String errorMessage = message != null ? message : (e.getMessage() != null ? e.getMessage() : ""); + Map metadata = errorInfo.getMetadataMap().isEmpty() ? null + : new HashMap(errorInfo.getMetadataMap()); + return mapByErrorCode(errorCode, errorPrefix + errorMessage, errorMessage, metadata); } } - + // Fall back to mapping based on status code - switch (code) { - case NOT_FOUND: - return new A2AClientException(errorPrefix + (description != null ? description : e.getMessage()), new TaskNotFoundError()); - case UNIMPLEMENTED: - return new A2AClientException(errorPrefix + (description != null ? description : e.getMessage()), new UnsupportedOperationError()); - case INVALID_ARGUMENT: - return new A2AClientException(errorPrefix + (description != null ? description : e.getMessage()), new InvalidParamsError()); - case INTERNAL: - return new A2AClientException(errorPrefix + (description != null ? description : e.getMessage()), new io.a2a.spec.InternalError(null, e.getMessage(), null)); - case UNAUTHENTICATED: - return new A2AClientException(errorPrefix + A2AErrorMessages.AUTHENTICATION_FAILED); - case PERMISSION_DENIED: - return new A2AClientException(errorPrefix + A2AErrorMessages.AUTHORIZATION_FAILED); - default: - return new A2AClientException(errorPrefix + e.getMessage(), e); + String desc = message != null ? message : e.getMessage() == null ? "" : e.getMessage(); + return switch (code) { + case NOT_FOUND -> new A2AClientException(errorPrefix + desc, new TaskNotFoundError()); + case UNIMPLEMENTED -> new A2AClientException(errorPrefix + desc, new UnsupportedOperationError()); + case INVALID_ARGUMENT -> new A2AClientException(errorPrefix + desc, new InvalidParamsError()); + case INTERNAL -> new A2AClientException(errorPrefix + desc, new io.a2a.spec.InternalError(null, desc, null)); + case UNAUTHENTICATED -> new A2AClientException(errorPrefix + A2AErrorMessages.AUTHENTICATION_FAILED); + case PERMISSION_DENIED -> new A2AClientException(errorPrefix + A2AErrorMessages.AUTHORIZATION_FAILED); + default -> new A2AClientException(errorPrefix + e.getMessage(), e); + }; + } + + private static com.google.rpc.@Nullable ErrorInfo extractErrorInfo(Throwable e) { + try { + com.google.rpc.Status rpcStatus = StatusProto.fromThrowable(e); + if (rpcStatus != null) { + for (com.google.protobuf.Any detail : rpcStatus.getDetailsList()) { + if (detail.is(com.google.rpc.ErrorInfo.class)) { + com.google.rpc.ErrorInfo errorInfo = detail.unpack(com.google.rpc.ErrorInfo.class); + if ("a2a-protocol.org".equals(errorInfo.getDomain())) { + return errorInfo; + } + } + } + } + } catch (InvalidProtocolBufferException ignored) { + // Fall through to status code-based mapping } + return null; + } + + private static A2AClientException mapByErrorCode(A2AErrorCodes errorCode, String fullMessage, String errorMessage, @Nullable Map metadata) { + return switch (errorCode) { + case TASK_NOT_FOUND -> new A2AClientException(fullMessage, new TaskNotFoundError(errorMessage, metadata)); + case TASK_NOT_CANCELABLE -> new A2AClientException(fullMessage, new TaskNotCancelableError(null, errorMessage, metadata)); + case PUSH_NOTIFICATION_NOT_SUPPORTED -> new A2AClientException(fullMessage, new PushNotificationNotSupportedError(null, errorMessage, metadata)); + case UNSUPPORTED_OPERATION -> new A2AClientException(fullMessage, new UnsupportedOperationError(null, errorMessage, metadata)); + case CONTENT_TYPE_NOT_SUPPORTED -> new A2AClientException(fullMessage, new ContentTypeNotSupportedError(null, errorMessage, metadata)); + case INVALID_AGENT_RESPONSE -> new A2AClientException(fullMessage, new InvalidAgentResponseError(null, errorMessage, metadata)); + case EXTENDED_AGENT_CARD_NOT_CONFIGURED -> new A2AClientException(fullMessage, new ExtendedAgentCardNotConfiguredError(null, errorMessage, metadata)); + case EXTENSION_SUPPORT_REQUIRED -> new A2AClientException(fullMessage, new ExtensionSupportRequiredError(null, errorMessage, metadata)); + case VERSION_NOT_SUPPORTED -> new A2AClientException(fullMessage, new VersionNotSupportedError(null, errorMessage, metadata)); + case INVALID_REQUEST -> new A2AClientException(fullMessage, new InvalidRequestError(null, errorMessage, metadata)); + case JSON_PARSE -> new A2AClientException(fullMessage, new JSONParseError(null, errorMessage, metadata)); + case METHOD_NOT_FOUND -> new A2AClientException(fullMessage, new MethodNotFoundError(null, errorMessage, metadata)); + case INVALID_PARAMS -> new A2AClientException(fullMessage, new InvalidParamsError(null, errorMessage, metadata)); + case INTERNAL -> new A2AClientException(fullMessage, new io.a2a.spec.InternalError(null, errorMessage, metadata)); + }; } } diff --git a/client/transport/grpc/src/test/java/io/a2a/client/transport/grpc/GrpcErrorMapperTest.java b/client/transport/grpc/src/test/java/io/a2a/client/transport/grpc/GrpcErrorMapperTest.java index 864e406d2..151ca7b91 100644 --- a/client/transport/grpc/src/test/java/io/a2a/client/transport/grpc/GrpcErrorMapperTest.java +++ b/client/transport/grpc/src/test/java/io/a2a/client/transport/grpc/GrpcErrorMapperTest.java @@ -1,10 +1,11 @@ package io.a2a.client.transport.grpc; -import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.google.protobuf.Any; +import com.google.rpc.ErrorInfo; import io.a2a.spec.A2AClientException; import io.a2a.spec.ContentTypeNotSupportedError; import io.a2a.spec.ExtendedAgentCardNotConfiguredError; @@ -15,25 +16,38 @@ import io.a2a.spec.VersionNotSupportedError; import io.grpc.Status; import io.grpc.StatusRuntimeException; +import io.grpc.protobuf.StatusProto; import org.junit.jupiter.api.Test; /** * Tests for GrpcErrorMapper - verifies correct unmarshalling of gRPC errors to A2A error types + * using google.rpc.ErrorInfo in status details. */ public class GrpcErrorMapperTest { + private static StatusRuntimeException createA2AStatusException(int grpcCode, String message, String reason) { + ErrorInfo errorInfo = ErrorInfo.newBuilder() + .setReason(reason) + .setDomain("a2a-protocol.org") + .build(); + + com.google.rpc.Status rpcStatus = com.google.rpc.Status.newBuilder() + .setCode(grpcCode) + .setMessage(message) + .addDetails(Any.pack(errorInfo)) + .build(); + + return StatusProto.toStatusRuntimeException(rpcStatus); + } + @Test public void testExtensionSupportRequiredErrorUnmarshalling() { - // Create a gRPC StatusRuntimeException with ExtensionSupportRequiredError in description - String errorMessage = "ExtensionSupportRequiredError: Extension required: https://example.com/test-extension"; - StatusRuntimeException grpcException = Status.FAILED_PRECONDITION - .withDescription(errorMessage) - .asRuntimeException(); + String errorMessage = "Extension required: https://example.com/test-extension"; + StatusRuntimeException grpcException = createA2AStatusException( + Status.Code.FAILED_PRECONDITION.value(), errorMessage, "EXTENSION_SUPPORT_REQUIRED"); - // Map the gRPC error to A2A error A2AClientException result = GrpcErrorMapper.mapGrpcError(grpcException); - // Verify the result assertNotNull(result); assertNotNull(result.getCause()); assertInstanceOf(ExtensionSupportRequiredError.class, result.getCause()); @@ -46,16 +60,12 @@ public void testExtensionSupportRequiredErrorUnmarshalling() { @Test public void testVersionNotSupportedErrorUnmarshalling() { - // Create a gRPC StatusRuntimeException with VersionNotSupportedError in description - String errorMessage = "VersionNotSupportedError: Version 2.0 is not supported"; - StatusRuntimeException grpcException = Status.FAILED_PRECONDITION - .withDescription(errorMessage) - .asRuntimeException(); + String errorMessage = "Version 2.0 is not supported"; + StatusRuntimeException grpcException = createA2AStatusException( + Status.Code.UNIMPLEMENTED.value(), errorMessage, "VERSION_NOT_SUPPORTED"); - // Map the gRPC error to A2A error A2AClientException result = GrpcErrorMapper.mapGrpcError(grpcException); - // Verify the result assertNotNull(result); assertNotNull(result.getCause()); assertInstanceOf(VersionNotSupportedError.class, result.getCause()); @@ -67,16 +77,12 @@ public void testVersionNotSupportedErrorUnmarshalling() { @Test public void testExtendedCardNotConfiguredErrorUnmarshalling() { - // Create a gRPC StatusRuntimeException with ExtendedCardNotConfiguredError in description - String errorMessage = "ExtendedCardNotConfiguredError: Extended card not configured for this agent"; - StatusRuntimeException grpcException = Status.FAILED_PRECONDITION - .withDescription(errorMessage) - .asRuntimeException(); + String errorMessage = "Extended card not configured for this agent"; + StatusRuntimeException grpcException = createA2AStatusException( + Status.Code.FAILED_PRECONDITION.value(), errorMessage, "EXTENDED_AGENT_CARD_NOT_CONFIGURED"); - // Map the gRPC error to A2A error A2AClientException result = GrpcErrorMapper.mapGrpcError(grpcException); - // Verify the result assertNotNull(result); assertNotNull(result.getCause()); assertInstanceOf(ExtendedAgentCardNotConfiguredError.class, result.getCause()); @@ -88,16 +94,12 @@ public void testExtendedCardNotConfiguredErrorUnmarshalling() { @Test public void testTaskNotFoundErrorUnmarshalling() { - // Create a gRPC StatusRuntimeException with TaskNotFoundError in description - String errorMessage = "TaskNotFoundError: Task task-123 not found"; - StatusRuntimeException grpcException = Status.NOT_FOUND - .withDescription(errorMessage) - .asRuntimeException(); + String errorMessage = "Task task-123 not found"; + StatusRuntimeException grpcException = createA2AStatusException( + Status.Code.NOT_FOUND.value(), errorMessage, "TASK_NOT_FOUND"); - // Map the gRPC error to A2A error A2AClientException result = GrpcErrorMapper.mapGrpcError(grpcException); - // Verify the result assertNotNull(result); assertNotNull(result.getCause()); assertInstanceOf(TaskNotFoundError.class, result.getCause()); @@ -105,16 +107,12 @@ public void testTaskNotFoundErrorUnmarshalling() { @Test public void testUnsupportedOperationErrorUnmarshalling() { - // Create a gRPC StatusRuntimeException with UnsupportedOperationError in description - String errorMessage = "UnsupportedOperationError: Operation not supported"; - StatusRuntimeException grpcException = Status.UNIMPLEMENTED - .withDescription(errorMessage) - .asRuntimeException(); + String errorMessage = "Operation not supported"; + StatusRuntimeException grpcException = createA2AStatusException( + Status.Code.UNIMPLEMENTED.value(), errorMessage, "UNSUPPORTED_OPERATION"); - // Map the gRPC error to A2A error A2AClientException result = GrpcErrorMapper.mapGrpcError(grpcException); - // Verify the result assertNotNull(result); assertNotNull(result.getCause()); assertInstanceOf(UnsupportedOperationError.class, result.getCause()); @@ -122,16 +120,12 @@ public void testUnsupportedOperationErrorUnmarshalling() { @Test public void testInvalidParamsErrorUnmarshalling() { - // Create a gRPC StatusRuntimeException with InvalidParamsError in description - String errorMessage = "InvalidParamsError: Invalid parameters provided"; - StatusRuntimeException grpcException = Status.INVALID_ARGUMENT - .withDescription(errorMessage) - .asRuntimeException(); + String errorMessage = "Invalid parameters provided"; + StatusRuntimeException grpcException = createA2AStatusException( + Status.Code.INVALID_ARGUMENT.value(), errorMessage, "INVALID_PARAMS"); - // Map the gRPC error to A2A error A2AClientException result = GrpcErrorMapper.mapGrpcError(grpcException); - // Verify the result assertNotNull(result); assertNotNull(result.getCause()); assertInstanceOf(InvalidParamsError.class, result.getCause()); @@ -139,16 +133,12 @@ public void testInvalidParamsErrorUnmarshalling() { @Test public void testContentTypeNotSupportedErrorUnmarshalling() { - // Create a gRPC StatusRuntimeException with ContentTypeNotSupportedError in description - String errorMessage = "ContentTypeNotSupportedError: Content type application/xml not supported"; - StatusRuntimeException grpcException = Status.FAILED_PRECONDITION - .withDescription(errorMessage) - .asRuntimeException(); + String errorMessage = "Content type application/xml not supported"; + StatusRuntimeException grpcException = createA2AStatusException( + Status.Code.INVALID_ARGUMENT.value(), errorMessage, "CONTENT_TYPE_NOT_SUPPORTED"); - // Map the gRPC error to A2A error A2AClientException result = GrpcErrorMapper.mapGrpcError(grpcException); - // Verify the result assertNotNull(result); assertNotNull(result.getCause()); assertInstanceOf(ContentTypeNotSupportedError.class, result.getCause()); @@ -160,12 +150,11 @@ public void testContentTypeNotSupportedErrorUnmarshalling() { @Test public void testFallbackToStatusCodeMapping() { - // Create a gRPC StatusRuntimeException without specific error type in description + // Create a gRPC StatusRuntimeException without ErrorInfo details StatusRuntimeException grpcException = Status.NOT_FOUND .withDescription("Generic not found error") .asRuntimeException(); - // Map the gRPC error to A2A error A2AClientException result = GrpcErrorMapper.mapGrpcError(grpcException); // Verify fallback to status code mapping @@ -176,17 +165,13 @@ public void testFallbackToStatusCodeMapping() { @Test public void testCustomErrorPrefix() { - // Create a gRPC StatusRuntimeException - String errorMessage = "ExtensionSupportRequiredError: Extension required: https://example.com/ext"; - StatusRuntimeException grpcException = Status.FAILED_PRECONDITION - .withDescription(errorMessage) - .asRuntimeException(); + String errorMessage = "Extension required: https://example.com/ext"; + StatusRuntimeException grpcException = createA2AStatusException( + Status.Code.FAILED_PRECONDITION.value(), errorMessage, "EXTENSION_SUPPORT_REQUIRED"); - // Map with custom error prefix String customPrefix = "Custom Error: "; A2AClientException result = GrpcErrorMapper.mapGrpcError(grpcException, customPrefix); - // Verify custom prefix is used assertNotNull(result); assertTrue(result.getMessage().startsWith(customPrefix)); assertInstanceOf(ExtensionSupportRequiredError.class, result.getCause()); diff --git a/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java index 2bbb25d87..63100ee09 100644 --- a/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java +++ b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java @@ -364,7 +364,7 @@ private > T unmarshalResponse(String response, String m A2AResponse value = JSONRPCUtils.parseResponseBody(response, method); A2AError error = value.getError(); if (error != null) { - throw new A2AClientException(error.getMessage() + (error.getData() != null ? ": " + error.getData() : ""), error); + throw new A2AClientException(error.getMessage() + (!error.getDetails().isEmpty() ? ": " + error.getDetails() : ""), error); } // Safe cast: JSONRPCUtils.parseResponseBody returns the correct concrete type based on method return (T) value; diff --git a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java index 47c450506..55c20bc55 100644 --- a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java +++ b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java @@ -212,7 +212,7 @@ public void testA2AClientSendMessageWithError() throws Exception { client.sendMessage(params, null); fail(); // should not reach here } catch (A2AClientException e) { - assertTrue(e.getMessage().contains("Invalid parameters: \"Hello world\""),e.getMessage()); + assertTrue(e.getMessage().contains("Invalid parameters: {info=Hello world}"),e.getMessage()); } } diff --git a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java index a9639b84c..a0be30e0d 100644 --- a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java +++ b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java @@ -216,7 +216,7 @@ public class JsonMessages { "error": { "code": -32702, "message": "Invalid parameters", - "data": "Hello world" + "details": {"info": "Hello world"} } }"""; diff --git a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonStreamingMessages.java b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonStreamingMessages.java index 39f2d7859..df6fee000 100644 --- a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonStreamingMessages.java +++ b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonStreamingMessages.java @@ -99,7 +99,7 @@ public class JsonStreamingMessages { "error": { "code": -32602, "message": "Invalid parameters", - "data": "Missing required field" + "details": {"info": "Missing required field"} } }"""; diff --git a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListenerTest.java b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListenerTest.java index e9e74d12f..389c7df8d 100644 --- a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListenerTest.java +++ b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListenerTest.java @@ -161,7 +161,7 @@ public void testOnEventWithError() throws Exception { A2AError jsonrpcError = (A2AError) receivedError.get(); assertEquals(-32602, jsonrpcError.getCode()); assertEquals("Invalid parameters", jsonrpcError.getMessage()); - assertEquals("\"Missing required field\"", jsonrpcError.getData()); + assertEquals("Missing required field", jsonrpcError.getDetails().get("info")); } @Test diff --git a/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestErrorMapper.java b/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestErrorMapper.java index e749070af..128a81223 100644 --- a/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestErrorMapper.java +++ b/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestErrorMapper.java @@ -1,5 +1,7 @@ package io.a2a.client.transport.rest; +import java.util.HashMap; +import java.util.Map; import java.util.logging.Level; import java.util.logging.Logger; @@ -8,8 +10,9 @@ import io.a2a.jsonrpc.common.json.JsonProcessingException; import io.a2a.jsonrpc.common.json.JsonUtil; import io.a2a.spec.A2AClientException; -import io.a2a.spec.ExtendedAgentCardNotConfiguredError; +import io.a2a.spec.A2AErrorCodes; import io.a2a.spec.ContentTypeNotSupportedError; +import io.a2a.spec.ExtendedAgentCardNotConfiguredError; import io.a2a.spec.ExtensionSupportRequiredError; import io.a2a.spec.InternalError; import io.a2a.spec.InvalidAgentResponseError; @@ -28,6 +31,25 @@ */ public class RestErrorMapper { + private record ReasonAndMetadata(String reason, @org.jspecify.annotations.Nullable Map metadata) {} + + private static final Map REASON_MAP = Map.ofEntries( + Map.entry("TASK_NOT_FOUND", A2AErrorCodes.TASK_NOT_FOUND), + Map.entry("TASK_NOT_CANCELABLE", A2AErrorCodes.TASK_NOT_CANCELABLE), + Map.entry("PUSH_NOTIFICATION_NOT_SUPPORTED", A2AErrorCodes.PUSH_NOTIFICATION_NOT_SUPPORTED), + Map.entry("UNSUPPORTED_OPERATION", A2AErrorCodes.UNSUPPORTED_OPERATION), + Map.entry("CONTENT_TYPE_NOT_SUPPORTED", A2AErrorCodes.CONTENT_TYPE_NOT_SUPPORTED), + Map.entry("INVALID_AGENT_RESPONSE", A2AErrorCodes.INVALID_AGENT_RESPONSE), + Map.entry("EXTENDED_AGENT_CARD_NOT_CONFIGURED", A2AErrorCodes.EXTENDED_AGENT_CARD_NOT_CONFIGURED), + Map.entry("EXTENSION_SUPPORT_REQUIRED", A2AErrorCodes.EXTENSION_SUPPORT_REQUIRED), + Map.entry("VERSION_NOT_SUPPORTED", A2AErrorCodes.VERSION_NOT_SUPPORTED), + Map.entry("INVALID_REQUEST", A2AErrorCodes.INVALID_REQUEST), + Map.entry("METHOD_NOT_FOUND", A2AErrorCodes.METHOD_NOT_FOUND), + Map.entry("INVALID_PARAMS", A2AErrorCodes.INVALID_PARAMS), + Map.entry("INTERNAL", A2AErrorCodes.INTERNAL), + Map.entry("JSON_PARSE", A2AErrorCodes.JSON_PARSE) + ); + public static A2AClientException mapRestError(A2AHttpResponse response) { return RestErrorMapper.mapRestError(response.body(), response.status()); } @@ -36,13 +58,17 @@ public static A2AClientException mapRestError(String body, int code) { try { if (body != null && !body.isBlank()) { JsonObject node = JsonUtil.fromJson(body, JsonObject.class); - // Support RFC 7807 Problem Details format (type, title, details, status) - if (node.has("type")) { - String type = node.get("type").getAsString(); - String errorMessage = node.has("title") ? node.get("title").getAsString() : ""; - return mapRestErrorByType(type, errorMessage, code); + // Google Cloud API error format: { "error": { "code", "status", "message", "details" } } + if (node.has("error") && node.get("error").isJsonObject()) { + JsonObject errorObj = node.getAsJsonObject("error"); + String errorMessage = errorObj.has("message") ? errorObj.get("message").getAsString() : ""; + ReasonAndMetadata reasonAndMetadata = extractReasonAndMetadata(errorObj); + if (reasonAndMetadata != null) { + return mapRestErrorByReason(reasonAndMetadata.reason(), errorMessage, reasonAndMetadata.metadata()); + } + return new A2AClientException(errorMessage); } - // Legacy format (error, message) + // Legacy format (error class name, message) String className = node.has("error") ? node.get("error").getAsString() : ""; String errorMessage = node.has("message") ? node.get("message").getAsString() : ""; return mapRestErrorByClassName(className, errorMessage, code); @@ -59,36 +85,59 @@ public static A2AClientException mapRestError(String className, String errorMess } /** - * Maps RFC 7807 Problem Details error type URIs to A2A exceptions. - *

- * Note: Error constructors receive null for code and data parameters because: - *

+ * Extracts the "reason" and "metadata" fields from the first entry in the "details" array. + */ + private static @org.jspecify.annotations.Nullable ReasonAndMetadata extractReasonAndMetadata(JsonObject errorObj) { + if (errorObj.has("details") && errorObj.get("details").isJsonArray()) { + var details = errorObj.getAsJsonArray("details"); + if (!details.isEmpty() && details.get(0).isJsonObject()) { + JsonObject detail = details.get(0).getAsJsonObject(); + if (detail.has("reason")) { + String reason = detail.get("reason").getAsString(); + Map metadata = null; + if (detail.has("metadata") && detail.get("metadata").isJsonObject()) { + JsonObject metaObj = detail.getAsJsonObject("metadata"); + Map metaMap = new HashMap<>(); + for (var entry : metaObj.entrySet()) { + metaMap.put(entry.getKey(), entry.getValue().getAsString()); + } + metadata = metaMap; + } + return new ReasonAndMetadata(reason, metadata); + } + } + } + return null; + } + + /** + * Maps error reason strings to A2A exceptions. * - * @param type the RFC 7807 error type URI (e.g., "https://a2a-protocol.org/errors/task-not-found") - * @param errorMessage the error message from the "title" field - * @param code the HTTP status code (currently unused, kept for consistency) + * @param reason the error reason (e.g., "TASK_NOT_FOUND") + * @param errorMessage the error message + * @param metadata additional metadata extracted from the error details * @return an A2AClientException wrapping the appropriate A2A error */ - private static A2AClientException mapRestErrorByType(String type, String errorMessage, int code) { - return switch (type) { - case "https://a2a-protocol.org/errors/task-not-found" -> new A2AClientException(errorMessage, new TaskNotFoundError()); - case "https://a2a-protocol.org/errors/extended-agent-card-not-configured" -> new A2AClientException(errorMessage, new ExtendedAgentCardNotConfiguredError(null, errorMessage, null)); - case "https://a2a-protocol.org/errors/content-type-not-supported" -> new A2AClientException(errorMessage, new ContentTypeNotSupportedError(null, errorMessage, null)); - case "https://a2a-protocol.org/errors/internal-error" -> new A2AClientException(errorMessage, new InternalError(errorMessage)); - case "https://a2a-protocol.org/errors/invalid-agent-response" -> new A2AClientException(errorMessage, new InvalidAgentResponseError(null, errorMessage, null)); - case "https://a2a-protocol.org/errors/invalid-params" -> new A2AClientException(errorMessage, new InvalidParamsError()); - case "https://a2a-protocol.org/errors/invalid-request" -> new A2AClientException(errorMessage, new InvalidRequestError()); - case "https://a2a-protocol.org/errors/method-not-found" -> new A2AClientException(errorMessage, new MethodNotFoundError()); - case "https://a2a-protocol.org/errors/push-notification-not-supported" -> new A2AClientException(errorMessage, new PushNotificationNotSupportedError()); - case "https://a2a-protocol.org/errors/task-not-cancelable" -> new A2AClientException(errorMessage, new TaskNotCancelableError()); - case "https://a2a-protocol.org/errors/unsupported-operation" -> new A2AClientException(errorMessage, new UnsupportedOperationError()); - case "https://a2a-protocol.org/errors/extension-support-required" -> new A2AClientException(errorMessage, new ExtensionSupportRequiredError(null, errorMessage, null)); - case "https://a2a-protocol.org/errors/version-not-supported" -> new A2AClientException(errorMessage, new VersionNotSupportedError(null, errorMessage, null)); - default -> new A2AClientException(errorMessage); + private static A2AClientException mapRestErrorByReason(String reason, String errorMessage, @org.jspecify.annotations.Nullable Map metadata) { + A2AErrorCodes errorCode = REASON_MAP.get(reason); + if (errorCode == null) { + return new A2AClientException(errorMessage); + } + return switch (errorCode) { + case TASK_NOT_FOUND -> new A2AClientException(errorMessage, new TaskNotFoundError(errorMessage, metadata)); + case TASK_NOT_CANCELABLE -> new A2AClientException(errorMessage, new TaskNotCancelableError(null, errorMessage, metadata)); + case PUSH_NOTIFICATION_NOT_SUPPORTED -> new A2AClientException(errorMessage, new PushNotificationNotSupportedError(null, errorMessage, metadata)); + case UNSUPPORTED_OPERATION -> new A2AClientException(errorMessage, new UnsupportedOperationError(null, errorMessage, metadata)); + case CONTENT_TYPE_NOT_SUPPORTED -> new A2AClientException(errorMessage, new ContentTypeNotSupportedError(null, errorMessage, metadata)); + case INVALID_AGENT_RESPONSE -> new A2AClientException(errorMessage, new InvalidAgentResponseError(null, errorMessage, metadata)); + case EXTENDED_AGENT_CARD_NOT_CONFIGURED -> new A2AClientException(errorMessage, new ExtendedAgentCardNotConfiguredError(null, errorMessage, metadata)); + case EXTENSION_SUPPORT_REQUIRED -> new A2AClientException(errorMessage, new ExtensionSupportRequiredError(null, errorMessage, metadata)); + case VERSION_NOT_SUPPORTED -> new A2AClientException(errorMessage, new VersionNotSupportedError(null, errorMessage, metadata)); + case INVALID_REQUEST -> new A2AClientException(errorMessage, new InvalidRequestError(null, errorMessage, metadata)); + case JSON_PARSE -> new A2AClientException(errorMessage, new JSONParseError(null, errorMessage, metadata)); + case METHOD_NOT_FOUND -> new A2AClientException(errorMessage, new MethodNotFoundError(null, errorMessage, metadata)); + case INVALID_PARAMS -> new A2AClientException(errorMessage, new InvalidParamsError(null, errorMessage, metadata)); + case INTERNAL -> new A2AClientException(errorMessage, new InternalError(null, errorMessage, metadata)); }; } @@ -96,9 +145,9 @@ private static A2AClientException mapRestErrorByClassName(String className, Stri return switch (className) { case "io.a2a.spec.TaskNotFoundError" -> new A2AClientException(errorMessage, new TaskNotFoundError()); case "io.a2a.spec.ExtendedCardNotConfiguredError" -> new A2AClientException(errorMessage, new ExtendedAgentCardNotConfiguredError(null, errorMessage, null)); - case "io.a2a.spec.ContentTypeNotSupportedError" -> new A2AClientException(errorMessage, new ContentTypeNotSupportedError(null, null, errorMessage)); + case "io.a2a.spec.ContentTypeNotSupportedError" -> new A2AClientException(errorMessage, new ContentTypeNotSupportedError(null, errorMessage, null)); case "io.a2a.spec.InternalError" -> new A2AClientException(errorMessage, new InternalError(errorMessage)); - case "io.a2a.spec.InvalidAgentResponseError" -> new A2AClientException(errorMessage, new InvalidAgentResponseError(null, null, errorMessage)); + case "io.a2a.spec.InvalidAgentResponseError" -> new A2AClientException(errorMessage, new InvalidAgentResponseError(null, errorMessage, null)); case "io.a2a.spec.InvalidParamsError" -> new A2AClientException(errorMessage, new InvalidParamsError()); case "io.a2a.spec.InvalidRequestError" -> new A2AClientException(errorMessage, new InvalidRequestError()); case "io.a2a.spec.JSONParseError" -> new A2AClientException(errorMessage, new JSONParseError()); diff --git a/jsonrpc-common/src/main/java/io/a2a/jsonrpc/common/json/JsonUtil.java b/jsonrpc-common/src/main/java/io/a2a/jsonrpc/common/json/JsonUtil.java index 9e8fb057f..3967cbf80 100644 --- a/jsonrpc-common/src/main/java/io/a2a/jsonrpc/common/json/JsonUtil.java +++ b/jsonrpc-common/src/main/java/io/a2a/jsonrpc/common/json/JsonUtil.java @@ -1,23 +1,12 @@ package io.a2a.jsonrpc.common.json; -import static io.a2a.jsonrpc.common.json.JsonUtil.A2AErrorTypeAdapter.THROWABLE_MARKER_FIELD; -import static io.a2a.spec.A2AErrorCodes.CONTENT_TYPE_NOT_SUPPORTED_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.INTERNAL_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.INVALID_AGENT_RESPONSE_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.INVALID_PARAMS_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.INVALID_REQUEST_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.JSON_PARSE_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.METHOD_NOT_FOUND_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.PUSH_NOTIFICATION_NOT_SUPPORTED_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.TASK_NOT_CANCELABLE_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.TASK_NOT_FOUND_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.UNSUPPORTED_OPERATION_ERROR_CODE; +import static io.a2a.jsonrpc.common.json.JsonUtil.ThrowableTypeAdapter.THROWABLE_MARKER_FIELD; +import io.a2a.spec.A2AErrorCodes; import static io.a2a.spec.DataPart.DATA; import static io.a2a.spec.TextPart.TEXT; import static java.lang.String.format; import static java.util.Collections.emptyMap; -import java.io.StringReader; import java.lang.reflect.Type; import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; @@ -44,6 +33,8 @@ import io.a2a.spec.APIKeySecurityScheme; import io.a2a.spec.ContentTypeNotSupportedError; import io.a2a.spec.DataPart; +import io.a2a.spec.ExtendedAgentCardNotConfiguredError; +import io.a2a.spec.ExtensionSupportRequiredError; import io.a2a.spec.FileContent; import io.a2a.spec.FilePart; import io.a2a.spec.FileWithBytes; @@ -70,6 +61,7 @@ import io.a2a.spec.TaskStatusUpdateEvent; import io.a2a.spec.TextPart; import io.a2a.spec.UnsupportedOperationError; +import io.a2a.spec.VersionNotSupportedError; import org.jspecify.annotations.Nullable; @@ -218,6 +210,8 @@ OffsetDateTime read(JsonReader in) throws java.io.IOException { */ static class ThrowableTypeAdapter extends TypeAdapter { + static final String THROWABLE_MARKER_FIELD = "__throwable"; + @Override public void write(JsonWriter out, Throwable value) throws java.io.IOException { if (value == null) { @@ -305,10 +299,8 @@ Throwable read(JsonReader in) throws java.io.IOException { */ static class A2AErrorTypeAdapter extends TypeAdapter { - private static final ThrowableTypeAdapter THROWABLE_ADAPTER = new ThrowableTypeAdapter(); - static final String THROWABLE_MARKER_FIELD = "__throwable"; private static final String CODE_FIELD = "code"; - private static final String DATA_FIELD = "data"; + private static final String DETAILS_FIELD = "details"; private static final String MESSAGE_FIELD = "message"; private static final String TYPE_FIELD = "type"; @@ -321,15 +313,9 @@ public void write(JsonWriter out, A2AError value) throws java.io.IOException { out.beginObject(); out.name(CODE_FIELD).value(value.getCode()); out.name(MESSAGE_FIELD).value(value.getMessage()); - if (value.getData() != null) { - out.name(DATA_FIELD); - // If data is a Throwable, use ThrowableTypeAdapter to avoid reflection issues - if (value.getData() instanceof Throwable throwable) { - THROWABLE_ADAPTER.write(out, throwable); - } else { - // Use Gson to serialize the data field for non-Throwable types - OBJECT_MAPPER.toJson(value.getData(), Object.class, out); - } + if (!value.getDetails().isEmpty()) { + out.name(DETAILS_FIELD); + OBJECT_MAPPER.toJson(value.getDetails(), Map.class, out); } out.endObject(); } @@ -344,7 +330,7 @@ A2AError read(JsonReader in) throws java.io.IOException { Integer code = null; String message = null; - Object data = null; + Map details = null; in.beginObject(); while (in.hasNext()) { @@ -354,9 +340,9 @@ A2AError read(JsonReader in) throws java.io.IOException { code = in.nextInt(); case MESSAGE_FIELD -> message = in.nextString(); - case DATA_FIELD -> { - // Read data as a generic object (could be string, number, object, etc.) - data = readDataValue(in); + case DETAILS_FIELD -> { + // Read details as a map + details = readDetailsValue(in); } default -> in.skipValue(); @@ -365,82 +351,53 @@ A2AError read(JsonReader in) throws java.io.IOException { in.endObject(); // Create the appropriate subclass based on the error code - return createErrorInstance(code, message, data); + return createErrorInstance(code, message, details); } /** - * Reads the data field value, which can be of any JSON type. + * Reads the details field value as a map. */ + @SuppressWarnings("unchecked") private @Nullable - Object readDataValue(JsonReader in) throws java.io.IOException { - return switch (in.peek()) { - case STRING -> - in.nextString(); - case NUMBER -> - in.nextDouble(); - case BOOLEAN -> - in.nextBoolean(); - case NULL -> { - in.nextNull(); - yield null; - } - case BEGIN_OBJECT -> { - // Parse as JsonElement to check if it's a Throwable - com.google.gson.JsonElement element = com.google.gson.JsonParser.parseReader(in); - if (element.isJsonObject()) { - com.google.gson.JsonObject obj = element.getAsJsonObject(); - // Check if it has the structure of a serialized Throwable (type + message) - if (obj.has(TYPE_FIELD) && obj.has(MESSAGE_FIELD) && obj.has(THROWABLE_MARKER_FIELD)) { - // Deserialize as Throwable using ThrowableTypeAdapter - yield THROWABLE_ADAPTER.read(new JsonReader(new StringReader(element.toString()))); - } - } - // Otherwise, deserialize as generic object - yield OBJECT_MAPPER.fromJson(element, Object.class); - } - case BEGIN_ARRAY -> - // For arrays, read as raw JSON using Gson - OBJECT_MAPPER.fromJson(in, Object.class); - default -> { - in.skipValue(); - yield null; - } - }; + Map readDetailsValue(JsonReader in) throws java.io.IOException { + if (in.peek() == com.google.gson.stream.JsonToken.NULL) { + in.nextNull(); + return null; + } + if (in.peek() == com.google.gson.stream.JsonToken.BEGIN_OBJECT) { + return (Map) OBJECT_MAPPER.fromJson(in, Map.class); + } + in.skipValue(); + return null; } /** * Creates the appropriate A2AError subclass based on the error code. */ - private A2AError createErrorInstance(@Nullable Integer code, @Nullable String message, @Nullable Object data) { + private A2AError createErrorInstance(@Nullable Integer code, @Nullable String message, @Nullable Map details) { if (code == null) { throw new JsonSyntaxException("A2AError must have a code field"); } - return switch (code) { - case JSON_PARSE_ERROR_CODE -> - new JSONParseError(code, message, data); - case INVALID_REQUEST_ERROR_CODE -> - new InvalidRequestError(code, message, data); - case METHOD_NOT_FOUND_ERROR_CODE -> - new MethodNotFoundError(code, message, data); - case INVALID_PARAMS_ERROR_CODE -> - new InvalidParamsError(code, message, data); - case INTERNAL_ERROR_CODE -> - new io.a2a.spec.InternalError(code, message, data); - case TASK_NOT_FOUND_ERROR_CODE -> - new TaskNotFoundError(message, data); - case TASK_NOT_CANCELABLE_ERROR_CODE -> - new TaskNotCancelableError(code, message, data); - case PUSH_NOTIFICATION_NOT_SUPPORTED_ERROR_CODE -> - new PushNotificationNotSupportedError(code, message, data); - case UNSUPPORTED_OPERATION_ERROR_CODE -> - new UnsupportedOperationError(code, message, data); - case CONTENT_TYPE_NOT_SUPPORTED_ERROR_CODE -> - new ContentTypeNotSupportedError(code, message, data); - case INVALID_AGENT_RESPONSE_ERROR_CODE -> - new InvalidAgentResponseError(code, message, data); - default -> - new A2AError(code, message == null ? "" : message, data); + A2AErrorCodes errorCode = A2AErrorCodes.fromCode(code); + if (errorCode == null) { + return new A2AError(code, message == null ? "" : message, details); + } + return switch (errorCode) { + case JSON_PARSE -> new JSONParseError(code, message, details); + case INVALID_REQUEST -> new InvalidRequestError(code, message, details); + case METHOD_NOT_FOUND -> new MethodNotFoundError(code, message, details); + case INVALID_PARAMS -> new InvalidParamsError(code, message, details); + case INTERNAL -> new io.a2a.spec.InternalError(code, message, details); + case TASK_NOT_FOUND -> new TaskNotFoundError(message, details); + case TASK_NOT_CANCELABLE -> new TaskNotCancelableError(code, message, details); + case PUSH_NOTIFICATION_NOT_SUPPORTED -> new PushNotificationNotSupportedError(code, message, details); + case UNSUPPORTED_OPERATION -> new UnsupportedOperationError(code, message, details); + case CONTENT_TYPE_NOT_SUPPORTED -> new ContentTypeNotSupportedError(code, message, details); + case INVALID_AGENT_RESPONSE -> new InvalidAgentResponseError(code, message, details); + case EXTENDED_AGENT_CARD_NOT_CONFIGURED -> new ExtendedAgentCardNotConfiguredError(code, message, details); + case EXTENSION_SUPPORT_REQUIRED -> new ExtensionSupportRequiredError(code, message, details); + case VERSION_NOT_SUPPORTED -> new VersionNotSupportedError(code, message, details); }; } } diff --git a/jsonrpc-common/src/test/java/io/a2a/jsonrpc/common/json/A2AErrorSerializationTest.java b/jsonrpc-common/src/test/java/io/a2a/jsonrpc/common/json/A2AErrorSerializationTest.java index ff7ee5cc1..734ff47e0 100644 --- a/jsonrpc-common/src/test/java/io/a2a/jsonrpc/common/json/A2AErrorSerializationTest.java +++ b/jsonrpc-common/src/test/java/io/a2a/jsonrpc/common/json/A2AErrorSerializationTest.java @@ -1,22 +1,12 @@ package io.a2a.jsonrpc.common.json; -import static io.a2a.spec.A2AErrorCodes.CONTENT_TYPE_NOT_SUPPORTED_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.INTERNAL_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.INVALID_AGENT_RESPONSE_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.INVALID_PARAMS_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.INVALID_REQUEST_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.JSON_PARSE_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.METHOD_NOT_FOUND_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.PUSH_NOTIFICATION_NOT_SUPPORTED_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.TASK_NOT_CANCELABLE_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.TASK_NOT_FOUND_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.UNSUPPORTED_OPERATION_ERROR_CODE; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import java.util.List; import io.a2a.spec.A2AError; +import io.a2a.spec.A2AErrorCodes; import io.a2a.spec.ContentTypeNotSupportedError; import io.a2a.spec.InternalError; import io.a2a.spec.InvalidAgentResponseError; @@ -35,22 +25,23 @@ public class A2AErrorSerializationTest { @Test public void shouldDeserializeToCorrectA2AErrorSubclass() throws JsonProcessingException { String jsonTemplate = """ - {"code": %s, "message": "error", "data": "anything"} + {"code": %s, "message": "error", "details": {"key": "anything"}} """; record ErrorCase(int code, Class clazz) {} - List cases = List.of(new ErrorCase(JSON_PARSE_ERROR_CODE, JSONParseError.class), - new ErrorCase(INVALID_REQUEST_ERROR_CODE, InvalidRequestError.class), - new ErrorCase(METHOD_NOT_FOUND_ERROR_CODE, MethodNotFoundError.class), - new ErrorCase(INVALID_PARAMS_ERROR_CODE, InvalidParamsError.class), - new ErrorCase(INTERNAL_ERROR_CODE, InternalError.class), - new ErrorCase(PUSH_NOTIFICATION_NOT_SUPPORTED_ERROR_CODE, PushNotificationNotSupportedError.class), - new ErrorCase(UNSUPPORTED_OPERATION_ERROR_CODE, UnsupportedOperationError.class), - new ErrorCase(CONTENT_TYPE_NOT_SUPPORTED_ERROR_CODE, ContentTypeNotSupportedError.class), - new ErrorCase(INVALID_AGENT_RESPONSE_ERROR_CODE, InvalidAgentResponseError.class), - new ErrorCase(TASK_NOT_CANCELABLE_ERROR_CODE, TaskNotCancelableError.class), - new ErrorCase(TASK_NOT_FOUND_ERROR_CODE, TaskNotFoundError.class), + List cases = List.of( + new ErrorCase(A2AErrorCodes.JSON_PARSE.code(), JSONParseError.class), + new ErrorCase(A2AErrorCodes.INVALID_REQUEST.code(), InvalidRequestError.class), + new ErrorCase(A2AErrorCodes.METHOD_NOT_FOUND.code(), MethodNotFoundError.class), + new ErrorCase(A2AErrorCodes.INVALID_PARAMS.code(), InvalidParamsError.class), + new ErrorCase(A2AErrorCodes.INTERNAL.code(), InternalError.class), + new ErrorCase(A2AErrorCodes.PUSH_NOTIFICATION_NOT_SUPPORTED.code(), PushNotificationNotSupportedError.class), + new ErrorCase(A2AErrorCodes.UNSUPPORTED_OPERATION.code(), UnsupportedOperationError.class), + new ErrorCase(A2AErrorCodes.CONTENT_TYPE_NOT_SUPPORTED.code(), ContentTypeNotSupportedError.class), + new ErrorCase(A2AErrorCodes.INVALID_AGENT_RESPONSE.code(), InvalidAgentResponseError.class), + new ErrorCase(A2AErrorCodes.TASK_NOT_CANCELABLE.code(), TaskNotCancelableError.class), + new ErrorCase(A2AErrorCodes.TASK_NOT_FOUND.code(), TaskNotFoundError.class), new ErrorCase(Integer.MAX_VALUE, A2AError.class) // Any unknown code will be treated as A2AError ); @@ -59,7 +50,7 @@ record ErrorCase(int code, Class clazz) {} A2AError error = JsonUtil.fromJson(json, A2AError.class); assertInstanceOf(errorCase.clazz(), error); assertEquals("error", error.getMessage()); - assertEquals("anything", error.getData().toString()); + assertEquals("anything", error.getDetails().get("key")); } } diff --git a/reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java b/reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java index 6914dc5f9..eaf6c02b3 100644 --- a/reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java +++ b/reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java @@ -580,7 +580,7 @@ private String extractTenant(RoutingContext rc) { * "error": { * "code": -32602, * "message": "Invalid params", - * "data": { ... } + * "details": { ... } * } * } * } diff --git a/reference/rest/src/test/java/io/a2a/server/rest/quarkus/A2AServerRoutesTest.java b/reference/rest/src/test/java/io/a2a/server/rest/quarkus/A2AServerRoutesTest.java index 80a30e2f6..09a45c828 100644 --- a/reference/rest/src/test/java/io/a2a/server/rest/quarkus/A2AServerRoutesTest.java +++ b/reference/rest/src/test/java/io/a2a/server/rest/quarkus/A2AServerRoutesTest.java @@ -447,7 +447,7 @@ public void testSendMessage_UnsupportedContentType_ReturnsContentTypeNotSupporte HTTPRestResponse mockErrorResponse = mock(HTTPRestResponse.class); when(mockErrorResponse.getStatusCode()).thenReturn(415); when(mockErrorResponse.getContentType()).thenReturn("application/problem+json"); - when(mockErrorResponse.getBody()).thenReturn("{\"type\":\"https://a2a-protocol.org/errors/content-type-not-supported\"}"); + when(mockErrorResponse.getBody()).thenReturn("{\"error\":{\"code\":415,\"status\":\"INVALID_ARGUMENT\",\"message\":\"Incompatible content types\",\"details\":[{\"reason\":\"CONTENT_TYPE_NOT_SUPPORTED\",\"domain\":\"a2a-protocol.org\"}]}}"); when(mockRestHandler.createErrorResponse(any(ContentTypeNotSupportedError.class))).thenReturn(mockErrorResponse); when(mockRequest.getHeader(any(CharSequence.class))).thenReturn("text/plain"); @@ -465,7 +465,7 @@ public void testSendMessageStreaming_UnsupportedContentType_ReturnsContentTypeNo HTTPRestResponse mockErrorResponse = mock(HTTPRestResponse.class); when(mockErrorResponse.getStatusCode()).thenReturn(415); when(mockErrorResponse.getContentType()).thenReturn("application/problem+json"); - when(mockErrorResponse.getBody()).thenReturn("{\"type\":\"https://a2a-protocol.org/errors/content-type-not-supported\"}"); + when(mockErrorResponse.getBody()).thenReturn("{\"error\":{\"code\":415,\"status\":\"INVALID_ARGUMENT\",\"message\":\"Incompatible content types\",\"details\":[{\"reason\":\"CONTENT_TYPE_NOT_SUPPORTED\",\"domain\":\"a2a-protocol.org\"}]}}"); when(mockRestHandler.createErrorResponse(any(ContentTypeNotSupportedError.class))).thenReturn(mockErrorResponse); when(mockRequest.getHeader(any(CharSequence.class))).thenReturn("text/plain"); @@ -483,7 +483,7 @@ public void testSendMessage_UnsupportedProtocolVersion_ReturnsVersionNotSupporte HTTPRestResponse mockErrorResponse = mock(HTTPRestResponse.class); when(mockErrorResponse.getStatusCode()).thenReturn(400); when(mockErrorResponse.getContentType()).thenReturn("application/problem+json"); - when(mockErrorResponse.getBody()).thenReturn("{\"type\":\"https://a2a-protocol.org/errors/version-not-supported\"}"); + when(mockErrorResponse.getBody()).thenReturn("{\"error\":{\"code\":400,\"status\":\"UNIMPLEMENTED\",\"message\":\"Protocol version not supported\",\"details\":[{\"reason\":\"VERSION_NOT_SUPPORTED\",\"domain\":\"a2a-protocol.org\"}]}}"); when(mockRequest.getHeader(any(CharSequence.class))).thenReturn("application/json"); when(mockRestHandler.sendMessage(any(ServerCallContext.class), anyString(), anyString())) .thenReturn(mockErrorResponse); diff --git a/reference/rest/src/test/java/io/a2a/server/rest/quarkus/QuarkusA2ARestTest.java b/reference/rest/src/test/java/io/a2a/server/rest/quarkus/QuarkusA2ARestTest.java index 8e234f9f9..15f5178a3 100644 --- a/reference/rest/src/test/java/io/a2a/server/rest/quarkus/QuarkusA2ARestTest.java +++ b/reference/rest/src/test/java/io/a2a/server/rest/quarkus/QuarkusA2ARestTest.java @@ -42,8 +42,8 @@ public void testSendMessageWithUnsupportedContentType() throws Exception { .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); Assertions.assertEquals(415, response.statusCode()); - Assertions.assertTrue(response.body().contains("content-type-not-supported"), - "Expected content-type-not-supported in response body: " + response.body()); + Assertions.assertTrue(response.body().contains("CONTENT_TYPE_NOT_SUPPORTED"), + "Expected CONTENT_TYPE_NOT_SUPPORTED in response body: " + response.body()); } @Test @@ -59,8 +59,8 @@ public void testSendMessageWithUnsupportedProtocolVersion() throws Exception { .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); Assertions.assertEquals(400, response.statusCode()); - Assertions.assertTrue(response.body().contains("version-not-supported"), - "Expected version-not-supported in response body: " + response.body()); + Assertions.assertTrue(response.body().contains("VERSION_NOT_SUPPORTED"), + "Expected VERSION_NOT_SUPPORTED in response body: " + response.body()); } @Test diff --git a/spec-grpc/src/main/java/io/a2a/grpc/utils/JSONRPCUtils.java b/spec-grpc/src/main/java/io/a2a/grpc/utils/JSONRPCUtils.java index 9ea4a2a38..a21008bda 100644 --- a/spec-grpc/src/main/java/io/a2a/grpc/utils/JSONRPCUtils.java +++ b/spec-grpc/src/main/java/io/a2a/grpc/utils/JSONRPCUtils.java @@ -1,25 +1,13 @@ package io.a2a.grpc.utils; -import static io.a2a.spec.A2AErrorCodes.CONTENT_TYPE_NOT_SUPPORTED_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.EXTENDED_AGENT_CARD_NOT_CONFIGURED_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.EXTENSION_SUPPORT_REQUIRED_ERROR; -import static io.a2a.spec.A2AErrorCodes.INTERNAL_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.INVALID_AGENT_RESPONSE_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.INVALID_PARAMS_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.INVALID_REQUEST_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.JSON_PARSE_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.METHOD_NOT_FOUND_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.PUSH_NOTIFICATION_NOT_SUPPORTED_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.TASK_NOT_CANCELABLE_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.TASK_NOT_FOUND_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.UNSUPPORTED_OPERATION_ERROR_CODE; -import static io.a2a.spec.A2AErrorCodes.VERSION_NOT_SUPPORTED_ERROR_CODE; +import io.a2a.spec.A2AErrorCodes; import static io.a2a.spec.A2AMethods.CANCEL_TASK_METHOD; import static io.a2a.spec.A2AMethods.GET_EXTENDED_AGENT_CARD_METHOD; import static io.a2a.spec.A2AMethods.SEND_STREAMING_MESSAGE_METHOD; import java.io.IOException; import java.io.StringWriter; +import java.util.Map; import java.util.UUID; import java.util.logging.Level; import java.util.logging.Logger; @@ -399,45 +387,37 @@ public static A2AResponse parseError(JsonObject error, Object id, String meth } } + @SuppressWarnings("unchecked") private static A2AError processError(JsonObject error) { String message = error.has("message") ? error.get("message").getAsString() : null; Integer code = error.has("code") ? error.get("code").getAsInt() : null; - String data = error.has("data") ? error.get("data").toString() : null; + Map details = null; + if (error.has("details") && error.get("details").isJsonObject()) { + details =GSON.fromJson(error.get("details"), Map.class); + } if (code != null) { - switch (code) { - case JSON_PARSE_ERROR_CODE: - return new JSONParseError(code, message, data); - case INVALID_REQUEST_ERROR_CODE: - return new InvalidRequestError(code, message, data); - case METHOD_NOT_FOUND_ERROR_CODE: - return new MethodNotFoundError(code, message, data); - case INVALID_PARAMS_ERROR_CODE: - return new InvalidParamsError(code, message, data); - case INTERNAL_ERROR_CODE: - return new io.a2a.spec.InternalError(code, message, data); - case PUSH_NOTIFICATION_NOT_SUPPORTED_ERROR_CODE: - return new PushNotificationNotSupportedError(code, message, data); - case UNSUPPORTED_OPERATION_ERROR_CODE: - return new UnsupportedOperationError(code, message, data); - case CONTENT_TYPE_NOT_SUPPORTED_ERROR_CODE: - return new ContentTypeNotSupportedError(code, message, data); - case INVALID_AGENT_RESPONSE_ERROR_CODE: - return new InvalidAgentResponseError(code, message, data); - case EXTENDED_AGENT_CARD_NOT_CONFIGURED_ERROR_CODE: - return new ExtendedAgentCardNotConfiguredError(code, message, data); - case EXTENSION_SUPPORT_REQUIRED_ERROR: - return new ExtensionSupportRequiredError(code, message, data); - case VERSION_NOT_SUPPORTED_ERROR_CODE: - return new VersionNotSupportedError(code, message, data); - case TASK_NOT_CANCELABLE_ERROR_CODE: - return new TaskNotCancelableError(code, message, data); - case TASK_NOT_FOUND_ERROR_CODE: - return new TaskNotFoundError(message, data); - default: - return new A2AError(code, message == null ? "": message, data); - } + A2AErrorCodes errorCode = A2AErrorCodes.fromCode(code); + if (errorCode != null) { + return switch (errorCode) { + case JSON_PARSE -> new JSONParseError(code, message, details); + case INVALID_REQUEST -> new InvalidRequestError(code, message, details); + case METHOD_NOT_FOUND -> new MethodNotFoundError(code, message, details); + case INVALID_PARAMS -> new InvalidParamsError(code, message, details); + case INTERNAL -> new io.a2a.spec.InternalError(code, message, details); + case PUSH_NOTIFICATION_NOT_SUPPORTED -> new PushNotificationNotSupportedError(code, message, details); + case UNSUPPORTED_OPERATION -> new UnsupportedOperationError(code, message, details); + case CONTENT_TYPE_NOT_SUPPORTED -> new ContentTypeNotSupportedError(code, message, details); + case INVALID_AGENT_RESPONSE -> new InvalidAgentResponseError(code, message, details); + case EXTENDED_AGENT_CARD_NOT_CONFIGURED -> new ExtendedAgentCardNotConfiguredError(code, message, details); + case EXTENSION_SUPPORT_REQUIRED -> new ExtensionSupportRequiredError(code, message, details); + case VERSION_NOT_SUPPORTED -> new VersionNotSupportedError(code, message, details); + case TASK_NOT_CANCELABLE -> new TaskNotCancelableError(code, message, details); + case TASK_NOT_FOUND -> new TaskNotFoundError(message, details); + }; + } + return new A2AError(code, message == null ? "" : message, details); } - return new A2AError(INTERNAL_ERROR_CODE, message == null ? "": message, data); + return new A2AError(A2AErrorCodes.INTERNAL.code(), message == null ? "" : message, details); } protected static void parseRequestBody(JsonElement jsonRpc, com.google.protobuf.Message.Builder builder, Object id) throws JsonProcessingException { @@ -625,8 +605,9 @@ public static String toJsonRPCErrorResponse(Object requestId, A2AError error) { output.beginObject(); output.name("code").value(error.getCode()); output.name("message").value(error.getMessage()); - if (error.getData() != null) { - output.name("data").value(error.getData().toString()); + if (!error.getDetails().isEmpty()) { + output.name("details"); + GSON.toJson(error.getDetails(), Map.class, output); } output.endObject(); output.endObject(); diff --git a/spec/src/main/java/io/a2a/spec/A2AError.java b/spec/src/main/java/io/a2a/spec/A2AError.java index c81bbbe32..eaec2b0fe 100644 --- a/spec/src/main/java/io/a2a/spec/A2AError.java +++ b/spec/src/main/java/io/a2a/spec/A2AError.java @@ -1,5 +1,7 @@ package io.a2a.spec; +import java.util.Map; + import io.a2a.util.Assert; import org.jspecify.annotations.Nullable; @@ -35,24 +37,24 @@ public class A2AError extends RuntimeException implements Event { private final Integer code; /** - * Additional error information (structure defined by the error code). + * Additional error details as key-value pairs. */ - private final @Nullable Object data; + private final Map details; /** - * Constructs a JSON-RPC error with the specified code, message, and optional data. + * Constructs a JSON-RPC error with the specified code, message, and optional details. *

* This constructor is used by Jackson for JSON deserialization. * * @param code the numeric error code (required, see JSON-RPC 2.0 spec for standard codes) * @param message the human-readable error message (required) - * @param data additional error information, structure defined by the error code (optional) + * @param details additional error details as key-value pairs (defaults to empty map if null) * @throws IllegalArgumentException if code or message is null */ - public A2AError(Integer code, String message, @Nullable Object data) { + public A2AError(Integer code, String message, @Nullable Map details) { super(Assert.checkNotNullParam("message", message)); this.code = Assert.checkNotNullParam("code", code); - this.data = data; + this.details = details == null ? Map.of() : Map.copyOf(details); } /** @@ -75,15 +77,11 @@ public Integer getCode() { } /** - * Gets additional information about the error. - *

- * The structure and type of the data field is defined by the specific error code. - * It may contain detailed debugging information, validation errors, or other - * context-specific data to help diagnose the error. + * Gets additional details about the error as key-value pairs. * - * @return the error data, or null if not provided + * @return the error details, never null (empty map if no details provided) */ - public @Nullable Object getData() { - return data; + public Map getDetails() { + return details; } } diff --git a/spec/src/main/java/io/a2a/spec/A2AErrorCodes.java b/spec/src/main/java/io/a2a/spec/A2AErrorCodes.java index 8eb4c6584..1143d7dfc 100644 --- a/spec/src/main/java/io/a2a/spec/A2AErrorCodes.java +++ b/spec/src/main/java/io/a2a/spec/A2AErrorCodes.java @@ -1,51 +1,115 @@ package io.a2a.spec; +import org.jspecify.annotations.Nullable; + /** * All the error codes for A2A errors. + *

+ * Each constant provides: + *

    + *
  • {@link #code()} - the JSON-RPC error code
  • + *
  • {@link #grpcStatus()} - the corresponding gRPC status name
  • + *
  • {@link #httpCode()} - the HTTP status code
  • + *
+ * + * @see A2A Protocol Specification - Error Code Mappings */ -public interface A2AErrorCodes { +public enum A2AErrorCodes { /** Error code indicating the requested task was not found (-32001). */ - int TASK_NOT_FOUND_ERROR_CODE = -32001; + TASK_NOT_FOUND(-32001, "NOT_FOUND", 404), /** Error code indicating the task cannot be canceled in its current state (-32002). */ - int TASK_NOT_CANCELABLE_ERROR_CODE = -32002; + TASK_NOT_CANCELABLE(-32002, "FAILED_PRECONDITION", 409), /** Error code indicating push notifications are not supported by this agent (-32003). */ - int PUSH_NOTIFICATION_NOT_SUPPORTED_ERROR_CODE = -32003; + PUSH_NOTIFICATION_NOT_SUPPORTED(-32003, "UNIMPLEMENTED", 400), /** Error code indicating the requested operation is not supported (-32004). */ - int UNSUPPORTED_OPERATION_ERROR_CODE = -32004; + UNSUPPORTED_OPERATION(-32004, "UNIMPLEMENTED", 400), /** Error code indicating the content type is not supported (-32005). */ - int CONTENT_TYPE_NOT_SUPPORTED_ERROR_CODE = -32005; + CONTENT_TYPE_NOT_SUPPORTED(-32005, "INVALID_ARGUMENT", 415), /** Error code indicating the agent returned an invalid response (-32006). */ - int INVALID_AGENT_RESPONSE_ERROR_CODE = -32006; + INVALID_AGENT_RESPONSE(-32006, "INTERNAL", 502), /** Error code indicating extended agent card is not configured (-32007). */ - int EXTENDED_AGENT_CARD_NOT_CONFIGURED_ERROR_CODE = -32007; + EXTENDED_AGENT_CARD_NOT_CONFIGURED(-32007, "FAILED_PRECONDITION", 400), /** Error code indicating client requested use of an extension marked as required: true in the Agent Card * but the client did not declare support for it in the request (-32008). */ - int EXTENSION_SUPPORT_REQUIRED_ERROR = -32008; + EXTENSION_SUPPORT_REQUIRED(-32008, "FAILED_PRECONDITION", 400), /** Error code indicating the A2A protocol version specified in the request (via A2A-Version service parameter) * is not supported by the agent (-32009). */ - int VERSION_NOT_SUPPORTED_ERROR_CODE = -32009; + VERSION_NOT_SUPPORTED(-32009, "UNIMPLEMENTED", 400), /** JSON-RPC error code for invalid request structure (-32600). */ - int INVALID_REQUEST_ERROR_CODE = -32600; + INVALID_REQUEST(-32600, "INVALID_ARGUMENT", 400), /** JSON-RPC error code for method not found (-32601). */ - int METHOD_NOT_FOUND_ERROR_CODE = -32601; + METHOD_NOT_FOUND(-32601, "NOT_FOUND", 404), /** JSON-RPC error code for invalid method parameters (-32602). */ - int INVALID_PARAMS_ERROR_CODE = -32602; + INVALID_PARAMS(-32602, "INVALID_ARGUMENT", 422), /** JSON-RPC error code for internal server errors (-32603). */ - int INTERNAL_ERROR_CODE = -32603; + INTERNAL(-32603, "INTERNAL", 500), /** JSON-RPC error code for JSON parsing errors (-32700). */ - int JSON_PARSE_ERROR_CODE = -32700; + JSON_PARSE(-32700, "INVALID_ARGUMENT", 400); + + private final int code; + private final String grpcStatus; + private final int httpCode; + + A2AErrorCodes(int code, String grpcStatus, int httpCode) { + this.code = code; + this.grpcStatus = grpcStatus; + this.httpCode = httpCode; + } + + /** + * Returns the JSON-RPC error code. + * + * @return the numeric error code + */ + public int code() { + return code; + } + + /** + * Returns the corresponding gRPC status name. + * + * @return the gRPC status string (e.g., "NOT_FOUND", "INTERNAL") + */ + public String grpcStatus() { + return grpcStatus; + } + + /** + * Returns the HTTP status code. + * + * @return the HTTP status code + */ + public int httpCode() { + return httpCode; + } + + /** + * Looks up an error code enum constant by its JSON-RPC numeric code. + * + * @param code the JSON-RPC error code + * @return the matching enum constant, or {@code null} if not found + */ + public static @Nullable A2AErrorCodes fromCode(int code) { + for (A2AErrorCodes e : values()) { + if (e.code == code) { + return e; + } + } + return null; + } + } diff --git a/spec/src/main/java/io/a2a/spec/A2AProtocolError.java b/spec/src/main/java/io/a2a/spec/A2AProtocolError.java index b03ba4080..dfa4e961e 100644 --- a/spec/src/main/java/io/a2a/spec/A2AProtocolError.java +++ b/spec/src/main/java/io/a2a/spec/A2AProtocolError.java @@ -1,46 +1,28 @@ package io.a2a.spec; +import java.util.Map; + import org.jspecify.annotations.Nullable; /** - * Represents a protocol-level error in the A2A Protocol with a reference URL. + * Represents a protocol-level error in the A2A Protocol. *

- * This error extends {@link A2AError} to include a URL field that provides a reference - * to documentation, specification details, or additional context about the protocol error. - * This is particularly useful for protocol version mismatches, unsupported features, or - * other situations where pointing to the protocol specification would help diagnose the issue. + * This error extends {@link A2AError} to distinguish A2A protocol-specific errors + * from standard JSON-RPC errors. Protocol errors have dedicated error codes in the + * A2A specification. * * @see A2AError for the base error implementation */ public class A2AProtocolError extends A2AError { /** - * URL reference for additional information about this protocol error. - */ - private final @Nullable String url; - - /** - * Constructs a protocol error with the specified code, message, data, and reference URL. + * Constructs a protocol error with the specified code, message, and details. * * @param code the numeric error code (required, see JSON-RPC 2.0 spec for standard codes) * @param message the human-readable error message (required) - * @param data additional error information, structure defined by the error code (optional) - * @param url URL reference providing additional context about this protocol error (optional) - */ - public A2AProtocolError(Integer code, String message, @Nullable Object data, @Nullable String url) { - super(code, message, data); - this.url = url; - } - - /** - * Gets the URL reference for additional information about this protocol error. - *

- * This URL typically points to protocol specification documentation or other resources - * that provide context about the error condition. - * - * @return the reference URL, or null if not provided + * @param details additional error details as key-value pairs (defaults to empty map if null) */ - public @Nullable String getUrl() { - return url; + public A2AProtocolError(Integer code, String message, @Nullable Map details) { + super(code, message, details); } } diff --git a/spec/src/main/java/io/a2a/spec/ContentTypeNotSupportedError.java b/spec/src/main/java/io/a2a/spec/ContentTypeNotSupportedError.java index cb657967c..b87addb87 100644 --- a/spec/src/main/java/io/a2a/spec/ContentTypeNotSupportedError.java +++ b/spec/src/main/java/io/a2a/spec/ContentTypeNotSupportedError.java @@ -1,8 +1,9 @@ package io.a2a.spec; -import static io.a2a.spec.A2AErrorCodes.CONTENT_TYPE_NOT_SUPPORTED_ERROR_CODE; import static io.a2a.util.Utils.defaultIfNull; +import java.util.Map; + import org.jspecify.annotations.Nullable; /** @@ -48,12 +49,11 @@ public ContentTypeNotSupportedError() { * * @param code the error code * @param message the error message - * @param data additional error data + * @param details additional error details */ - public ContentTypeNotSupportedError(@Nullable Integer code, @Nullable String message, @Nullable Object data) { - super(defaultIfNull(code, CONTENT_TYPE_NOT_SUPPORTED_ERROR_CODE), + public ContentTypeNotSupportedError(@Nullable Integer code, @Nullable String message, @Nullable Map details) { + super(defaultIfNull(code, A2AErrorCodes.CONTENT_TYPE_NOT_SUPPORTED.code()), defaultIfNull(message, "Incompatible content types"), - data, - "https://a2a-protocol.org/errors/content-type-not-supported"); + details); } } diff --git a/spec/src/main/java/io/a2a/spec/ExtendedAgentCardNotConfiguredError.java b/spec/src/main/java/io/a2a/spec/ExtendedAgentCardNotConfiguredError.java index 5b961e2bf..941788abd 100644 --- a/spec/src/main/java/io/a2a/spec/ExtendedAgentCardNotConfiguredError.java +++ b/spec/src/main/java/io/a2a/spec/ExtendedAgentCardNotConfiguredError.java @@ -1,8 +1,9 @@ package io.a2a.spec; -import static io.a2a.spec.A2AErrorCodes.EXTENDED_AGENT_CARD_NOT_CONFIGURED_ERROR_CODE; import static io.a2a.util.Utils.defaultIfNull; +import java.util.Map; + import org.jspecify.annotations.Nullable; @@ -36,13 +37,12 @@ public class ExtendedAgentCardNotConfiguredError extends A2AProtocolError { * * @param code the error code * @param message the error message - * @param data additional error data + * @param details additional error details */ - public ExtendedAgentCardNotConfiguredError(@Nullable Integer code, @Nullable String message, @Nullable Object data) { + public ExtendedAgentCardNotConfiguredError(@Nullable Integer code, @Nullable String message, @Nullable Map details) { super( - defaultIfNull(code, EXTENDED_AGENT_CARD_NOT_CONFIGURED_ERROR_CODE), + defaultIfNull(code, A2AErrorCodes.EXTENDED_AGENT_CARD_NOT_CONFIGURED.code()), defaultIfNull(message, "Extended Card not configured"), - data, - "https://a2a-protocol.org/errors/extended-agent-card-not-configured"); + details); } } diff --git a/spec/src/main/java/io/a2a/spec/ExtensionSupportRequiredError.java b/spec/src/main/java/io/a2a/spec/ExtensionSupportRequiredError.java index abb11aa9f..fd7882202 100644 --- a/spec/src/main/java/io/a2a/spec/ExtensionSupportRequiredError.java +++ b/spec/src/main/java/io/a2a/spec/ExtensionSupportRequiredError.java @@ -1,8 +1,9 @@ package io.a2a.spec; -import static io.a2a.spec.A2AErrorCodes.EXTENSION_SUPPORT_REQUIRED_ERROR; import static io.a2a.util.Utils.defaultIfNull; +import java.util.Map; + import org.jspecify.annotations.Nullable; @@ -36,13 +37,12 @@ public class ExtensionSupportRequiredError extends A2AProtocolError { * * @param code the error code (defaults to -32008 if null) * @param message the error message (defaults to standard message if null) - * @param data additional error data (optional) + * @param details additional error details (optional) */ - public ExtensionSupportRequiredError(@Nullable Integer code, @Nullable String message, @Nullable Object data) { + public ExtensionSupportRequiredError(@Nullable Integer code, @Nullable String message, @Nullable Map details) { super( - defaultIfNull(code, EXTENSION_SUPPORT_REQUIRED_ERROR), + defaultIfNull(code, A2AErrorCodes.EXTENSION_SUPPORT_REQUIRED.code()), defaultIfNull(message, "Extension support required but not declared"), - data, - "https://a2a-protocol.org/errors/extension-support-required"); + details); } } diff --git a/spec/src/main/java/io/a2a/spec/InternalError.java b/spec/src/main/java/io/a2a/spec/InternalError.java index 5d9793972..d6809f314 100644 --- a/spec/src/main/java/io/a2a/spec/InternalError.java +++ b/spec/src/main/java/io/a2a/spec/InternalError.java @@ -1,8 +1,9 @@ package io.a2a.spec; -import static io.a2a.spec.A2AErrorCodes.INTERNAL_ERROR_CODE; import static io.a2a.util.Utils.defaultIfNull; +import java.util.Map; + import org.jspecify.annotations.Nullable; @@ -34,13 +35,13 @@ public class InternalError extends A2AError { * * @param code the error code * @param message the error message - * @param data additional error data + * @param details additional error details */ - public InternalError(@Nullable Integer code, @Nullable String message, @Nullable Object data) { + public InternalError(@Nullable Integer code, @Nullable String message, @Nullable Map details) { super( - defaultIfNull(code, INTERNAL_ERROR_CODE), + defaultIfNull(code, A2AErrorCodes.INTERNAL.code()), defaultIfNull(message, "Internal Error"), - data); + details); } /** diff --git a/spec/src/main/java/io/a2a/spec/InvalidAgentResponseError.java b/spec/src/main/java/io/a2a/spec/InvalidAgentResponseError.java index d6d8508d9..c6c2c2cc2 100644 --- a/spec/src/main/java/io/a2a/spec/InvalidAgentResponseError.java +++ b/spec/src/main/java/io/a2a/spec/InvalidAgentResponseError.java @@ -1,8 +1,9 @@ package io.a2a.spec; -import static io.a2a.spec.A2AErrorCodes.INVALID_AGENT_RESPONSE_ERROR_CODE; import static io.a2a.util.Utils.defaultIfNull; +import java.util.Map; + import org.jspecify.annotations.Nullable; @@ -44,13 +45,12 @@ public class InvalidAgentResponseError extends A2AProtocolError { * * @param code the error code * @param message the error message - * @param data additional error data + * @param details additional error details */ - public InvalidAgentResponseError(@Nullable Integer code, @Nullable String message, @Nullable Object data) { + public InvalidAgentResponseError(@Nullable Integer code, @Nullable String message, @Nullable Map details) { super( - defaultIfNull(code, INVALID_AGENT_RESPONSE_ERROR_CODE), + defaultIfNull(code, A2AErrorCodes.INVALID_AGENT_RESPONSE.code()), defaultIfNull(message, "Invalid agent response"), - data, - "https://a2a-protocol.org/errors/invalid-agent-response"); + details); } } diff --git a/spec/src/main/java/io/a2a/spec/InvalidParamsError.java b/spec/src/main/java/io/a2a/spec/InvalidParamsError.java index a56f1ac91..6c6751d41 100644 --- a/spec/src/main/java/io/a2a/spec/InvalidParamsError.java +++ b/spec/src/main/java/io/a2a/spec/InvalidParamsError.java @@ -1,8 +1,9 @@ package io.a2a.spec; -import static io.a2a.spec.A2AErrorCodes.INVALID_PARAMS_ERROR_CODE; import static io.a2a.util.Utils.defaultIfNull; +import java.util.Map; + import org.jspecify.annotations.Nullable; /** @@ -37,13 +38,13 @@ public class InvalidParamsError extends A2AError { * * @param code the error code * @param message the error message - * @param data additional error data + * @param details additional error details */ - public InvalidParamsError(@Nullable Integer code, @Nullable String message, @Nullable Object data) { + public InvalidParamsError(@Nullable Integer code, @Nullable String message, @Nullable Map details) { super( - defaultIfNull(code, INVALID_PARAMS_ERROR_CODE), + defaultIfNull(code, A2AErrorCodes.INVALID_PARAMS.code()), defaultIfNull(message, "Invalid parameters"), - data); + details); } /** diff --git a/spec/src/main/java/io/a2a/spec/InvalidRequestError.java b/spec/src/main/java/io/a2a/spec/InvalidRequestError.java index 512c21b59..151f0631f 100644 --- a/spec/src/main/java/io/a2a/spec/InvalidRequestError.java +++ b/spec/src/main/java/io/a2a/spec/InvalidRequestError.java @@ -1,8 +1,9 @@ package io.a2a.spec; -import static io.a2a.spec.A2AErrorCodes.INVALID_REQUEST_ERROR_CODE; import static io.a2a.util.Utils.defaultIfNull; +import java.util.Map; + import org.jspecify.annotations.Nullable; @@ -45,13 +46,13 @@ public InvalidRequestError() { * * @param code the error code * @param message the error message - * @param data additional error data + * @param details additional error details */ - public InvalidRequestError(@Nullable Integer code, @Nullable String message, @Nullable Object data) { + public InvalidRequestError(@Nullable Integer code, @Nullable String message, @Nullable Map details) { super( - defaultIfNull(code, INVALID_REQUEST_ERROR_CODE), + defaultIfNull(code, A2AErrorCodes.INVALID_REQUEST.code()), defaultIfNull(message, "Request payload validation error"), - data); + details); } /** diff --git a/spec/src/main/java/io/a2a/spec/JSONParseError.java b/spec/src/main/java/io/a2a/spec/JSONParseError.java index 72e4aaf02..5349add2d 100644 --- a/spec/src/main/java/io/a2a/spec/JSONParseError.java +++ b/spec/src/main/java/io/a2a/spec/JSONParseError.java @@ -1,8 +1,9 @@ package io.a2a.spec; -import static io.a2a.spec.A2AErrorCodes.JSON_PARSE_ERROR_CODE; import static io.a2a.util.Utils.defaultIfNull; +import java.util.Map; + import org.jspecify.annotations.Nullable; /** @@ -51,12 +52,12 @@ public JSONParseError(String message) { * * @param code the error code * @param message the error message - * @param data additional error data + * @param details additional error details */ - public JSONParseError(@Nullable Integer code, @Nullable String message, @Nullable Object data) { + public JSONParseError(@Nullable Integer code, @Nullable String message, @Nullable Map details) { super( - defaultIfNull(code, JSON_PARSE_ERROR_CODE), + defaultIfNull(code, A2AErrorCodes.JSON_PARSE.code()), defaultIfNull(message, "Invalid JSON payload"), - data); + details); } } diff --git a/spec/src/main/java/io/a2a/spec/MethodNotFoundError.java b/spec/src/main/java/io/a2a/spec/MethodNotFoundError.java index c69aaa572..b97f5acae 100644 --- a/spec/src/main/java/io/a2a/spec/MethodNotFoundError.java +++ b/spec/src/main/java/io/a2a/spec/MethodNotFoundError.java @@ -1,8 +1,9 @@ package io.a2a.spec; -import static io.a2a.spec.A2AErrorCodes.METHOD_NOT_FOUND_ERROR_CODE; import static io.a2a.util.Utils.defaultIfNull; +import java.util.Map; + import org.jspecify.annotations.Nullable; /** @@ -29,19 +30,19 @@ public class MethodNotFoundError extends A2AError { * * @param code the error code (defaults to -32601 if null) * @param message the error message (defaults to "Method not found" if null) - * @param data additional error data (optional) + * @param details additional error details (optional) */ - public MethodNotFoundError(@Nullable Integer code, @Nullable String message, @Nullable Object data) { + public MethodNotFoundError(@Nullable Integer code, @Nullable String message, @Nullable Map details) { super( - defaultIfNull(code, METHOD_NOT_FOUND_ERROR_CODE), + defaultIfNull(code, A2AErrorCodes.METHOD_NOT_FOUND.code()), defaultIfNull(message, "Method not found"), - data); + details); } /** * Constructs error with default message. */ public MethodNotFoundError() { - this(METHOD_NOT_FOUND_ERROR_CODE, null, null); + this(A2AErrorCodes.METHOD_NOT_FOUND.code(), null, null); } } diff --git a/spec/src/main/java/io/a2a/spec/PushNotificationNotSupportedError.java b/spec/src/main/java/io/a2a/spec/PushNotificationNotSupportedError.java index c84f4d79f..e167bb64a 100644 --- a/spec/src/main/java/io/a2a/spec/PushNotificationNotSupportedError.java +++ b/spec/src/main/java/io/a2a/spec/PushNotificationNotSupportedError.java @@ -1,8 +1,9 @@ package io.a2a.spec; -import static io.a2a.spec.A2AErrorCodes.PUSH_NOTIFICATION_NOT_SUPPORTED_ERROR_CODE; import static io.a2a.util.Utils.defaultIfNull; +import java.util.Map; + import org.jspecify.annotations.Nullable; /** @@ -42,13 +43,12 @@ public PushNotificationNotSupportedError() { * * @param code the error code (defaults to -32003 if null) * @param message the error message (defaults to "Push Notification is not supported" if null) - * @param data additional error data (optional) + * @param details additional error details (optional) */ - public PushNotificationNotSupportedError(@Nullable Integer code, @Nullable String message, @Nullable Object data) { + public PushNotificationNotSupportedError(@Nullable Integer code, @Nullable String message, @Nullable Map details) { super( - defaultIfNull(code, PUSH_NOTIFICATION_NOT_SUPPORTED_ERROR_CODE), + defaultIfNull(code, A2AErrorCodes.PUSH_NOTIFICATION_NOT_SUPPORTED.code()), defaultIfNull(message, "Push Notification is not supported"), - data, - "https://a2a-protocol.org/errors/push-notification-not-supported"); + details); } } diff --git a/spec/src/main/java/io/a2a/spec/TaskNotCancelableError.java b/spec/src/main/java/io/a2a/spec/TaskNotCancelableError.java index 83e19ec54..49d86fa52 100644 --- a/spec/src/main/java/io/a2a/spec/TaskNotCancelableError.java +++ b/spec/src/main/java/io/a2a/spec/TaskNotCancelableError.java @@ -1,8 +1,9 @@ package io.a2a.spec; -import static io.a2a.spec.A2AErrorCodes.TASK_NOT_CANCELABLE_ERROR_CODE; import static io.a2a.util.Utils.defaultIfNull; +import java.util.Map; + import org.jspecify.annotations.Nullable; /** @@ -45,14 +46,13 @@ public TaskNotCancelableError() { * * @param code the error code (defaults to -32002 if null) * @param message the error message (defaults to "Task cannot be canceled" if null) - * @param data additional error data (optional) + * @param details additional error details (optional) */ - public TaskNotCancelableError(@Nullable Integer code, @Nullable String message, @Nullable Object data) { + public TaskNotCancelableError(@Nullable Integer code, @Nullable String message, @Nullable Map details) { super( - defaultIfNull(code, TASK_NOT_CANCELABLE_ERROR_CODE), + defaultIfNull(code, A2AErrorCodes.TASK_NOT_CANCELABLE.code()), defaultIfNull(message, "Task cannot be canceled"), - data, - "https://a2a-protocol.org/errors/task-not-cancelable"); + details); } /** diff --git a/spec/src/main/java/io/a2a/spec/TaskNotFoundError.java b/spec/src/main/java/io/a2a/spec/TaskNotFoundError.java index 523d44f40..817175f84 100644 --- a/spec/src/main/java/io/a2a/spec/TaskNotFoundError.java +++ b/spec/src/main/java/io/a2a/spec/TaskNotFoundError.java @@ -1,8 +1,9 @@ package io.a2a.spec; -import static io.a2a.spec.A2AErrorCodes.TASK_NOT_FOUND_ERROR_CODE; import static io.a2a.util.Utils.defaultIfNull; +import java.util.Map; + import org.jspecify.annotations.Nullable; /** @@ -45,13 +46,12 @@ public TaskNotFoundError() { * Constructs error with all parameters. * * @param message the error message (defaults to "Task not found" if null) - * @param data additional error data (optional) + * @param details additional error details (optional) */ - public TaskNotFoundError(@Nullable String message, @Nullable Object data) { + public TaskNotFoundError(@Nullable String message, @Nullable Map details) { super( - TASK_NOT_FOUND_ERROR_CODE, + A2AErrorCodes.TASK_NOT_FOUND.code(), defaultIfNull(message, "Task not found"), - data, - "https://a2a-protocol.org/errors/task-not-found"); + details); } } diff --git a/spec/src/main/java/io/a2a/spec/UnsupportedOperationError.java b/spec/src/main/java/io/a2a/spec/UnsupportedOperationError.java index 65bdbed9a..a0ced6f74 100644 --- a/spec/src/main/java/io/a2a/spec/UnsupportedOperationError.java +++ b/spec/src/main/java/io/a2a/spec/UnsupportedOperationError.java @@ -1,8 +1,9 @@ package io.a2a.spec; -import static io.a2a.spec.A2AErrorCodes.UNSUPPORTED_OPERATION_ERROR_CODE; import static io.a2a.util.Utils.defaultIfNull; +import java.util.Map; + import org.jspecify.annotations.Nullable; /** @@ -40,14 +41,13 @@ public class UnsupportedOperationError extends A2AProtocolError { * * @param code the error code (defaults to -32004 if null) * @param message the error message (defaults to "This operation is not supported" if null) - * @param data additional error data (optional) + * @param details additional error details (optional) */ - public UnsupportedOperationError(@Nullable Integer code, @Nullable String message, @Nullable Object data) { + public UnsupportedOperationError(@Nullable Integer code, @Nullable String message, @Nullable Map details) { super( - defaultIfNull(code, UNSUPPORTED_OPERATION_ERROR_CODE), + defaultIfNull(code, A2AErrorCodes.UNSUPPORTED_OPERATION.code()), defaultIfNull(message, "This operation is not supported"), - data, - "https://a2a-protocol.org/errors/unsupported-operation"); + details); } /** diff --git a/spec/src/main/java/io/a2a/spec/VersionNotSupportedError.java b/spec/src/main/java/io/a2a/spec/VersionNotSupportedError.java index f4caf5e77..41dbed471 100644 --- a/spec/src/main/java/io/a2a/spec/VersionNotSupportedError.java +++ b/spec/src/main/java/io/a2a/spec/VersionNotSupportedError.java @@ -1,8 +1,9 @@ package io.a2a.spec; -import static io.a2a.spec.A2AErrorCodes.VERSION_NOT_SUPPORTED_ERROR_CODE; import static io.a2a.util.Utils.defaultIfNull; +import java.util.Map; + import org.jspecify.annotations.Nullable; @@ -35,13 +36,12 @@ public class VersionNotSupportedError extends A2AProtocolError { * * @param code the error code (defaults to -32009 if null) * @param message the error message (defaults to standard message if null) - * @param data additional error data (optional) + * @param details additional error details (optional) */ - public VersionNotSupportedError(@Nullable Integer code, @Nullable String message, @Nullable Object data) { + public VersionNotSupportedError(@Nullable Integer code, @Nullable String message, @Nullable Map details) { super( - defaultIfNull(code, VERSION_NOT_SUPPORTED_ERROR_CODE), + defaultIfNull(code, A2AErrorCodes.VERSION_NOT_SUPPORTED.code()), defaultIfNull(message, "Protocol version not supported"), - data, - "https://a2a-protocol.org/errors/version-not-supported"); + details); } } diff --git a/transport/grpc/src/main/java/io/a2a/transport/grpc/handler/GrpcHandler.java b/transport/grpc/src/main/java/io/a2a/transport/grpc/handler/GrpcHandler.java index 4eb8c7223..fe21c5ce3 100644 --- a/transport/grpc/src/main/java/io/a2a/transport/grpc/handler/GrpcHandler.java +++ b/transport/grpc/src/main/java/io/a2a/transport/grpc/handler/GrpcHandler.java @@ -21,6 +21,7 @@ import io.a2a.common.A2AErrorMessages; import io.a2a.grpc.A2AServiceGrpc; import io.a2a.grpc.StreamResponse; +import io.a2a.jsonrpc.common.json.JsonUtil; import io.a2a.jsonrpc.common.wrappers.ListTasksResult; import io.a2a.server.AgentCardValidator; import io.a2a.server.ServerCallContext; @@ -56,12 +57,14 @@ import io.a2a.spec.TaskPushNotificationConfig; import io.a2a.spec.TaskQueryParams; import io.a2a.spec.TransportProtocol; +import io.a2a.spec.A2AErrorCodes; import io.a2a.spec.UnsupportedOperationError; import io.a2a.spec.VersionNotSupportedError; import io.a2a.transport.grpc.context.GrpcContextKeys; import io.grpc.Context; import io.grpc.Metadata; import io.grpc.Status; +import io.grpc.protobuf.StatusProto; import io.grpc.stub.StreamObserver; import org.jspecify.annotations.Nullable; @@ -719,55 +722,26 @@ private ServerCallContext createCallContext(StreamObserver responseObserv * @param error the A2A protocol error */ private void handleError(StreamObserver responseObserver, A2AError error) { - Status status; - String description; - if (error instanceof InvalidRequestError) { - status = Status.INVALID_ARGUMENT; - description = "InvalidRequestError: " + error.getMessage(); - } else if (error instanceof MethodNotFoundError) { - status = Status.NOT_FOUND; - description = "MethodNotFoundError: " + error.getMessage(); - } else if (error instanceof InvalidParamsError) { - status = Status.INVALID_ARGUMENT; - description = "InvalidParamsError: " + error.getMessage(); - } else if (error instanceof InternalError) { - status = Status.INTERNAL; - description = "InternalError: " + error.getMessage(); - } else if (error instanceof TaskNotFoundError) { - status = Status.NOT_FOUND; - description = "TaskNotFoundError: " + error.getMessage(); - } else if (error instanceof TaskNotCancelableError) { - status = Status.FAILED_PRECONDITION; - description = "TaskNotCancelableError: " + error.getMessage(); - } else if (error instanceof PushNotificationNotSupportedError) { - status = Status.UNIMPLEMENTED; - description = "PushNotificationNotSupportedError: " + error.getMessage(); - } else if (error instanceof UnsupportedOperationError) { - status = Status.UNIMPLEMENTED; - description = "UnsupportedOperationError: " + error.getMessage(); - } else if (error instanceof JSONParseError) { - status = Status.INTERNAL; - description = "JSONParseError: " + error.getMessage(); - } else if (error instanceof ContentTypeNotSupportedError) { - status = Status.INVALID_ARGUMENT; - description = "ContentTypeNotSupportedError: " + error.getMessage(); - } else if (error instanceof InvalidAgentResponseError) { - status = Status.INTERNAL; - description = "InvalidAgentResponseError: " + error.getMessage(); - } else if (error instanceof ExtendedAgentCardNotConfiguredError) { - status = Status.FAILED_PRECONDITION; - description = "ExtendedCardNotConfiguredError: " + error.getMessage(); - } else if (error instanceof ExtensionSupportRequiredError) { - status = Status.FAILED_PRECONDITION; - description = "ExtensionSupportRequiredError: " + error.getMessage(); - } else if (error instanceof VersionNotSupportedError) { - status = Status.UNIMPLEMENTED; - description = "VersionNotSupportedError: " + error.getMessage(); - } else { - status = Status.UNKNOWN; - description = "Unknown error type: " + error.getMessage(); + A2AErrorCodes errorCode = A2AErrorCodes.fromCode(error.getCode()); + String grpcStatusName = errorCode != null ? errorCode.grpcStatus() : "UNKNOWN"; + String reason = errorCode != null ? errorCode.name() : "UNKNOWN"; + int grpcCode = Status.Code.valueOf(grpcStatusName).value(); + + com.google.rpc.ErrorInfo.Builder errorInfoBuilder = com.google.rpc.ErrorInfo.newBuilder() + .setReason(reason) + .setDomain("a2a-protocol.org"); + if (!error.getDetails().isEmpty()) { + error.getDetails().forEach((k, v) -> + errorInfoBuilder.putMetadata(k, v instanceof String s ? s : JsonUtil.OBJECT_MAPPER.toJson(v))); } - responseObserver.onError(status.withDescription(description).asRuntimeException()); + + com.google.rpc.Status rpcStatus = com.google.rpc.Status.newBuilder() + .setCode(grpcCode) + .setMessage(error.getMessage() != null ? error.getMessage() : "") + .addDetails(com.google.protobuf.Any.pack(errorInfoBuilder.build())) + .build(); + + responseObserver.onError(StatusProto.toStatusRuntimeException(rpcStatus)); } /** diff --git a/transport/grpc/src/test/java/io/a2a/transport/grpc/handler/GrpcHandlerTest.java b/transport/grpc/src/test/java/io/a2a/transport/grpc/handler/GrpcHandlerTest.java index c0feac4d7..b26b58c53 100644 --- a/transport/grpc/src/test/java/io/a2a/transport/grpc/handler/GrpcHandlerTest.java +++ b/transport/grpc/src/test/java/io/a2a/transport/grpc/handler/GrpcHandlerTest.java @@ -1185,8 +1185,21 @@ private void sendStreamingMessageRequest(GrpcHandler handler, StreamObserver void assertGrpcError(StreamRecorder streamRecorder, Status.Code expectedStatusCode) { Assertions.assertNotNull(streamRecorder.getError()); Assertions.assertInstanceOf(StatusRuntimeException.class, streamRecorder.getError()); - Assertions.assertEquals(expectedStatusCode, ((StatusRuntimeException) streamRecorder.getError()).getStatus().getCode()); + StatusRuntimeException sre = (StatusRuntimeException) streamRecorder.getError(); + Assertions.assertEquals(expectedStatusCode, sre.getStatus().getCode()); Assertions.assertTrue(streamRecorder.getValues().isEmpty()); + + // Verify ErrorInfo is present in status details + com.google.rpc.Status rpcStatus = io.grpc.protobuf.StatusProto.fromThrowable(sre); + Assertions.assertNotNull(rpcStatus, "rpc status should be present"); + Assertions.assertFalse(rpcStatus.getDetailsList().isEmpty(), "details should not be empty"); + try { + com.google.rpc.ErrorInfo errorInfo = rpcStatus.getDetails(0).unpack(com.google.rpc.ErrorInfo.class); + Assertions.assertEquals("a2a-protocol.org", errorInfo.getDomain()); + Assertions.assertFalse(errorInfo.getReason().isEmpty(), "reason should not be empty"); + } catch (com.google.protobuf.InvalidProtocolBufferException e) { + Assertions.fail("Failed to unpack ErrorInfo: " + e.getMessage()); + } } @Test diff --git a/transport/rest/src/main/java/io/a2a/transport/rest/handler/RestHandler.java b/transport/rest/src/main/java/io/a2a/transport/rest/handler/RestHandler.java index 8491d9216..364b87b77 100644 --- a/transport/rest/src/main/java/io/a2a/transport/rest/handler/RestHandler.java +++ b/transport/rest/src/main/java/io/a2a/transport/rest/handler/RestHandler.java @@ -3,12 +3,13 @@ import static io.a2a.common.MediaType.APPLICATION_JSON; import static io.a2a.common.MediaType.APPLICATION_PROBLEM_JSON; import static io.a2a.server.util.async.AsyncUtils.createTubeConfig; -import static io.a2a.spec.A2AErrorCodes.JSON_PARSE_ERROR_CODE; +import io.a2a.spec.A2AErrorCodes; import java.time.Instant; import java.time.format.DateTimeParseException; import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; @@ -41,31 +42,25 @@ import io.a2a.spec.AgentCard; import io.a2a.spec.CancelTaskParams; import io.a2a.spec.ExtendedAgentCardNotConfiguredError; -import io.a2a.spec.ContentTypeNotSupportedError; import io.a2a.spec.DeleteTaskPushNotificationConfigParams; import io.a2a.spec.EventKind; import io.a2a.spec.GetTaskPushNotificationConfigParams; import io.a2a.spec.InternalError; -import io.a2a.spec.InvalidAgentResponseError; import io.a2a.spec.InvalidParamsError; import io.a2a.spec.InvalidRequestError; import io.a2a.spec.JSONParseError; import io.a2a.spec.ListTaskPushNotificationConfigsParams; import io.a2a.spec.ListTaskPushNotificationConfigsResult; import io.a2a.spec.ListTasksParams; -import io.a2a.spec.MethodNotFoundError; import io.a2a.spec.PushNotificationNotSupportedError; import io.a2a.spec.StreamingEventKind; import io.a2a.spec.Task; import io.a2a.spec.TaskIdParams; -import io.a2a.spec.TaskNotCancelableError; import io.a2a.spec.TaskNotFoundError; import io.a2a.spec.TaskPushNotificationConfig; import io.a2a.spec.TaskQueryParams; import io.a2a.spec.TaskState; import io.a2a.spec.UnsupportedOperationError; -import io.a2a.spec.ExtensionSupportRequiredError; -import io.a2a.spec.VersionNotSupportedError; import mutiny.zero.ZeroPublisher; import org.jspecify.annotations.Nullable; @@ -647,7 +642,7 @@ private void validate(String json) { try { JsonParser.parseString(json); } catch (JsonSyntaxException e) { - throw new JSONParseError(JSON_PARSE_ERROR_CODE, "Failed to parse json", e.getMessage()); + throw new JSONParseError(A2AErrorCodes.JSON_PARSE.code(), "Failed to parse json", null); } } @@ -745,113 +740,17 @@ public void onComplete() { } /** - * Maps A2A protocol errors to HTTP status codes. - * - *

- * This method ensures consistent HTTP status code mapping for all A2A errors: - *

    - *
  • 400 - Invalid request, JSON parse errors, missing extensions
  • - *
  • 404 - Method not found, task not found
  • - *
  • 409 - Task not cancelable (conflict)
  • - *
  • 415 - Unsupported content type
  • - *
  • 422 - Invalid parameters (unprocessable entity)
  • - *
  • 500 - Internal errors
  • - *
  • 501 - Not implemented (unsupported operations, version)
  • - *
  • 502 - Bad gateway (invalid agent response)
  • - *
+ * Maps A2A protocol errors to HTTP status codes using {@link A2AErrorCodes}. * * @param error the A2A error to map * @return the corresponding HTTP status code */ private static int mapErrorToHttpStatus(A2AError error) { - if (error instanceof InvalidRequestError || error instanceof JSONParseError) { - return 400; - } - if (error instanceof InvalidParamsError) { - return 422; - } - if (error instanceof MethodNotFoundError || error instanceof TaskNotFoundError) { - return 404; - } - if (error instanceof TaskNotCancelableError) { - return 409; - } - if (error instanceof PushNotificationNotSupportedError - || error instanceof UnsupportedOperationError) { - return 501; - } - if (error instanceof ContentTypeNotSupportedError) { - return 415; - } - if (error instanceof InvalidAgentResponseError) { - return 502; - } - if (error instanceof ExtendedAgentCardNotConfiguredError - || error instanceof ExtensionSupportRequiredError - || error instanceof VersionNotSupportedError) { - return 400; - } - if (error instanceof InternalError) { - return 500; + A2AErrorCodes errorCode = A2AErrorCodes.fromCode(error.getCode()); + if (errorCode != null) { + return errorCode.httpCode(); } - return 500; - } - - /** - * Maps A2A protocol errors to RFC 7807 Problem Details type URIs. - * - *

- * This method provides a unique URI for each A2A error type, which is used in the "type" - * field of RFC 7807 error responses. For example: - *

    - *
  • {@link InvalidRequestError} -> "https://a2a-protocol.org/errors/invalid-request"
  • - *
  • {@link TaskNotFoundError} -> "https://a2a-protocol.org/errors/task-not-found"
  • - *
- * - * @param error the A2A error to map - * @return the corresponding RFC 7807 type URI - */ - private static String mapErrorToURI(A2AError error) { - if (error instanceof InvalidRequestError || error instanceof JSONParseError) { - return "https://a2a-protocol.org/errors/invalid-request"; - } - if (error instanceof InvalidParamsError) { - return "https://a2a-protocol.org/errors/invalid-params"; - } - if (error instanceof MethodNotFoundError) { - return "https://a2a-protocol.org/errors/method-not-found"; - } - if (error instanceof TaskNotFoundError) { - return "https://a2a-protocol.org/errors/task-not-found"; - } - if (error instanceof TaskNotCancelableError) { - return "https://a2a-protocol.org/errors/task-not-cancelable"; - } - if (error instanceof PushNotificationNotSupportedError) { - return "https://a2a-protocol.org/errors/push-notification-not-supported"; - } - if (error instanceof UnsupportedOperationError) { - return "https://a2a-protocol.org/errors/unsupported-operation"; - } - if (error instanceof VersionNotSupportedError) { - return "https://a2a-protocol.org/errors/version-not-supported"; - } - if (error instanceof ContentTypeNotSupportedError) { - return "https://a2a-protocol.org/errors/content-type-not-supported"; - } - if (error instanceof InvalidAgentResponseError) { - return "https://a2a-protocol.org/errors/invalid-agent-response"; - } - if (error instanceof ExtendedAgentCardNotConfiguredError) { - return "https://a2a-protocol.org/errors/extended-agent-card-not-configured"; - } - if (error instanceof ExtensionSupportRequiredError) { - return "https://a2a-protocol.org/errors/extension-support-required"; - } - if (error instanceof InternalError) { - return "https://a2a-protocol.org/errors/internal-error"; - } - return "https://a2a-protocol.org/errors/internal-error"; + return A2AErrorCodes.INTERNAL.httpCode(); } /** @@ -1017,27 +916,46 @@ public Flow.Publisher getPublisher() { } } + private static final String ERROR_INFO_TYPE = "type.googleapis.com/google.rpc.ErrorInfo"; + private static final String ERROR_DOMAIN = "a2a-protocol.org"; + /** - * Represents an HTTP error response containing A2A error details. + * Represents an HTTP error response containing A2A error details in the Google Cloud API error format. + *

+ * Produces JSON of the form: + *

{@code
+     * {
+     *   "error": {
+     *     "code": 404,
+     *     "status": "NOT_FOUND",
+     *     "message": "Task not found",
+     *     "details": [
+     *       {
+     *         "@type": "type.googleapis.com/google.rpc.ErrorInfo",
+     *         "reason": "TASK_NOT_FOUND",
+     *         "domain": "a2a-protocol.org",
+     *         "metadata": { ... }
+     *       }
+     *     ]
+     *   }
+     * }
+     * }
*/ private static class HTTPRestErrorResponse { - private final String title; - private final String details; - private final int status; - private final String type; + private final ErrorBody error; + private HTTPRestErrorResponse(A2AError a2aError) { + A2AErrorCodes errorCode = A2AErrorCodes.fromCode(a2aError.getCode()); + int httpCode = mapErrorToHttpStatus(a2aError); + String status = errorCode != null + ? errorCode.grpcStatus() + : A2AErrorCodes.INTERNAL.grpcStatus(); + String reason = errorCode != null ? errorCode.name() : "INTERNAL"; + String message = a2aError.getMessage() == null ? a2aError.getClass().getName() : a2aError.getMessage(); - /** - * Creates an error response from an A2A error. - * - * @param jsonRpcError the A2A error - */ - private HTTPRestErrorResponse(A2AError jsonRpcError) { - this.title = jsonRpcError.getMessage() == null ? jsonRpcError.getClass().getName() : jsonRpcError.getMessage(); - this.details = jsonRpcError.getData() == null ? "" : jsonRpcError.getData().toString(); - this.type = mapErrorToURI(jsonRpcError); - this.status = mapErrorToHttpStatus(jsonRpcError); + ErrorDetail detail = new ErrorDetail(ERROR_INFO_TYPE, reason, ERROR_DOMAIN, a2aError.getDetails()); + this.error = new ErrorBody(httpCode, status, message, List.of(detail)); } private String toJson() { @@ -1045,14 +963,21 @@ private String toJson() { return JsonUtil.toJson(this); } catch (JsonProcessingException ex) { log.log(Level.SEVERE, "Failed to serialize HTTPRestErrorResponse to JSON", ex); - return "{\"title\":\"Internal Server Error\",\"details\":\"Failed to serialize error response.\",\"status\":500,\"type\":\"https://a2a-protocol.org/errors/internal-error\"}"; + return "{\"error\":{\"code\":500,\"status\":\"INTERNAL\",\"message\":\"Internal Server Error\",\"details\":[]}}"; } } @Override public String toString() { - return "HTTPRestErrorResponse{" + "title=" + title + ", details=" + details + ", status=" + status + ", type=" + type + '}'; + return "HTTPRestErrorResponse{error=" + error + '}'; } + private record ErrorBody(int code, String status, String message, List details) {} + + private record ErrorDetail( + @com.google.gson.annotations.SerializedName("@type") String type, + String reason, + String domain, + Map metadata) {} } } diff --git a/transport/rest/src/test/java/io/a2a/transport/rest/handler/RestHandlerTest.java b/transport/rest/src/test/java/io/a2a/transport/rest/handler/RestHandlerTest.java index 738b027e7..d07f5a42c 100644 --- a/transport/rest/src/test/java/io/a2a/transport/rest/handler/RestHandlerTest.java +++ b/transport/rest/src/test/java/io/a2a/transport/rest/handler/RestHandlerTest.java @@ -56,7 +56,7 @@ public void testGetTaskNotFound() { RestHandler.HTTPRestResponse response = handler.getTask(callContext, "", "nonexistent", 0); assertProblemDetail(response, 404, - "https://a2a-protocol.org/errors/task-not-found", "Task not found"); + "TASK_NOT_FOUND", "Task not found"); } @Test @@ -66,7 +66,7 @@ public void testGetTaskNegativeHistoryLengthReturns422() { RestHandler.HTTPRestResponse response = handler.getTask(callContext, "", MINIMAL_TASK.id(), -1); assertProblemDetail(response, 422, - "https://a2a-protocol.org/errors/invalid-params", "Invalid history length"); + "INVALID_PARAMS", "Invalid history length"); } @Test @@ -90,7 +90,7 @@ public void testListTasksInvalidStatus() { null, null, null); assertProblemDetail(response, 422, - "https://a2a-protocol.org/errors/invalid-params", "Invalid params"); + "INVALID_PARAMS", "Invalid params"); } @Test @@ -132,7 +132,7 @@ public void testSendMessageInvalidBody() { RestHandler.HTTPRestResponse response = handler.sendMessage(callContext, "", invalidBody); assertProblemDetail(response, 400, - "https://a2a-protocol.org/errors/invalid-request", "Failed to parse json"); + "JSON_PARSE", "Failed to parse json"); } @Test @@ -157,10 +157,11 @@ public void testSendMessageWrongValueBody() { Assertions.assertEquals(422, response.getStatusCode()); Assertions.assertEquals("application/problem+json", response.getContentType()); JsonObject body = JsonParser.parseString(response.getBody()).getAsJsonObject(); - Assertions.assertEquals(422, body.get("status").getAsInt()); - Assertions.assertEquals("https://a2a-protocol.org/errors/invalid-params", body.get("type").getAsString()); - Assertions.assertTrue(body.get("title").getAsString().startsWith("Failed to parse request body:"), - "title should indicate parse failure: " + body.get("title").getAsString()); + JsonObject error = body.getAsJsonObject("error"); + Assertions.assertEquals(422, error.get("code").getAsInt()); + Assertions.assertEquals("INVALID_PARAMS", error.getAsJsonArray("details").get(0).getAsJsonObject().get("reason").getAsString()); + Assertions.assertTrue(error.get("message").getAsString().startsWith("Failed to parse request body:"), + "message should indicate parse failure: " + error.get("message").getAsString()); } @Test @@ -170,7 +171,7 @@ public void testSendMessageEmptyBody() { RestHandler.HTTPRestResponse response = handler.sendMessage(callContext, "", ""); assertProblemDetail(response, 400, - "https://a2a-protocol.org/errors/invalid-request", "Request body is required"); + "INVALID_REQUEST", "Request body is required"); } @Test @@ -202,7 +203,7 @@ public void testCancelTaskNotFound() { RestHandler.HTTPRestResponse response = handler.cancelTask(callContext, "", requestBody, "nonexistent"); assertProblemDetail(response, 404, - "https://a2a-protocol.org/errors/task-not-found", "Task not found"); + "TASK_NOT_FOUND", "Task not found"); } @Test @@ -347,7 +348,7 @@ public void testSendStreamingMessageNotSupported() { RestHandler.HTTPRestResponse response = handler.sendStreamingMessage(callContext, "", requestBody); assertProblemDetail(response, 400, - "https://a2a-protocol.org/errors/invalid-request", + "INVALID_REQUEST", "Streaming is not supported by the agent"); } @@ -388,8 +389,8 @@ public void testPushNotificationConfigNotSupported() { RestHandler.HTTPRestResponse response = handler.createTaskPushNotificationConfiguration(callContext, "", requestBody, MINIMAL_TASK.id()); - assertProblemDetail(response, 501, - "https://a2a-protocol.org/errors/push-notification-not-supported", + assertProblemDetail(response, 400, + "PUSH_NOTIFICATION_NOT_SUPPORTED", "Push Notification is not supported"); } @@ -577,7 +578,7 @@ public void testExtensionSupportRequiredErrorOnSendMessage() { RestHandler.HTTPRestResponse response = handler.sendMessage(callContext, "", requestBody); assertProblemDetail(response, 400, - "https://a2a-protocol.org/errors/extension-support-required", + "EXTENSION_SUPPORT_REQUIRED", "Required extension 'https://example.com/test-extension' was not requested by the client"); } @@ -642,11 +643,17 @@ public void onSubscribe(Flow.Subscription subscription) { @Override public void onNext(String item) { - JsonObject error = JsonParser.parseString(item).getAsJsonObject(); - if ("https://a2a-protocol.org/errors/extension-support-required".equals( - error.has("type") ? error.get("type").getAsString() : null) && - item.contains("https://example.com/streaming-extension")) { - errorFound.set(true); + JsonObject body = JsonParser.parseString(item).getAsJsonObject(); + if (body.has("error")) { + JsonObject error = body.getAsJsonObject("error"); + var details = error.has("details") ? error.getAsJsonArray("details") : null; + if (details != null && !details.isEmpty()) { + String reason = details.get(0).getAsJsonObject().get("reason").getAsString(); + if ("EXTENSION_SUPPORT_REQUIRED".equals(reason) && + item.contains("https://example.com/streaming-extension")) { + errorFound.set(true); + } + } } latch.countDown(); } @@ -778,7 +785,7 @@ public void testVersionNotSupportedErrorOnSendMessage() { RestHandler.HTTPRestResponse response = handler.sendMessage(contextWithVersion, "", requestBody); assertProblemDetail(response, 400, - "https://a2a-protocol.org/errors/version-not-supported", + "VERSION_NOT_SUPPORTED", "Protocol version '2.0' is not supported. Supported versions: [1.0]"); } @@ -845,11 +852,17 @@ public void onSubscribe(Flow.Subscription subscription) { @Override public void onNext(String item) { - JsonObject error = JsonParser.parseString(item).getAsJsonObject(); - if ("https://a2a-protocol.org/errors/version-not-supported".equals( - error.has("type") ? error.get("type").getAsString() : null) && - error.has("title") && error.get("title").getAsString().contains("2.0")) { - errorFound.set(true); + JsonObject body = JsonParser.parseString(item).getAsJsonObject(); + if (body.has("error")) { + JsonObject error = body.getAsJsonObject("error"); + var details = error.has("details") ? error.getAsJsonArray("details") : null; + if (details != null && !details.isEmpty()) { + String reason = details.get(0).getAsJsonObject().get("reason").getAsString(); + if ("VERSION_NOT_SUPPORTED".equals(reason) && + error.has("message") && error.get("message").getAsString().contains("2.0")) { + errorFound.set(true); + } + } } } @@ -984,7 +997,7 @@ public void testListTasksNegativeTimestampReturns422() { null, "-1", null); assertProblemDetail(response, 422, - "https://a2a-protocol.org/errors/invalid-params", "Invalid params"); + "INVALID_PARAMS", "Invalid params"); } @Test @@ -1047,13 +1060,21 @@ public void testListTasksEmptyResultIncludesAllFields() { } private static void assertProblemDetail(RestHandler.HTTPRestResponse response, - int expectedStatus, String expectedType, String expectedTitle) { + int expectedStatus, String expectedReason, String expectedMessage) { Assertions.assertEquals(expectedStatus, response.getStatusCode()); Assertions.assertEquals("application/problem+json", response.getContentType()); JsonObject body = JsonParser.parseString(response.getBody()).getAsJsonObject(); - Assertions.assertEquals(expectedStatus, body.get("status").getAsInt(), "status field mismatch"); - Assertions.assertEquals(expectedType, body.get("type").getAsString(), "type field mismatch"); - Assertions.assertEquals(expectedTitle, body.get("title").getAsString(), "title field mismatch"); - Assertions.assertTrue(body.has("details"), "details field should be present"); + Assertions.assertTrue(body.has("error"), "error wrapper should be present"); + JsonObject error = body.getAsJsonObject("error"); + Assertions.assertEquals(expectedStatus, error.get("code").getAsInt(), "code field mismatch"); + Assertions.assertEquals(expectedMessage, error.get("message").getAsString(), "message field mismatch"); + Assertions.assertTrue(error.has("status"), "status field should be present"); + Assertions.assertTrue(error.has("details"), "details field should be present"); + var details = error.getAsJsonArray("details"); + Assertions.assertFalse(details.isEmpty(), "details array should not be empty"); + JsonObject detail = details.get(0).getAsJsonObject(); + Assertions.assertEquals("type.googleapis.com/google.rpc.ErrorInfo", detail.get("@type").getAsString(), "@type field mismatch"); + Assertions.assertEquals(expectedReason, detail.get("reason").getAsString(), "reason field mismatch"); + Assertions.assertEquals("a2a-protocol.org", detail.get("domain").getAsString(), "domain field mismatch"); } }