From e999b37743d53096d01d2403001c6107fdc0abb7 Mon Sep 17 00:00:00 2001 From: Kabir Khan Date: Tue, 9 Jun 2026 10:09:51 +0200 Subject: [PATCH 1/6] 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/6] 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/6] 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); From 3867f8c66b2241e1b87452d30db6935989851dc2 Mon Sep 17 00:00:00 2001 From: Kabir Khan Date: Wed, 10 Jun 2026 11:53:42 +0200 Subject: [PATCH 4/6] Incorporate Emmanuel's feedback --- .../jsonrpc/JSONRPCTransportTest.java | 6 +- .../transport/rest/RestErrorMapper.java | 21 ++++- .../sdk/client/http/AndroidA2AHttpClient.java | 64 ++++---------- .../sdk/client/http/VertxA2AHttpClient.java | 87 +++++++------------ .../sdk/client/http/A2ACardResolver.java | 8 +- .../sdk/client/http/A2AHttpHeaders.java | 45 ++++++++++ .../sdk/client/http/JdkA2AHttpClient.java | 67 ++++++-------- .../sdk/client/http/A2ACardResolverTest.java | 11 +-- .../sdk/spec/A2AClientHTTPError.java | 2 +- 9 files changed, 151 insertions(+), 160 deletions(-) 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 45080d5ce..e8794d497 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 @@ -728,13 +728,11 @@ public void testHttpErrorExposeResponseHeaders() throws Exception { assertNotNull(headers); assertFalse(headers.isEmpty()); - List retryAfter = headers.getOrDefault("Retry-After", - headers.getOrDefault("retry-after", List.of())); + List retryAfter = 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())); + List rateLimitRemaining = headers.getOrDefault("X-RateLimit-Remaining", List.of()); assertFalse(rateLimitRemaining.isEmpty(), "Expected X-RateLimit-Remaining header"); assertEquals("0", rateLimitRemaining.get(0)); } 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 2c3cf167a..ec92c8975 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 @@ -70,7 +70,13 @@ public static A2AClientException mapRestError(String body, int code, Map= HTTP_OK && status < HTTP_MULT_CHOICE)) { - if (status == HTTP_UNAUTHORIZED) { - errorConsumer.accept(new IOException(A2AErrorMessages.AUTHENTICATION_FAILED)); - return; - } else if (status == HTTP_FORBIDDEN) { - errorConsumer.accept(new IOException(A2AErrorMessages.AUTHORIZATION_FAILED)); + if (status == HTTP_UNAUTHORIZED || status == HTTP_FORBIDDEN) { + A2AHttpHeaders responseHeaders = fromConnectionHeaders(connection.getHeaderFields()); + String msg = status == HTTP_UNAUTHORIZED + ? A2AErrorMessages.AUTHENTICATION_FAILED + : A2AErrorMessages.AUTHORIZATION_FAILED; + errorConsumer.accept(new IOException(msg, + new A2AClientHTTPError(status, msg, null, responseHeaders.toMap()))); return; } @@ -312,40 +317,9 @@ public A2AHttpResponse delete() throws IOException { } 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) { - if (name == null) { - return null; - } - List 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(); - } - - @Override - public Map> toMap() { - return immutable; - } - }; + // HttpURLConnection.getHeaderFields() may include a null key for the HTTP status line; + // A2AHttpHeaders.of() filters those out automatically. + return A2AHttpHeaders.of(headerFields != null ? headerFields : Map.of()); } private record AndroidHttpResponse(int status, String body, A2AHttpHeaders headers) implements A2AHttpResponse { 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 6e9f51b4a..723f4eeb7 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,11 +7,9 @@ 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; @@ -276,7 +274,8 @@ T self() { * @param headers custom headers to add to the request * @param bodyBuffer optional body buffer for POST requests (null for GET/DELETE) * @return the HTTP response - * @throws IOException if the request fails or returns 401/403 + * @throws IOException if the request fails; the cause may be an + * {@link org.a2aproject.sdk.spec.A2AClientHTTPError} for HTTP 401/403 responses * @throws InterruptedException if the thread is interrupted while waiting */ private A2AHttpResponse executeSyncRequest( @@ -332,10 +331,19 @@ private void handleResponse( HttpResponse response = ar.result(); int status = response.statusCode(); - // Check for authentication/authorization errors switch (status) { - case HTTP_UNAUTHORIZED -> errorRef.set(new IOException(A2AErrorMessages.AUTHENTICATION_FAILED)); - case HTTP_FORBIDDEN -> errorRef.set(new IOException(A2AErrorMessages.AUTHORIZATION_FAILED)); + case HTTP_UNAUTHORIZED -> { + A2AHttpHeaders headers = fromVertxHeaders(response.headers()); + errorRef.set(new IOException(A2AErrorMessages.AUTHENTICATION_FAILED, + new org.a2aproject.sdk.spec.A2AClientHTTPError( + HTTP_UNAUTHORIZED, A2AErrorMessages.AUTHENTICATION_FAILED, null, headers.toMap()))); + } + case HTTP_FORBIDDEN -> { + A2AHttpHeaders headers = fromVertxHeaders(response.headers()); + errorRef.set(new IOException(A2AErrorMessages.AUTHORIZATION_FAILED, + new org.a2aproject.sdk.spec.A2AClientHTTPError( + HTTP_FORBIDDEN, A2AErrorMessages.AUTHORIZATION_FAILED, null, headers.toMap()))); + } default -> { String body = response.bodyAsString(); A2AHttpHeaders headers = fromVertxHeaders(response.headers()); @@ -394,10 +402,13 @@ private CompletableFuture executeAsyncSSE( int statusCode = response.statusCode(); if (statusCode == HTTP_UNAUTHORIZED || statusCode == HTTP_FORBIDDEN) { if (futureCompleted.compareAndSet(false, true)) { - IOException error = (statusCode == HTTP_UNAUTHORIZED) - ? new IOException(A2AErrorMessages.AUTHENTICATION_FAILED) - : new IOException(A2AErrorMessages.AUTHORIZATION_FAILED); - errorConsumer.accept(error); + A2AHttpHeaders respHeaders = fromVertxHeaders(response.headers()); + String msg = statusCode == HTTP_UNAUTHORIZED + ? A2AErrorMessages.AUTHENTICATION_FAILED + : A2AErrorMessages.AUTHORIZATION_FAILED; + errorConsumer.accept(new IOException(msg, + new org.a2aproject.sdk.spec.A2AClientHTTPError( + statusCode, msg, null, respHeaders.toMap()))); future.complete(null); } return; @@ -478,12 +489,8 @@ private class VertxGetBuilder extends VertxBuilder implements A2AHtt * the asynchronous HTTP request completes. The underlying Vert.x operation executes * asynchronously on the Vert.x event loop. * - * @throws IOException if the request fails, including: - *

    - *
  • Network errors (connection refused, timeout, etc.)
  • - *
  • HTTP 401 Unauthorized - with message from {@link A2AErrorMessages#AUTHENTICATION_FAILED}
  • - *
  • HTTP 403 Forbidden - with message from {@link A2AErrorMessages#AUTHORIZATION_FAILED}
  • - *
+ * @throws IOException if the request fails, including network errors, HTTP 401, and HTTP 403; + * the cause may be an {@link org.a2aproject.sdk.spec.A2AClientHTTPError} carrying the response headers * @throws InterruptedException if the thread is interrupted while waiting */ @Override @@ -519,12 +526,8 @@ public PostBuilder body(String body) { * the asynchronous HTTP request completes. The underlying Vert.x operation executes * asynchronously on the Vert.x event loop. * - * @throws IOException if the request fails, including: - *
    - *
  • Network errors (connection refused, timeout, etc.)
  • - *
  • HTTP 401 Unauthorized - with message from {@link A2AErrorMessages#AUTHENTICATION_FAILED}
  • - *
  • HTTP 403 Forbidden - with message from {@link A2AErrorMessages#AUTHORIZATION_FAILED}
  • - *
+ * @throws IOException if the request fails, including network errors, HTTP 401, and HTTP 403; + * the cause may be an {@link org.a2aproject.sdk.spec.A2AClientHTTPError} carrying the response headers * @throws InterruptedException if the thread is interrupted while waiting */ @Override @@ -554,12 +557,8 @@ private class VertxDeleteBuilder extends VertxBuilder implements * the asynchronous HTTP request completes. The underlying Vert.x operation executes * asynchronously on the Vert.x event loop. * - * @throws IOException if the request fails, including: - *
    - *
  • Network errors (connection refused, timeout, etc.)
  • - *
  • HTTP 401 Unauthorized - with message from {@link A2AErrorMessages#AUTHENTICATION_FAILED}
  • - *
  • HTTP 403 Forbidden - with message from {@link A2AErrorMessages#AUTHORIZATION_FAILED}
  • - *
+ * @throws IOException if the request fails, including network errors, HTTP 401, and HTTP 403; + * the cause may be an {@link org.a2aproject.sdk.spec.A2AClientHTTPError} carrying the response headers * @throws InterruptedException if the thread is interrupted while waiting */ @Override @@ -649,33 +648,11 @@ public WriteStream drainHandler(@Nullable Handler handler) { } 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(); - } - - @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); - } - }; + Map> snapshot = new HashMap<>(); + for (String name : headers.names()) { + snapshot.put(name, headers.getAll(name)); + } + return A2AHttpHeaders.of(snapshot); } private record VertxHttpResponse(int status, String body, A2AHttpHeaders headers) implements A2AHttpResponse { 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 e8ab5fd48..350511f01 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,7 +11,6 @@ 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; @@ -228,12 +227,12 @@ public A2ACardResolver build() throws A2AClientError { * is performed; errors are propagated directly to the caller. * * @return the agent card - * @throws A2AClientException If an HTTP error occurs fetching the card (with {@link A2AClientHTTPError} as cause) + * @throws A2AClientHTTPError If the server returns a non-2xx response (carries status, body, and headers) * @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 A2AClientException, A2AClientJSONError { + public AgentCard getAgentCard() throws A2AClientError, A2AClientJSONError { LOGGER.debug("Fetching agent card from URL: {}", cardUrl); A2AHttpClient.GetBuilder builder = httpClient.createGet() @@ -250,8 +249,7 @@ public AgentCard getAgentCard() throws A2AClientException, A2AClientJSONError { 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 A2AClientException(msg, - new A2AClientHTTPError(response.status(), msg, response.body(), response.headers().toMap())); + throw 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/A2AHttpHeaders.java b/http-client/src/main/java/org/a2aproject/sdk/client/http/A2AHttpHeaders.java index dce2e2762..14fdbb20f 100644 --- 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 @@ -1,7 +1,9 @@ package org.a2aproject.sdk.client.http; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.TreeMap; import org.jspecify.annotations.Nullable; @@ -77,4 +79,47 @@ public Map> toMap() { * @return map of header names to lists of values */ Map> toMap(); + + /** + * Creates an {@link A2AHttpHeaders} instance from a map of header name to value lists. + * + *

Builds a case-insensitive snapshot: null keys and null value lists are silently + * skipped (e.g. the {@code null} status-line key from {@link java.net.HttpURLConnection}). + * + * @param headers the source header map; may be null-keyed + * @return an immutable, case-insensitive {@link A2AHttpHeaders} view + */ + static A2AHttpHeaders of(Map> headers) { + TreeMap> copy = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (Map.Entry> entry : headers.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + copy.put(entry.getKey(), List.copyOf(entry.getValue())); + } + } + Map> immutable = Collections.unmodifiableMap(copy); + return new A2AHttpHeaders() { + @Override + public @Nullable String firstValue(String name) { + if (name == null) { + return null; + } + List 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(); + } + + @Override + public Map> toMap() { + return immutable; + } + }; + } } 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 7862d1c80..47c7f0e32 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,11 +16,9 @@ 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; @@ -28,6 +26,7 @@ import org.jspecify.annotations.Nullable; import org.a2aproject.sdk.common.A2AErrorMessages; +import org.a2aproject.sdk.spec.A2AClientHTTPError; /** * Default HTTP client implementation using JDK 11+ {@link HttpClient}. @@ -207,17 +206,17 @@ public void onComplete() { BodyHandler bodyHandler = responseInfo -> { // Check for authentication/authorization errors only if (responseInfo.statusCode() == HTTP_UNAUTHORIZED || responseInfo.statusCode() == HTTP_FORBIDDEN) { - final String errorMessage; - if (responseInfo.statusCode() == HTTP_UNAUTHORIZED) { - errorMessage = A2AErrorMessages.AUTHENTICATION_FAILED; - } else { - errorMessage = A2AErrorMessages.AUTHORIZATION_FAILED; - } + final int statusCode = responseInfo.statusCode(); + final String errorMessage = statusCode == HTTP_UNAUTHORIZED + ? A2AErrorMessages.AUTHENTICATION_FAILED + : A2AErrorMessages.AUTHORIZATION_FAILED; + final A2AClientHTTPError httpError = new A2AClientHTTPError( + statusCode, errorMessage, null, responseInfo.headers().map()); // Return a body subscriber that immediately signals error return BodySubscribers.fromSubscriber(new Flow.Subscriber>() { @Override public void onSubscribe(Flow.Subscription subscription) { - subscriber.onError(new IOException(errorMessage)); + subscriber.onError(new IOException(errorMessage, httpError)); } @Override @@ -277,9 +276,13 @@ public A2AHttpResponse get() throws IOException, InterruptedException { httpClient.send(request, BodyHandlers.ofString(StandardCharsets.UTF_8)); if (response.statusCode() == HTTP_UNAUTHORIZED) { - throw new IOException(A2AErrorMessages.AUTHENTICATION_FAILED); + throw new IOException(A2AErrorMessages.AUTHENTICATION_FAILED, + new A2AClientHTTPError(HTTP_UNAUTHORIZED, A2AErrorMessages.AUTHENTICATION_FAILED, + null, response.headers().map())); } else if (response.statusCode() == HTTP_FORBIDDEN) { - throw new IOException(A2AErrorMessages.AUTHORIZATION_FAILED); + throw new IOException(A2AErrorMessages.AUTHORIZATION_FAILED, + new A2AClientHTTPError(HTTP_FORBIDDEN, A2AErrorMessages.AUTHORIZATION_FAILED, + null, response.headers().map())); } return new JdkHttpResponse(response); @@ -306,9 +309,13 @@ public A2AHttpResponse delete() throws IOException, InterruptedException { httpClient.send(request, BodyHandlers.ofString(StandardCharsets.UTF_8)); if (response.statusCode() == HTTP_UNAUTHORIZED) { - throw new IOException(A2AErrorMessages.AUTHENTICATION_FAILED); + throw new IOException(A2AErrorMessages.AUTHENTICATION_FAILED, + new A2AClientHTTPError(HTTP_UNAUTHORIZED, A2AErrorMessages.AUTHENTICATION_FAILED, + null, response.headers().map())); } else if (response.statusCode() == HTTP_FORBIDDEN) { - throw new IOException(A2AErrorMessages.AUTHORIZATION_FAILED); + throw new IOException(A2AErrorMessages.AUTHORIZATION_FAILED, + new A2AClientHTTPError(HTTP_FORBIDDEN, A2AErrorMessages.AUTHORIZATION_FAILED, + null, response.headers().map())); } return new JdkHttpResponse(response); @@ -342,9 +349,13 @@ public A2AHttpResponse post() throws IOException, InterruptedException { httpClient.send(request, BodyHandlers.ofString(StandardCharsets.UTF_8)); if (response.statusCode() == HTTP_UNAUTHORIZED) { - throw new IOException(A2AErrorMessages.AUTHENTICATION_FAILED); + throw new IOException(A2AErrorMessages.AUTHENTICATION_FAILED, + new A2AClientHTTPError(HTTP_UNAUTHORIZED, A2AErrorMessages.AUTHENTICATION_FAILED, + null, response.headers().map())); } else if (response.statusCode() == HTTP_FORBIDDEN) { - throw new IOException(A2AErrorMessages.AUTHORIZATION_FAILED); + throw new IOException(A2AErrorMessages.AUTHORIZATION_FAILED, + new A2AClientHTTPError(HTTP_FORBIDDEN, A2AErrorMessages.AUTHORIZATION_FAILED, + null, response.headers().map())); } return new JdkHttpResponse(response); @@ -384,31 +395,7 @@ public String body() { @Override public A2AHttpHeaders headers() { - java.net.http.HttpHeaders jdkHeaders = response.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); - } - - @Override - public Map> toMap() { - Map> map = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); - map.putAll(jdkHeaders.map()); - return Collections.unmodifiableMap(map); - } - }; + return A2AHttpHeaders.of(response.headers().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 98e6602d2..1e4a2e690 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,7 +1,6 @@ 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; @@ -18,7 +17,6 @@ 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; @@ -137,10 +135,9 @@ public void testGetAgentCard_httpErrorThrows() throws Exception { TestHttpClient client = createTestClient(); client.status = 503; A2ACardResolver resolver = A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com/").build(); - A2AClientException error = assertThrows(A2AClientException.class, resolver::getAgentCard); + A2AClientHTTPError error = assertThrows(A2AClientHTTPError.class, resolver::getAgentCard); assertTrue(error.getMessage().contains("503")); - A2AClientHTTPError httpError = assertInstanceOf(A2AClientHTTPError.class, error.getCause()); - assertEquals(503, httpError.getCode()); + assertEquals(503, error.getCode()); } @Test @@ -152,8 +149,8 @@ public void testGetAgentCard_customPath_httpErrorThrows_noFallback() throws Exce .baseUrl("http://example.com") .agentCardPath("/custom/agent.json") .build(); - A2AClientException error = assertThrows(A2AClientException.class, resolver::getAgentCard); - assertInstanceOf(A2AClientHTTPError.class, error.getCause()); + A2AClientHTTPError error = assertThrows(A2AClientHTTPError.class, resolver::getAgentCard); + assertEquals(404, error.getCode()); 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 f853e8e6c..56b17e086 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(), entry.getValue().stream().filter(v -> v != null).toList()); + copy.put(entry.getKey(), List.copyOf(entry.getValue())); } } this.responseHeaders = Collections.unmodifiableMap(copy); From 2043fa67d46814fb90eb4012835021c2b231c2c2 Mon Sep 17 00:00:00 2001 From: Kabir Khan Date: Wed, 10 Jun 2026 12:19:06 +0200 Subject: [PATCH 5/6] Claude self-review --- .../transport/rest/RestErrorMapper.java | 4 --- .../a2aproject/sdk/util/HttpHeaderUtils.java | 35 +++++++++++++++++++ .../sdk/client/http/A2AHttpHeaders.java | 11 ++---- .../sdk/spec/A2AClientHTTPError.java | 11 ++---- 4 files changed, 39 insertions(+), 22 deletions(-) create mode 100644 common/src/main/java/org/a2aproject/sdk/util/HttpHeaderUtils.java 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 ec92c8975..66be96616 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 @@ -104,10 +104,6 @@ public static A2AClientException mapRestError(String body, int code, MapNull keys and null value lists are silently skipped (e.g. the {@code null} + * status-line key from {@link java.net.HttpURLConnection}). + * Individual value lists are defensively copied via {@link List#copyOf(java.util.Collection)}. + * + * @param headers the source header map + * @return an unmodifiable, case-insensitive map + */ + public static Map> copyOfCaseInsensitive(Map> headers) { + TreeMap> copy = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (Map.Entry> entry : headers.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + copy.put(entry.getKey(), List.copyOf(entry.getValue())); + } + } + return Collections.unmodifiableMap(copy); + } +} 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 index 14fdbb20f..f306dff7e 100644 --- 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 @@ -1,10 +1,9 @@ package org.a2aproject.sdk.client.http; -import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.TreeMap; +import org.a2aproject.sdk.util.HttpHeaderUtils; import org.jspecify.annotations.Nullable; /** @@ -90,13 +89,7 @@ public Map> toMap() { * @return an immutable, case-insensitive {@link A2AHttpHeaders} view */ static A2AHttpHeaders of(Map> headers) { - TreeMap> copy = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); - for (Map.Entry> entry : headers.entrySet()) { - if (entry.getKey() != null && entry.getValue() != null) { - copy.put(entry.getKey(), List.copyOf(entry.getValue())); - } - } - Map> immutable = Collections.unmodifiableMap(copy); + Map> immutable = HttpHeaderUtils.copyOfCaseInsensitive(headers); return new A2AHttpHeaders() { @Override public @Nullable String firstValue(String 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..24e6e405c 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/A2AClientHTTPError.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/A2AClientHTTPError.java @@ -1,11 +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.a2aproject.sdk.util.HttpHeaderUtils; import org.jspecify.annotations.Nullable; /** @@ -111,13 +110,7 @@ public A2AClientHTTPError(int code, String message, @Nullable String responseBod 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); + this.responseHeaders = HttpHeaderUtils.copyOfCaseInsensitive(responseHeaders); } /** From cedd619444710a7dcd685e40adb2f84064c76234 Mon Sep 17 00:00:00 2001 From: Kabir Khan Date: Wed, 10 Jun 2026 13:25:33 +0200 Subject: [PATCH 6/6] More Gemini Review --- .../java/org/a2aproject/sdk/util/HttpHeaderUtils.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/common/src/main/java/org/a2aproject/sdk/util/HttpHeaderUtils.java b/common/src/main/java/org/a2aproject/sdk/util/HttpHeaderUtils.java index 2b83edc92..b6b59a492 100644 --- a/common/src/main/java/org/a2aproject/sdk/util/HttpHeaderUtils.java +++ b/common/src/main/java/org/a2aproject/sdk/util/HttpHeaderUtils.java @@ -3,6 +3,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.TreeMap; /** @@ -18,7 +19,8 @@ private HttpHeaderUtils() { * *

Null keys and null value lists are silently skipped (e.g. the {@code null} * status-line key from {@link java.net.HttpURLConnection}). - * Individual value lists are defensively copied via {@link List#copyOf(java.util.Collection)}. + * Null elements within value lists are filtered out, and the remaining values + * are defensively copied. * * @param headers the source header map * @return an unmodifiable, case-insensitive map @@ -27,7 +29,10 @@ public static Map> copyOfCaseInsensitive(Map> copy = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); for (Map.Entry> entry : headers.entrySet()) { if (entry.getKey() != null && entry.getValue() != null) { - copy.put(entry.getKey(), List.copyOf(entry.getValue())); + List cleanValues = entry.getValue().stream() + .filter(Objects::nonNull) + .toList(); + copy.put(entry.getKey(), cleanValues); } } return Collections.unmodifiableMap(copy);