From 3fc30855b40829e7c38ceb09c188ac56efdb64f4 Mon Sep 17 00:00:00 2001 From: Pratik Lala Date: Thu, 16 Apr 2026 21:08:34 -0700 Subject: [PATCH 1/3] fix: expose HTTP status code in JSONRPCTransport errors Previously, when the JSONRPC transport received a non-2xx HTTP response, it threw a plain IOException with the status code embedded as a string (e.g. "Request failed 503"). This made it impossible for callers to programmatically distinguish 4xx from 5xx errors without fragile string parsing. This change makes the structured HTTP status code available while remaining fully backward compatible: - JSONRPCTransport.sendPostRequest now throws A2AClientException with an A2AClientHTTPError as the cause, instead of a plain IOException. All transport methods already declared 'throws A2AClientException' and already had 'catch (A2AClientException e) { throw e; }', so no caller signatures change. - A2AClientHTTPError gains a new 'responseBody' field (and getResponseBody() accessor) so callers can also inspect the raw error payload returned by the server (e.g. a gateway's JSON error body on a 429 or 503). The existing 'code', 'message', and getCode() are preserved unchanged. The old Object-typed constructor is deprecated in favour of the new String-typed one that actually stores the body. Callers that want the status code can now opt in with a simple instanceof check, while existing catch blocks for A2AClientException continue to work without modification: } catch (A2AClientException e) { if (e.getCause() instanceof A2AClientHTTPError httpErr) { int status = httpErr.getCode(); // e.g. 503 String body = httpErr.getResponseBody(); // raw response body } } Fixes #799 --- .../transport/jsonrpc/JSONRPCTransport.java | 7 ++- .../jsonrpc/JSONRPCTransportTest.java | 43 +++++++++++++++ .../sdk/spec/A2AClientHTTPError.java | 53 +++++++++++++++---- 3 files changed, 91 insertions(+), 12 deletions(-) diff --git a/client/transport/jsonrpc/src/main/java/org/a2aproject/sdk/client/transport/jsonrpc/JSONRPCTransport.java b/client/transport/jsonrpc/src/main/java/org/a2aproject/sdk/client/transport/jsonrpc/JSONRPCTransport.java index 070f564e5..bc7844084 100644 --- a/client/transport/jsonrpc/src/main/java/org/a2aproject/sdk/client/transport/jsonrpc/JSONRPCTransport.java +++ b/client/transport/jsonrpc/src/main/java/org/a2aproject/sdk/client/transport/jsonrpc/JSONRPCTransport.java @@ -46,6 +46,7 @@ import org.a2aproject.sdk.jsonrpc.common.wrappers.CreateTaskPushNotificationConfigResponse; import org.a2aproject.sdk.spec.A2AClientError; import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.A2AClientHTTPError; import org.a2aproject.sdk.spec.A2AError; import org.a2aproject.sdk.spec.AgentCard; import org.a2aproject.sdk.spec.AgentInterface; @@ -321,10 +322,12 @@ private PayloadAndHeaders applyInterceptors(String methodName, @Nullable Object } private String sendPostRequest(String url, PayloadAndHeaders payloadAndHeaders, String method) throws IOException, InterruptedException, JsonProcessingException { - A2AHttpClient.PostBuilder builder = createPostBuilder(url, payloadAndHeaders,method); + A2AHttpClient.PostBuilder builder = createPostBuilder(url, payloadAndHeaders, method); A2AHttpResponse response = builder.post(); if (!response.success()) { - throw new IOException("Request failed " + response.status()); + int status = response.status(); + String message = "Request failed with HTTP " + status; + throw new A2AClientException(message, new A2AClientHTTPError(status, message, response.body())); } return response.body(); } diff --git a/client/transport/jsonrpc/src/test/java/org/a2aproject/sdk/client/transport/jsonrpc/JSONRPCTransportTest.java b/client/transport/jsonrpc/src/test/java/org/a2aproject/sdk/client/transport/jsonrpc/JSONRPCTransportTest.java index 93c391aa2..5c1ab098d 100644 --- a/client/transport/jsonrpc/src/test/java/org/a2aproject/sdk/client/transport/jsonrpc/JSONRPCTransportTest.java +++ b/client/transport/jsonrpc/src/test/java/org/a2aproject/sdk/client/transport/jsonrpc/JSONRPCTransportTest.java @@ -38,6 +38,7 @@ import java.util.Map; import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.A2AClientHTTPError; import org.a2aproject.sdk.spec.AgentCard; import org.a2aproject.sdk.spec.ExtensionSupportRequiredError; import org.a2aproject.sdk.spec.VersionNotSupportedError; @@ -644,6 +645,48 @@ public void testExtensionSupportRequiredErrorUnmarshalling() throws Exception { } } + /** + * Test that HTTP error responses expose the status code via A2AClientHTTPError cause, + * while remaining backward compatible (A2AClientException is still thrown). + */ + @Test + public void testHttpErrorExposeStatusCode() throws Exception { + this.server.when( + request() + .withMethod("POST") + .withPath("/") + ) + .respond( + response() + .withStatusCode(503) + .withBody("{\"error\": \"Service Unavailable\"}") + ); + + JSONRPCTransport client = new JSONRPCTransport("http://localhost:4001"); + MessageSendParams params = MessageSendParams.builder() + .message(Message.builder() + .role(Message.Role.ROLE_USER) + .parts(Collections.singletonList(new TextPart("hello"))) + .contextId("ctx") + .messageId("msg") + .build()) + .build(); + + try { + client.sendMessage(params, null); + fail("Expected A2AClientException to be thrown"); + } catch (A2AClientException e) { + // Backward compatible: still throws A2AClientException + assertTrue(e.getMessage().contains("503"), "Expected message to contain '503' but was: " + e.getMessage()); + // New: cause carries structured HTTP status + assertInstanceOf(A2AClientHTTPError.class, e.getCause()); + A2AClientHTTPError httpError = (A2AClientHTTPError) e.getCause(); + assertEquals(503, httpError.getCode()); + assertNotNull(httpError.getResponseBody()); + assertTrue(httpError.getResponseBody().contains("Service Unavailable"), "Expected response body to contain 'Service Unavailable' but was: " + httpError.getResponseBody()); + } + } + /** * Test that VersionNotSupportedError is properly unmarshalled from JSON-RPC error response. */ diff --git a/spec/src/main/java/org/a2aproject/sdk/spec/A2AClientHTTPError.java b/spec/src/main/java/org/a2aproject/sdk/spec/A2AClientHTTPError.java index b840286be..b90ddb09c 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/A2AClientHTTPError.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/A2AClientHTTPError.java @@ -1,6 +1,7 @@ package org.a2aproject.sdk.spec; import org.a2aproject.sdk.util.Assert; +import org.jspecify.annotations.Nullable; /** * Client exception indicating an HTTP transport error with a specific status code. @@ -15,14 +16,14 @@ *
  • 5xx - Server errors (500 Internal Server Error, 503 Service Unavailable, etc.)
  • * *

    - * Usage example: + * This exception is set as the cause of {@link A2AClientException} so that callers + * can inspect the HTTP status code while remaining backward compatible: *

    {@code
    - * if (response.statusCode() >= 400) {
    - *     throw new A2AClientHTTPError(
    - *         response.statusCode(),
    - *         "HTTP error: " + response.statusMessage(),
    - *         response.body()
    - *     );
    + * } catch (A2AClientException e) {
    + *     if (e.getCause() instanceof A2AClientHTTPError httpError) {
    + *         int status = httpError.getCode();           // e.g. 401, 503
    + *         String body = httpError.getResponseBody();  // raw response body, may be null
    + *     }
      * }
      * }
    * @@ -40,6 +41,12 @@ public class A2AClientHTTPError extends A2AClientError { */ private final String message; + /** + * The raw HTTP response body, may be {@code null}. + */ + @Nullable + private final String responseBody; + /** * Creates a new HTTP client error with the specified status code and message. * @@ -47,25 +54,42 @@ public class A2AClientHTTPError extends A2AClientError { * @param message the error message * @param data additional error data (may be the response body) * @throws IllegalArgumentException if code or message is null + * @deprecated Use {@link #A2AClientHTTPError(int, String, String)} instead to preserve the response body. */ + @Deprecated(since = "1.0.0.Beta1", forRemoval = true) public A2AClientHTTPError(int code, String message, Object data) { Assert.checkNotNullParam("code", code); Assert.checkNotNullParam("message", message); this.code = code; this.message = message; + this.responseBody = data instanceof String s ? s : null; } /** - * Gets the error code + * Creates a new HTTP client error with the specified status code, message, and response body. * - * @return the error code + * @param code the HTTP status code (e.g. 401, 503) + * @param message the error message + * @param responseBody the raw HTTP response body, may be {@code null} + */ + public A2AClientHTTPError(int code, String message, @Nullable String responseBody) { + Assert.checkNotNullParam("message", message); + this.code = code; + this.message = message; + this.responseBody = responseBody; + } + + /** + * Gets the HTTP status code. + * + * @return the HTTP status code (e.g. 401, 404, 500, 503) */ public int getCode() { return code; } /** - * Gets the error message + * Gets the error message. * * @return the error message */ @@ -73,4 +97,13 @@ public int getCode() { public String getMessage() { return message; } + + /** + * Returns the raw HTTP response body, if available. + * + * @return the response body, or {@code null} if not available + */ + public @Nullable String getResponseBody() { + return responseBody; + } } From efe00bc96152ed5433bd5a658edade93bb71b93e Mon Sep 17 00:00:00 2001 From: Pratik Lala Date: Fri, 17 Apr 2026 10:47:05 -0700 Subject: [PATCH 2/3] ci: retrigger CI From 513a2a3034be3dbf19fd8f1db063d40c57f42abb Mon Sep 17 00:00:00 2001 From: Pratik Lala Date: Fri, 17 Apr 2026 10:57:07 -0700 Subject: [PATCH 3/3] ci: retrigger CI