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; + } }