From e999b37743d53096d01d2403001c6107fdc0abb7 Mon Sep 17 00:00:00 2001 From: Kabir Khan Date: Tue, 9 Jun 2026 10:09:51 +0200 Subject: [PATCH 1/3] feat: expose HTTP response headers in A2AHttpResponse and A2AClientHTTPError Add A2AHttpHeaders interface as a transport-independent abstraction over HTTP response headers, with implementations for JDK, Vert.x, and Android HTTP clients. Headers are also propagated into A2AClientHTTPError so callers can access headers like Retry-After (429) and WWW-Authenticate (401) from error responses. This fixes #920 Co-Authored-By: Claude Opus 4.6 --- .../transport/jsonrpc/JSONRPCTransport.java | 2 +- .../jsonrpc/JSONRPCTransportTest.java | 53 +++++++ .../transport/rest/RestErrorMapper.java | 15 +- .../transport/rest/RestErrorMapperTest.java | 130 ++++++++++++++++++ .../sdk/client/http/AndroidA2AHttpClient.java | 46 ++++++- .../sdk/client/http/VertxA2AHttpClient.java | 37 ++++- .../sdk/client/http/A2AHttpHeaders.java | 80 +++++++++++ .../sdk/client/http/A2AHttpResponse.java | 12 ++ .../sdk/client/http/JdkA2AHttpClient.java | 21 +++ .../AbstractA2AHttpClientIntegrationTest.java | 100 +++++++++++++- .../sdk/spec/A2AClientHTTPError.java | 61 +++++++- 11 files changed, 542 insertions(+), 15 deletions(-) create mode 100644 client/transport/rest/src/test/java/org/a2aproject/sdk/client/transport/rest/RestErrorMapperTest.java create mode 100644 http-client/src/main/java/org/a2aproject/sdk/client/http/A2AHttpHeaders.java 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 5e8778bb6..a5dfe552f 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 @@ -328,7 +328,7 @@ private String sendPostRequest(String url, PayloadAndHeaders payloadAndHeaders, if (!response.success()) { int status = response.status(); String message = "Request failed with HTTP " + status; - throw new A2AClientException(message, new A2AClientHTTPError(status, message, response.body())); + throw new A2AClientException(message, new A2AClientHTTPError(status, message, response.body(), response.headers().toMap())); } 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 5c1ab098d..45080d5ce 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 @@ -687,6 +687,59 @@ public void testHttpErrorExposeStatusCode() throws Exception { } } + /** + * Test that HTTP error responses expose response headers via A2AClientHTTPError, + * enabling callers to read headers like Retry-After on 429 responses. + */ + @Test + public void testHttpErrorExposeResponseHeaders() throws Exception { + this.server.when( + request() + .withMethod("POST") + .withPath("/") + ) + .respond( + response() + .withStatusCode(429) + .withHeader("Retry-After", "120") + .withHeader("X-RateLimit-Remaining", "0") + .withBody("{\"error\": \"Too Many Requests\"}") + ); + + 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) { + assertInstanceOf(A2AClientHTTPError.class, e.getCause()); + A2AClientHTTPError httpError = (A2AClientHTTPError) e.getCause(); + assertEquals(429, httpError.getCode()); + + Map> headers = httpError.getResponseHeaders(); + assertNotNull(headers); + assertFalse(headers.isEmpty()); + + List retryAfter = headers.getOrDefault("Retry-After", + headers.getOrDefault("retry-after", List.of())); + assertFalse(retryAfter.isEmpty(), "Expected Retry-After header"); + assertEquals("120", retryAfter.get(0)); + + List rateLimitRemaining = headers.getOrDefault("X-RateLimit-Remaining", + headers.getOrDefault("x-ratelimit-remaining", List.of())); + assertFalse(rateLimitRemaining.isEmpty(), "Expected X-RateLimit-Remaining header"); + assertEquals("0", rateLimitRemaining.get(0)); + } + } + /** * Test that VersionNotSupportedError is properly unmarshalled from JSON-RPC error response. */ diff --git a/client/transport/rest/src/main/java/org/a2aproject/sdk/client/transport/rest/RestErrorMapper.java b/client/transport/rest/src/main/java/org/a2aproject/sdk/client/transport/rest/RestErrorMapper.java index eee28300e..2c3cf167a 100644 --- a/client/transport/rest/src/main/java/org/a2aproject/sdk/client/transport/rest/RestErrorMapper.java +++ b/client/transport/rest/src/main/java/org/a2aproject/sdk/client/transport/rest/RestErrorMapper.java @@ -1,6 +1,7 @@ package org.a2aproject.sdk.client.transport.rest; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.logging.Level; import java.util.logging.Logger; @@ -10,6 +11,7 @@ import org.a2aproject.sdk.jsonrpc.common.json.JsonProcessingException; import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil; import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.A2AClientHTTPError; import org.a2aproject.sdk.spec.A2AErrorCodes; import org.a2aproject.sdk.spec.ContentTypeNotSupportedError; import org.a2aproject.sdk.spec.ExtendedAgentCardNotConfiguredError; @@ -51,10 +53,14 @@ private record ReasonAndMetadata(String reason, @org.jspecify.annotations.Nullab ); public static A2AClientException mapRestError(A2AHttpResponse response) { - return RestErrorMapper.mapRestError(response.body(), response.status()); + return RestErrorMapper.mapRestError(response.body(), response.status(), response.headers().toMap()); } public static A2AClientException mapRestError(String body, int code) { + return mapRestError(body, code, Map.of()); + } + + public static A2AClientException mapRestError(String body, int code, Map> headers) { try { if (body != null && !body.isBlank()) { JsonObject node = JsonUtil.fromJson(body, JsonObject.class); @@ -66,14 +72,17 @@ public static A2AClientException mapRestError(String body, int code) { if (reasonAndMetadata != null) { return mapRestErrorByReason(reasonAndMetadata.reason(), errorMessage, reasonAndMetadata.metadata()); } - return new A2AClientException(errorMessage); + return new A2AClientException(errorMessage, + new A2AClientHTTPError(code, errorMessage, body, headers)); } // 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); } - return mapRestErrorByClassName("", "", code); + String message = "Request failed with HTTP " + code; + return new A2AClientException(message, + new A2AClientHTTPError(code, message, body, headers)); } catch (JsonProcessingException ex) { Logger.getLogger(RestErrorMapper.class.getName()).log(Level.SEVERE, null, ex); return new A2AClientException("Failed to parse error response: " + ex.getMessage()); diff --git a/client/transport/rest/src/test/java/org/a2aproject/sdk/client/transport/rest/RestErrorMapperTest.java b/client/transport/rest/src/test/java/org/a2aproject/sdk/client/transport/rest/RestErrorMapperTest.java new file mode 100644 index 000000000..339b84b20 --- /dev/null +++ b/client/transport/rest/src/test/java/org/a2aproject/sdk/client/transport/rest/RestErrorMapperTest.java @@ -0,0 +1,130 @@ +package org.a2aproject.sdk.client.transport.rest; + +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 java.util.List; +import java.util.Map; + +import org.a2aproject.sdk.client.http.A2AHttpHeaders; +import org.a2aproject.sdk.client.http.A2AHttpResponse; +import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.A2AClientHTTPError; +import org.a2aproject.sdk.spec.TaskNotFoundError; +import org.junit.jupiter.api.Test; + +public class RestErrorMapperTest { + + @Test + public void testEmptyBodyFallbackIncludesHeaders() { + Map> headers = Map.of("Retry-After", List.of("60")); + A2AClientException ex = RestErrorMapper.mapRestError("", 429, headers); + + assertInstanceOf(A2AClientHTTPError.class, ex.getCause()); + A2AClientHTTPError httpError = (A2AClientHTTPError) ex.getCause(); + assertEquals(429, httpError.getCode()); + assertEquals(List.of("60"), httpError.getResponseHeaders().get("Retry-After")); + } + + @Test + public void testNullBodyFallbackIncludesHeaders() { + Map> headers = Map.of("WWW-Authenticate", List.of("Bearer")); + A2AClientException ex = RestErrorMapper.mapRestError(null, 401, headers); + + assertInstanceOf(A2AClientHTTPError.class, ex.getCause()); + A2AClientHTTPError httpError = (A2AClientHTTPError) ex.getCause(); + assertEquals(401, httpError.getCode()); + assertEquals(List.of("Bearer"), httpError.getResponseHeaders().get("WWW-Authenticate")); + } + + @Test + public void testUnrecognizedGoogleErrorFormatIncludesHeaders() { + String body = "{\"error\": {\"code\": 503, \"message\": \"Service Unavailable\"}}"; + Map> headers = Map.of("Retry-After", List.of("30")); + A2AClientException ex = RestErrorMapper.mapRestError(body, 503, headers); + + assertInstanceOf(A2AClientHTTPError.class, ex.getCause()); + A2AClientHTTPError httpError = (A2AClientHTTPError) ex.getCause(); + assertEquals(503, httpError.getCode()); + assertEquals(List.of("30"), httpError.getResponseHeaders().get("Retry-After")); + } + + @Test + public void testRecognizedA2AErrorDoesNotWrapInHttpError() { + String body = "{\"error\": {\"code\": 404, \"status\": \"NOT_FOUND\", \"message\": \"Task not found\", " + + "\"details\": [{\"reason\": \"TASK_NOT_FOUND\"}]}}"; + A2AClientException ex = RestErrorMapper.mapRestError(body, 404, Map.of()); + + assertInstanceOf(TaskNotFoundError.class, ex.getCause()); + } + + @Test + public void testMapRestErrorFromA2AHttpResponse() { + Map> headerMap = Map.of("X-Custom", List.of("value")); + A2AHttpResponse response = new A2AHttpResponse() { + @Override + public int status() { + return 500; + } + + @Override + public boolean success() { + return false; + } + + @Override + public String body() { + return ""; + } + + @Override + public A2AHttpHeaders headers() { + return new A2AHttpHeaders() { + @Override + public String firstValue(String name) { + List values = headerMap.get(name); + return values != null && !values.isEmpty() ? values.get(0) : null; + } + + @Override + public List allValues(String name) { + return headerMap.getOrDefault(name, List.of()); + } + + @Override + public Map> toMap() { + return headerMap; + } + }; + } + }; + + A2AClientException ex = RestErrorMapper.mapRestError(response); + assertInstanceOf(A2AClientHTTPError.class, ex.getCause()); + A2AClientHTTPError httpError = (A2AClientHTTPError) ex.getCause(); + assertEquals(500, httpError.getCode()); + assertEquals(List.of("value"), httpError.getResponseHeaders().get("X-Custom")); + } + + @Test + public void testHeaderLookupIsCaseInsensitive() { + Map> headers = Map.of("Retry-After", List.of("60")); + A2AClientException ex = RestErrorMapper.mapRestError("", 429, headers); + + A2AClientHTTPError httpError = (A2AClientHTTPError) ex.getCause(); + assertEquals(List.of("60"), httpError.getResponseHeaders().get("retry-after")); + assertEquals(List.of("60"), httpError.getResponseHeaders().get("RETRY-AFTER")); + } + + @Test + public void testTwoArgOverloadDefaultsToEmptyHeaders() { + A2AClientException ex = RestErrorMapper.mapRestError("", 500); + + assertInstanceOf(A2AClientHTTPError.class, ex.getCause()); + A2AClientHTTPError httpError = (A2AClientHTTPError) ex.getCause(); + assertNotNull(httpError.getResponseHeaders()); + assertTrue(httpError.getResponseHeaders().isEmpty()); + } +} diff --git a/extras/http-client-android/src/main/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClient.java b/extras/http-client-android/src/main/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClient.java index fa6aae288..9c3ae9238 100644 --- a/extras/http-client-android/src/main/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClient.java +++ b/extras/http-client-android/src/main/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClient.java @@ -17,13 +17,18 @@ import java.net.URISyntaxException; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.TreeMap; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; import java.util.concurrent.Executors; import java.util.function.Consumer; +import org.jspecify.annotations.Nullable; + /** Android-specific implementation of {@link A2AHttpClient} using {@link HttpURLConnection}. */ public class AndroidA2AHttpClient implements A2AHttpClient { @@ -136,7 +141,8 @@ protected A2AHttpResponse execute(HttpURLConnection connection) throws IOExcepti body = readStreamWithLimit(is); } - return new AndroidHttpResponse(status, body); + A2AHttpHeaders headers = fromConnectionHeaders(connection.getHeaderFields()); + return new AndroidHttpResponse(status, body, headers); } protected void processSSEResponse( @@ -305,10 +311,46 @@ public A2AHttpResponse delete() throws IOException { } } - private record AndroidHttpResponse(int status, String body) implements A2AHttpResponse { + private static A2AHttpHeaders fromConnectionHeaders(@Nullable Map> headerFields) { + Map> filtered = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + if (headerFields != null) { + for (Map.Entry> entry : headerFields.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + filtered.put(entry.getKey(), Collections.unmodifiableList(entry.getValue())); + } + } + } + Map> immutable = Collections.unmodifiableMap(filtered); + + return new A2AHttpHeaders() { + @Override + public @Nullable String firstValue(String name) { + List values = immutable.get(name); + return (values != null && !values.isEmpty()) ? values.get(0) : null; + } + + @Override + public List allValues(String name) { + List values = immutable.get(name); + return values != null ? values : List.of(); + } + + @Override + public Map> toMap() { + return immutable; + } + }; + } + + private record AndroidHttpResponse(int status, String body, A2AHttpHeaders headers) implements A2AHttpResponse { @Override public boolean success() { return status >= HTTP_OK && status < HTTP_MULT_CHOICE; } + + @Override + public A2AHttpHeaders headers() { + return headers; + } } } diff --git a/extras/http-client-vertx/src/main/java/org/a2aproject/sdk/client/http/VertxA2AHttpClient.java b/extras/http-client-vertx/src/main/java/org/a2aproject/sdk/client/http/VertxA2AHttpClient.java index 9793de216..f29b6bc70 100644 --- a/extras/http-client-vertx/src/main/java/org/a2aproject/sdk/client/http/VertxA2AHttpClient.java +++ b/extras/http-client-vertx/src/main/java/org/a2aproject/sdk/client/http/VertxA2AHttpClient.java @@ -7,8 +7,11 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.TreeMap; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicBoolean; @@ -335,7 +338,8 @@ private void handleResponse( case HTTP_FORBIDDEN -> errorRef.set(new IOException(A2AErrorMessages.AUTHORIZATION_FAILED)); default -> { String body = response.bodyAsString(); - responseRef.set(new VertxHttpResponse(status, body != null ? body : "")); + A2AHttpHeaders headers = fromVertxHeaders(response.headers()); + responseRef.set(new VertxHttpResponse(status, body != null ? body : "", headers)); } } } else { @@ -644,7 +648,31 @@ public WriteStream drainHandler(@Nullable Handler handler) { } } - private record VertxHttpResponse(int status, String body) implements A2AHttpResponse { + private static A2AHttpHeaders fromVertxHeaders(io.vertx.core.MultiMap headers) { + return new A2AHttpHeaders() { + @Override + public @Nullable String firstValue(String name) { + return headers.get(name); + } + + @Override + public List allValues(String name) { + List values = headers.getAll(name); + return values != null ? Collections.unmodifiableList(values) : List.of(); + } + + @Override + public Map> toMap() { + Map> map = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (String name : headers.names()) { + map.put(name, Collections.unmodifiableList(headers.getAll(name))); + } + return Collections.unmodifiableMap(map); + } + }; + } + + private record VertxHttpResponse(int status, String body, A2AHttpHeaders headers) implements A2AHttpResponse { @Override public int status() { @@ -660,5 +688,10 @@ public boolean success() { public String body() { return body; } + + @Override + public A2AHttpHeaders headers() { + return headers; + } } } diff --git a/http-client/src/main/java/org/a2aproject/sdk/client/http/A2AHttpHeaders.java b/http-client/src/main/java/org/a2aproject/sdk/client/http/A2AHttpHeaders.java new file mode 100644 index 000000000..dce2e2762 --- /dev/null +++ b/http-client/src/main/java/org/a2aproject/sdk/client/http/A2AHttpHeaders.java @@ -0,0 +1,80 @@ +package org.a2aproject.sdk.client.http; + +import java.util.List; +import java.util.Map; + +import org.jspecify.annotations.Nullable; + +/** + * Read-only abstraction over HTTP response headers. + * + *

Header names are case-insensitive per RFC 7230. Implementations must + * perform case-insensitive lookup for all accessor methods. + * + *

HTTP headers may have multiple values for the same name (e.g. Set-Cookie). + * Use {@link #allValues(String)} to retrieve all values, or {@link #firstValue(String)} + * when only a single value is expected (e.g. Retry-After, Content-Type). + * + *

Usage Example

+ *
{@code
+ * A2AHttpResponse response = client.createPost()
+ *     .url("http://localhost:9999/message:send")
+ *     .body(jsonBody)
+ *     .post();
+ *
+ * if (!response.success()) {
+ *     String retryAfter = response.headers().firstValue("Retry-After");
+ *     // Handle rate limiting
+ * }
+ * }
+ * + * @see A2AHttpResponse#headers() + */ +public interface A2AHttpHeaders { + + /** + * Empty headers instance returned by default when headers are not available. + */ + A2AHttpHeaders EMPTY = new A2AHttpHeaders() { + @Override + public @Nullable String firstValue(String name) { + return null; + } + + @Override + public List allValues(String name) { + return List.of(); + } + + @Override + public Map> toMap() { + return Map.of(); + } + }; + + /** + * Returns the first value for the given header name, or {@code null} if not present. + * + * @param name the header name (case-insensitive) + * @return the first header value, or {@code null} + */ + @Nullable + String firstValue(String name); + + /** + * Returns all values for the given header name. + * + * @param name the header name (case-insensitive) + * @return an unmodifiable list of values, empty if the header is not present + */ + List allValues(String name); + + /** + * Returns an unmodifiable map of all headers. + * + *

The keys in the returned map are in their original casing from the response. + * + * @return map of header names to lists of values + */ + Map> toMap(); +} diff --git a/http-client/src/main/java/org/a2aproject/sdk/client/http/A2AHttpResponse.java b/http-client/src/main/java/org/a2aproject/sdk/client/http/A2AHttpResponse.java index 3084b8ddc..b1e943409 100644 --- a/http-client/src/main/java/org/a2aproject/sdk/client/http/A2AHttpResponse.java +++ b/http-client/src/main/java/org/a2aproject/sdk/client/http/A2AHttpResponse.java @@ -44,4 +44,16 @@ public interface A2AHttpResponse { * @return the response body, may be empty but not null */ String body(); + + /** + * Returns the HTTP response headers. + * + *

Provides access to response headers such as {@code Retry-After}, + * {@code WWW-Authenticate}, or any other server-provided headers. + * + * @return the response headers, never null; may be empty + */ + default A2AHttpHeaders headers() { + return A2AHttpHeaders.EMPTY; + } } diff --git a/http-client/src/main/java/org/a2aproject/sdk/client/http/JdkA2AHttpClient.java b/http-client/src/main/java/org/a2aproject/sdk/client/http/JdkA2AHttpClient.java index 7499d52ac..586f58e3a 100644 --- a/http-client/src/main/java/org/a2aproject/sdk/client/http/JdkA2AHttpClient.java +++ b/http-client/src/main/java/org/a2aproject/sdk/client/http/JdkA2AHttpClient.java @@ -379,6 +379,27 @@ static boolean success(int statusCode) { public String body() { return response.body(); } + + @Override + public A2AHttpHeaders headers() { + java.net.http.HttpHeaders jdkHeaders = response.headers(); + return new A2AHttpHeaders() { + @Override + public @Nullable String firstValue(String name) { + return jdkHeaders.firstValue(name).orElse(null); + } + + @Override + public List allValues(String name) { + return jdkHeaders.allValues(name); + } + + @Override + public Map> toMap() { + return jdkHeaders.map(); + } + }; + } } } diff --git a/http-client/src/test/java/org/a2aproject/sdk/client/http/AbstractA2AHttpClientIntegrationTest.java b/http-client/src/test/java/org/a2aproject/sdk/client/http/AbstractA2AHttpClientIntegrationTest.java index e0b45f9b3..d3eca90c7 100644 --- a/http-client/src/test/java/org/a2aproject/sdk/client/http/AbstractA2AHttpClientIntegrationTest.java +++ b/http-client/src/test/java/org/a2aproject/sdk/client/http/AbstractA2AHttpClientIntegrationTest.java @@ -2,19 +2,23 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockserver.model.HttpRequest.request; import static org.mockserver.model.HttpResponse.response; +import java.io.IOException; +import java.util.List; +import java.util.Map; + import org.a2aproject.sdk.common.A2AErrorMessages; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockserver.integration.ClientAndServer; -import java.io.IOException; - public abstract class AbstractA2AHttpClientIntegrationTest { private ClientAndServer mockServer; @@ -213,4 +217,96 @@ public void test404NotFound() throws Exception { assertFalse(response.success()); assertEquals("Not Found", response.body()); } + + @Test + public void testResponseHeadersOnSuccess() throws Exception { + mockServer + .when(request().withMethod("GET").withPath("/test")) + .respond(response() + .withStatusCode(200) + .withHeader("X-Custom-Header", "custom-value") + .withHeader("X-Request-Id", "abc-123") + .withBody("ok")); + + A2AHttpResponse response = client.createGet() + .url(getBaseUrl() + "/test") + .get(); + + assertEquals(200, response.status()); + A2AHttpHeaders headers = response.headers(); + assertNotNull(headers); + assertEquals("custom-value", headers.firstValue("X-Custom-Header")); + assertEquals("abc-123", headers.firstValue("X-Request-Id")); + } + + @Test + public void testResponseHeadersCaseInsensitive() throws Exception { + mockServer + .when(request().withMethod("GET").withPath("/test")) + .respond(response() + .withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody("{}")); + + A2AHttpResponse response = client.createGet() + .url(getBaseUrl() + "/test") + .get(); + + A2AHttpHeaders headers = response.headers(); + assertEquals(headers.firstValue("Content-Type"), headers.firstValue("content-type")); + } + + @Test + public void testResponseHeadersOnErrorStatus() throws Exception { + mockServer + .when(request().withMethod("POST").withPath("/test")) + .respond(response() + .withStatusCode(429) + .withHeader("Retry-After", "120") + .withBody("Too Many Requests")); + + A2AHttpResponse response = client.createPost() + .url(getBaseUrl() + "/test") + .body("{}") + .post(); + + assertEquals(429, response.status()); + assertFalse(response.success()); + A2AHttpHeaders headers = response.headers(); + assertEquals("120", headers.firstValue("Retry-After")); + } + + @Test + public void testResponseHeadersMissingHeader() throws Exception { + mockServer + .when(request().withMethod("GET").withPath("/test")) + .respond(response().withStatusCode(200).withBody("ok")); + + A2AHttpResponse response = client.createGet() + .url(getBaseUrl() + "/test") + .get(); + + A2AHttpHeaders headers = response.headers(); + assertNull(headers.firstValue("X-Nonexistent")); + assertEquals(List.of(), headers.allValues("X-Nonexistent")); + } + + @Test + public void testResponseHeadersToMap() throws Exception { + mockServer + .when(request().withMethod("GET").withPath("/test")) + .respond(response() + .withStatusCode(200) + .withHeader("X-Test", "value1") + .withBody("ok")); + + A2AHttpResponse response = client.createGet() + .url(getBaseUrl() + "/test") + .get(); + + Map> headerMap = response.headers().toMap(); + assertNotNull(headerMap); + assertFalse(headerMap.isEmpty()); + assertTrue(headerMap.containsKey("X-Test") || headerMap.containsKey("x-test")); + } } 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 bdce6e275..55b0f5547 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/A2AClientHTTPError.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/A2AClientHTTPError.java @@ -1,5 +1,10 @@ package org.a2aproject.sdk.spec; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + import org.a2aproject.sdk.util.Assert; import org.jspecify.annotations.Nullable; @@ -18,14 +23,17 @@ *

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

{@code
+ * 
  * } 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
+ *         int status = httpError.getCode();
+ *         String body = httpError.getResponseBody();
+ *         Map<String, List<String>> headers = httpError.getResponseHeaders();
+ *         String retryAfter = headers.getOrDefault("Retry-After", List.of())
+ *             .stream().findFirst().orElse(null);
  *     }
  * }
- * }
+ *
* * @see A2AClientError for the base client error class * @see HTTP Status Codes @@ -47,6 +55,11 @@ public class A2AClientHTTPError extends A2AClientError { @Nullable private final String responseBody; + /** + * The HTTP response headers. + */ + private final Map> responseHeaders; + /** * Creates a new HTTP client error with the specified status code and message. * @@ -54,7 +67,7 @@ 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 Use {@link #A2AClientHTTPError(int, String, String, Map)} instead to preserve the response body and headers. */ @Deprecated(since = "1.0.0.Beta1", forRemoval = true) public A2AClientHTTPError(int code, String message, Object data) { @@ -63,6 +76,7 @@ public A2AClientHTTPError(int code, String message, Object data) { this.code = code; this.message = message; this.responseBody = data instanceof String s ? s : ""; + this.responseHeaders = Map.of(); } /** @@ -77,6 +91,31 @@ public A2AClientHTTPError(int code, String message, @Nullable String responseBod this.code = code; this.message = message; this.responseBody = responseBody; + this.responseHeaders = Map.of(); + } + + /** + * Creates a new HTTP client error with the specified status code, message, response body, and headers. + * + * @param code the HTTP status code (e.g. 429, 503) + * @param message the error message + * @param responseBody the raw HTTP response body, may be {@code null} + * @param responseHeaders the HTTP response headers + */ + public A2AClientHTTPError(int code, String message, @Nullable String responseBody, + Map> responseHeaders) { + Assert.checkNotNullParam("message", message); + Assert.checkNotNullParam("responseHeaders", responseHeaders); + this.code = code; + this.message = message; + this.responseBody = responseBody; + TreeMap> copy = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (Map.Entry> entry : responseHeaders.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + copy.put(entry.getKey(), List.copyOf(entry.getValue())); + } + } + this.responseHeaders = Collections.unmodifiableMap(copy); } /** @@ -106,4 +145,16 @@ public String getMessage() { public @Nullable String getResponseBody() { return responseBody; } + + /** + * Returns the HTTP response headers. + * + *

Useful for examining headers like {@code Retry-After} on 429 responses + * or {@code WWW-Authenticate} on 401 responses. + * + * @return unmodifiable, case-insensitive map of header names to lists of values, never null + */ + public Map> getResponseHeaders() { + return responseHeaders; + } } From 7a6ecebccb33547d017ce75c5cf87b5f253f5457 Mon Sep 17 00:00:00 2001 From: Kabir Khan Date: Tue, 9 Jun 2026 20:19:33 +0200 Subject: [PATCH 2/3] Emmanuel review fixes --- .../a2aproject/sdk/client/http/A2ACardResolver.java | 11 ++++++++--- .../a2aproject/sdk/client/http/JdkA2AHttpClient.java | 6 +++++- .../sdk/client/http/A2ACardResolverTest.java | 10 ++++++++-- .../org/a2aproject/sdk/spec/A2AClientHTTPError.java | 2 ++ 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/http-client/src/main/java/org/a2aproject/sdk/client/http/A2ACardResolver.java b/http-client/src/main/java/org/a2aproject/sdk/client/http/A2ACardResolver.java index d5024dac3..e8ab5fd48 100644 --- a/http-client/src/main/java/org/a2aproject/sdk/client/http/A2ACardResolver.java +++ b/http-client/src/main/java/org/a2aproject/sdk/client/http/A2ACardResolver.java @@ -11,6 +11,8 @@ import org.a2aproject.sdk.grpc.utils.ProtoUtils; import org.a2aproject.sdk.jsonrpc.common.json.JsonProcessingException; import org.a2aproject.sdk.spec.A2AClientError; +import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.A2AClientHTTPError; import org.a2aproject.sdk.spec.A2AClientJSONError; import org.a2aproject.sdk.spec.AgentCard; import org.a2aproject.sdk.util.Utils; @@ -226,11 +228,12 @@ public A2ACardResolver build() throws A2AClientError { * is performed; errors are propagated directly to the caller. * * @return the agent card - * @throws A2AClientError If an HTTP or network error occurs fetching the card + * @throws A2AClientException If an HTTP error occurs fetching the card (with {@link A2AClientHTTPError} as cause) + * @throws A2AClientError If a network error occurs fetching the card * @throws A2AClientJSONError If the response body cannot be decoded as JSON or validated * against the AgentCard schema */ - public AgentCard getAgentCard() throws A2AClientError, A2AClientJSONError { + public AgentCard getAgentCard() throws A2AClientException, A2AClientJSONError { LOGGER.debug("Fetching agent card from URL: {}", cardUrl); A2AHttpClient.GetBuilder builder = httpClient.createGet() @@ -245,8 +248,10 @@ public AgentCard getAgentCard() throws A2AClientError, A2AClientJSONError { try { A2AHttpResponse response = builder.get(); if (!response.success()) { + String msg = "Failed to obtain agent card: " + response.status(); LOGGER.debug("Failed to fetch agent card from {}, status: {}", cardUrl, response.status()); - throw new A2AClientError("Failed to obtain agent card: " + response.status()); + throw new A2AClientException(msg, + new A2AClientHTTPError(response.status(), msg, response.body(), response.headers().toMap())); } body = response.body(); LOGGER.debug("Successfully fetched agent card from {}", cardUrl); diff --git a/http-client/src/main/java/org/a2aproject/sdk/client/http/JdkA2AHttpClient.java b/http-client/src/main/java/org/a2aproject/sdk/client/http/JdkA2AHttpClient.java index 586f58e3a..f8af62462 100644 --- a/http-client/src/main/java/org/a2aproject/sdk/client/http/JdkA2AHttpClient.java +++ b/http-client/src/main/java/org/a2aproject/sdk/client/http/JdkA2AHttpClient.java @@ -16,9 +16,11 @@ import java.net.http.HttpResponse.BodySubscribers; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.TreeMap; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Flow; import java.util.concurrent.atomic.AtomicBoolean; @@ -396,7 +398,9 @@ public List allValues(String name) { @Override public Map> toMap() { - return jdkHeaders.map(); + Map> map = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + map.putAll(jdkHeaders.map()); + return Collections.unmodifiableMap(map); } }; } diff --git a/http-client/src/test/java/org/a2aproject/sdk/client/http/A2ACardResolverTest.java b/http-client/src/test/java/org/a2aproject/sdk/client/http/A2ACardResolverTest.java index 3caeeebda..98e6602d2 100644 --- a/http-client/src/test/java/org/a2aproject/sdk/client/http/A2ACardResolverTest.java +++ b/http-client/src/test/java/org/a2aproject/sdk/client/http/A2ACardResolverTest.java @@ -1,6 +1,7 @@ package org.a2aproject.sdk.client.http; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -17,6 +18,8 @@ import org.a2aproject.sdk.grpc.utils.ProtoUtils; import org.a2aproject.sdk.jsonrpc.common.json.JsonProcessingException; import org.a2aproject.sdk.spec.A2AClientError; +import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.A2AClientHTTPError; import org.a2aproject.sdk.spec.A2AClientJSONError; import org.a2aproject.sdk.spec.AgentCard; import org.junit.jupiter.api.Test; @@ -134,8 +137,10 @@ public void testGetAgentCard_httpErrorThrows() throws Exception { TestHttpClient client = createTestClient(); client.status = 503; A2ACardResolver resolver = A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com/").build(); - A2AClientError error = assertThrows(A2AClientError.class, resolver::getAgentCard); + A2AClientException error = assertThrows(A2AClientException.class, resolver::getAgentCard); assertTrue(error.getMessage().contains("503")); + A2AClientHTTPError httpError = assertInstanceOf(A2AClientHTTPError.class, error.getCause()); + assertEquals(503, httpError.getCode()); } @Test @@ -147,7 +152,8 @@ public void testGetAgentCard_customPath_httpErrorThrows_noFallback() throws Exce .baseUrl("http://example.com") .agentCardPath("/custom/agent.json") .build(); - assertThrows(A2AClientError.class, resolver::getAgentCard); + A2AClientException error = assertThrows(A2AClientException.class, resolver::getAgentCard); + assertInstanceOf(A2AClientHTTPError.class, error.getCause()); assertEquals(1, client.urlsCalled.size()); assertEquals("http://example.com/custom/agent.json", client.urlsCalled.get(0)); } 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 55b0f5547..56b17e086 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/A2AClientHTTPError.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/A2AClientHTTPError.java @@ -85,7 +85,9 @@ public A2AClientHTTPError(int code, String message, Object data) { * @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} + * @deprecated Use {@link #A2AClientHTTPError(int, String, String, Map)} instead to preserve the response headers. */ + @Deprecated(since = "1.0.0.Beta1", forRemoval = true) public A2AClientHTTPError(int code, String message, @Nullable String responseBody) { Assert.checkNotNullParam("message", message); this.code = code; From 83ee46120f64b5885eeeb16865f8ae0e4d7e4498 Mon Sep 17 00:00:00 2001 From: Kabir Khan Date: Tue, 9 Jun 2026 21:15:41 +0200 Subject: [PATCH 3/3] GEmini suggestions --- .../a2aproject/sdk/client/http/AndroidA2AHttpClient.java | 6 ++++++ .../org/a2aproject/sdk/client/http/VertxA2AHttpClient.java | 6 ++++++ .../org/a2aproject/sdk/client/http/JdkA2AHttpClient.java | 6 ++++++ .../java/org/a2aproject/sdk/spec/A2AClientHTTPError.java | 2 +- 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/extras/http-client-android/src/main/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClient.java b/extras/http-client-android/src/main/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClient.java index 9c3ae9238..b70ea5350 100644 --- a/extras/http-client-android/src/main/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClient.java +++ b/extras/http-client-android/src/main/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClient.java @@ -325,12 +325,18 @@ private static A2AHttpHeaders fromConnectionHeaders(@Nullable Map values = immutable.get(name); return (values != null && !values.isEmpty()) ? values.get(0) : null; } @Override public List allValues(String name) { + if (name == null) { + return List.of(); + } List values = immutable.get(name); return values != null ? values : List.of(); } diff --git a/extras/http-client-vertx/src/main/java/org/a2aproject/sdk/client/http/VertxA2AHttpClient.java b/extras/http-client-vertx/src/main/java/org/a2aproject/sdk/client/http/VertxA2AHttpClient.java index f29b6bc70..6e9f51b4a 100644 --- a/extras/http-client-vertx/src/main/java/org/a2aproject/sdk/client/http/VertxA2AHttpClient.java +++ b/extras/http-client-vertx/src/main/java/org/a2aproject/sdk/client/http/VertxA2AHttpClient.java @@ -652,11 +652,17 @@ private static A2AHttpHeaders fromVertxHeaders(io.vertx.core.MultiMap headers) { return new A2AHttpHeaders() { @Override public @Nullable String firstValue(String name) { + if (name == null) { + return null; + } return headers.get(name); } @Override public List allValues(String name) { + if (name == null) { + return List.of(); + } List values = headers.getAll(name); return values != null ? Collections.unmodifiableList(values) : List.of(); } diff --git a/http-client/src/main/java/org/a2aproject/sdk/client/http/JdkA2AHttpClient.java b/http-client/src/main/java/org/a2aproject/sdk/client/http/JdkA2AHttpClient.java index f8af62462..7862d1c80 100644 --- a/http-client/src/main/java/org/a2aproject/sdk/client/http/JdkA2AHttpClient.java +++ b/http-client/src/main/java/org/a2aproject/sdk/client/http/JdkA2AHttpClient.java @@ -388,11 +388,17 @@ public A2AHttpHeaders headers() { return new A2AHttpHeaders() { @Override public @Nullable String firstValue(String name) { + if (name == null) { + return null; + } return jdkHeaders.firstValue(name).orElse(null); } @Override public List allValues(String name) { + if (name == null) { + return List.of(); + } return jdkHeaders.allValues(name); } 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 56b17e086..f853e8e6c 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/A2AClientHTTPError.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/A2AClientHTTPError.java @@ -114,7 +114,7 @@ public A2AClientHTTPError(int code, String message, @Nullable String responseBod TreeMap> copy = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); for (Map.Entry> entry : responseHeaders.entrySet()) { if (entry.getKey() != null && entry.getValue() != null) { - copy.put(entry.getKey(), List.copyOf(entry.getValue())); + copy.put(entry.getKey(), entry.getValue().stream().filter(v -> v != null).toList()); } } this.responseHeaders = Collections.unmodifiableMap(copy);