Skip to content

Commit a57c919

Browse files
authored
feat: expose HTTP response headers in A2AHttpResponse and A2AClientHT… (#922)
* 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
1 parent 3e76140 commit a57c919

14 files changed

Lines changed: 647 additions & 72 deletions

File tree

‎client/transport/jsonrpc/src/main/java/org/a2aproject/sdk/client/transport/jsonrpc/JSONRPCTransport.java‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ private String sendPostRequest(String url, PayloadAndHeaders payloadAndHeaders,
328328
if (!response.success()) {
329329
int status = response.status();
330330
String message = "Request failed with HTTP " + status;
331-
throw new A2AClientException(message, new A2AClientHTTPError(status, message, response.body()));
331+
throw new A2AClientException(message, new A2AClientHTTPError(status, message, response.body(), response.headers().toMap()));
332332
}
333333
return response.body();
334334
}

‎client/transport/jsonrpc/src/test/java/org/a2aproject/sdk/client/transport/jsonrpc/JSONRPCTransportTest.java‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,57 @@ public void testHttpErrorExposeStatusCode() throws Exception {
687687
}
688688
}
689689

690+
/**
691+
* Test that HTTP error responses expose response headers via A2AClientHTTPError,
692+
* enabling callers to read headers like Retry-After on 429 responses.
693+
*/
694+
@Test
695+
public void testHttpErrorExposeResponseHeaders() throws Exception {
696+
this.server.when(
697+
request()
698+
.withMethod("POST")
699+
.withPath("/")
700+
)
701+
.respond(
702+
response()
703+
.withStatusCode(429)
704+
.withHeader("Retry-After", "120")
705+
.withHeader("X-RateLimit-Remaining", "0")
706+
.withBody("{\"error\": \"Too Many Requests\"}")
707+
);
708+
709+
JSONRPCTransport client = new JSONRPCTransport("http://localhost:4001");
710+
MessageSendParams params = MessageSendParams.builder()
711+
.message(Message.builder()
712+
.role(Message.Role.ROLE_USER)
713+
.parts(Collections.singletonList(new TextPart("hello")))
714+
.contextId("ctx")
715+
.messageId("msg")
716+
.build())
717+
.build();
718+
719+
try {
720+
client.sendMessage(params, null);
721+
fail("Expected A2AClientException to be thrown");
722+
} catch (A2AClientException e) {
723+
assertInstanceOf(A2AClientHTTPError.class, e.getCause());
724+
A2AClientHTTPError httpError = (A2AClientHTTPError) e.getCause();
725+
assertEquals(429, httpError.getCode());
726+
727+
Map<String, List<String>> headers = httpError.getResponseHeaders();
728+
assertNotNull(headers);
729+
assertFalse(headers.isEmpty());
730+
731+
List<String> retryAfter = headers.getOrDefault("Retry-After", List.of());
732+
assertFalse(retryAfter.isEmpty(), "Expected Retry-After header");
733+
assertEquals("120", retryAfter.get(0));
734+
735+
List<String> rateLimitRemaining = headers.getOrDefault("X-RateLimit-Remaining", List.of());
736+
assertFalse(rateLimitRemaining.isEmpty(), "Expected X-RateLimit-Remaining header");
737+
assertEquals("0", rateLimitRemaining.get(0));
738+
}
739+
}
740+
690741
/**
691742
* Test that VersionNotSupportedError is properly unmarshalled from JSON-RPC error response.
692743
*/

‎client/transport/rest/src/main/java/org/a2aproject/sdk/client/transport/rest/RestErrorMapper.java‎

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package org.a2aproject.sdk.client.transport.rest;
22

33
import java.util.HashMap;
4+
import java.util.List;
45
import java.util.Map;
56
import java.util.logging.Level;
67
import java.util.logging.Logger;
@@ -10,6 +11,7 @@
1011
import org.a2aproject.sdk.jsonrpc.common.json.JsonProcessingException;
1112
import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil;
1213
import org.a2aproject.sdk.spec.A2AClientException;
14+
import org.a2aproject.sdk.spec.A2AClientHTTPError;
1315
import org.a2aproject.sdk.spec.A2AErrorCodes;
1416
import org.a2aproject.sdk.spec.ContentTypeNotSupportedError;
1517
import org.a2aproject.sdk.spec.ExtendedAgentCardNotConfiguredError;
@@ -51,10 +53,14 @@ private record ReasonAndMetadata(String reason, @org.jspecify.annotations.Nullab
5153
);
5254

5355
public static A2AClientException mapRestError(A2AHttpResponse response) {
54-
return RestErrorMapper.mapRestError(response.body(), response.status());
56+
return RestErrorMapper.mapRestError(response.body(), response.status(), response.headers().toMap());
5557
}
5658

5759
public static A2AClientException mapRestError(String body, int code) {
60+
return mapRestError(body, code, Map.of());
61+
}
62+
63+
public static A2AClientException mapRestError(String body, int code, Map<String, List<String>> headers) {
5864
try {
5965
if (body != null && !body.isBlank()) {
6066
JsonObject node = JsonUtil.fromJson(body, JsonObject.class);
@@ -64,26 +70,40 @@ public static A2AClientException mapRestError(String body, int code) {
6470
String errorMessage = errorObj.has("message") ? errorObj.get("message").getAsString() : "";
6571
ReasonAndMetadata reasonAndMetadata = extractReasonAndMetadata(errorObj);
6672
if (reasonAndMetadata != null) {
67-
return mapRestErrorByReason(reasonAndMetadata.reason(), errorMessage, reasonAndMetadata.metadata());
73+
A2AClientException known = mapRestErrorByReason(reasonAndMetadata.reason(), errorMessage, reasonAndMetadata.metadata());
74+
if (known.getCause() != null) {
75+
return known;
76+
}
77+
// Unrecognized reason — include HTTP status and headers
78+
String msg = errorMessage.isEmpty() ? "Request failed with HTTP " + code : errorMessage;
79+
return new A2AClientException(msg, new A2AClientHTTPError(code, msg, body, headers));
6880
}
69-
return new A2AClientException(errorMessage);
81+
return new A2AClientException(errorMessage,
82+
new A2AClientHTTPError(code, errorMessage, body, headers));
7083
}
7184
// Legacy format (error class name, message)
7285
String className = node.has("error") ? node.get("error").getAsString() : "";
7386
String errorMessage = node.has("message") ? node.get("message").getAsString() : "";
74-
return mapRestErrorByClassName(className, errorMessage, code);
87+
if (!className.isEmpty()) {
88+
A2AClientException known = mapRestErrorByClassName(className, errorMessage, code);
89+
if (known.getCause() != null) {
90+
return known;
91+
}
92+
}
93+
// Unknown or empty class name — include HTTP status and headers
94+
String msg = errorMessage.isEmpty() ? "Request failed with HTTP " + code : errorMessage;
95+
return new A2AClientException(msg, new A2AClientHTTPError(code, msg, body, headers));
7596
}
76-
return mapRestErrorByClassName("", "", code);
97+
String message = "Request failed with HTTP " + code;
98+
return new A2AClientException(message,
99+
new A2AClientHTTPError(code, message, body, headers));
77100
} catch (JsonProcessingException ex) {
78101
Logger.getLogger(RestErrorMapper.class.getName()).log(Level.SEVERE, null, ex);
79-
return new A2AClientException("Failed to parse error response: " + ex.getMessage());
102+
String message = "Failed to parse error response: " + ex.getMessage();
103+
return new A2AClientException(message, new A2AClientHTTPError(code, message, body, headers));
80104
}
81105
}
82106

83-
public static A2AClientException mapRestError(String className, String errorMessage, int code) {
84-
return mapRestErrorByClassName(className, errorMessage, code);
85-
}
86-
87107
/**
88108
* Extracts the "reason" and "metadata" fields from the first entry in the "details" array.
89109
*/
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package org.a2aproject.sdk.client.transport.rest;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
5+
import static org.junit.jupiter.api.Assertions.assertNotNull;
6+
import static org.junit.jupiter.api.Assertions.assertTrue;
7+
8+
import java.util.List;
9+
import java.util.Map;
10+
11+
import org.a2aproject.sdk.client.http.A2AHttpHeaders;
12+
import org.a2aproject.sdk.client.http.A2AHttpResponse;
13+
import org.a2aproject.sdk.spec.A2AClientException;
14+
import org.a2aproject.sdk.spec.A2AClientHTTPError;
15+
import org.a2aproject.sdk.spec.TaskNotFoundError;
16+
import org.junit.jupiter.api.Test;
17+
18+
public class RestErrorMapperTest {
19+
20+
@Test
21+
public void testEmptyBodyFallbackIncludesHeaders() {
22+
Map<String, List<String>> headers = Map.of("Retry-After", List.of("60"));
23+
A2AClientException ex = RestErrorMapper.mapRestError("", 429, headers);
24+
25+
assertInstanceOf(A2AClientHTTPError.class, ex.getCause());
26+
A2AClientHTTPError httpError = (A2AClientHTTPError) ex.getCause();
27+
assertEquals(429, httpError.getCode());
28+
assertEquals(List.of("60"), httpError.getResponseHeaders().get("Retry-After"));
29+
}
30+
31+
@Test
32+
public void testNullBodyFallbackIncludesHeaders() {
33+
Map<String, List<String>> headers = Map.of("WWW-Authenticate", List.of("Bearer"));
34+
A2AClientException ex = RestErrorMapper.mapRestError(null, 401, headers);
35+
36+
assertInstanceOf(A2AClientHTTPError.class, ex.getCause());
37+
A2AClientHTTPError httpError = (A2AClientHTTPError) ex.getCause();
38+
assertEquals(401, httpError.getCode());
39+
assertEquals(List.of("Bearer"), httpError.getResponseHeaders().get("WWW-Authenticate"));
40+
}
41+
42+
@Test
43+
public void testUnrecognizedGoogleErrorFormatIncludesHeaders() {
44+
String body = "{\"error\": {\"code\": 503, \"message\": \"Service Unavailable\"}}";
45+
Map<String, List<String>> headers = Map.of("Retry-After", List.of("30"));
46+
A2AClientException ex = RestErrorMapper.mapRestError(body, 503, headers);
47+
48+
assertInstanceOf(A2AClientHTTPError.class, ex.getCause());
49+
A2AClientHTTPError httpError = (A2AClientHTTPError) ex.getCause();
50+
assertEquals(503, httpError.getCode());
51+
assertEquals(List.of("30"), httpError.getResponseHeaders().get("Retry-After"));
52+
}
53+
54+
@Test
55+
public void testRecognizedA2AErrorDoesNotWrapInHttpError() {
56+
String body = "{\"error\": {\"code\": 404, \"status\": \"NOT_FOUND\", \"message\": \"Task not found\", " +
57+
"\"details\": [{\"reason\": \"TASK_NOT_FOUND\"}]}}";
58+
A2AClientException ex = RestErrorMapper.mapRestError(body, 404, Map.of());
59+
60+
assertInstanceOf(TaskNotFoundError.class, ex.getCause());
61+
}
62+
63+
@Test
64+
public void testMapRestErrorFromA2AHttpResponse() {
65+
Map<String, List<String>> headerMap = Map.of("X-Custom", List.of("value"));
66+
A2AHttpResponse response = new A2AHttpResponse() {
67+
@Override
68+
public int status() {
69+
return 500;
70+
}
71+
72+
@Override
73+
public boolean success() {
74+
return false;
75+
}
76+
77+
@Override
78+
public String body() {
79+
return "";
80+
}
81+
82+
@Override
83+
public A2AHttpHeaders headers() {
84+
return new A2AHttpHeaders() {
85+
@Override
86+
public String firstValue(String name) {
87+
List<String> values = headerMap.get(name);
88+
return values != null && !values.isEmpty() ? values.get(0) : null;
89+
}
90+
91+
@Override
92+
public List<String> allValues(String name) {
93+
return headerMap.getOrDefault(name, List.of());
94+
}
95+
96+
@Override
97+
public Map<String, List<String>> toMap() {
98+
return headerMap;
99+
}
100+
};
101+
}
102+
};
103+
104+
A2AClientException ex = RestErrorMapper.mapRestError(response);
105+
assertInstanceOf(A2AClientHTTPError.class, ex.getCause());
106+
A2AClientHTTPError httpError = (A2AClientHTTPError) ex.getCause();
107+
assertEquals(500, httpError.getCode());
108+
assertEquals(List.of("value"), httpError.getResponseHeaders().get("X-Custom"));
109+
}
110+
111+
@Test
112+
public void testHeaderLookupIsCaseInsensitive() {
113+
Map<String, List<String>> headers = Map.of("Retry-After", List.of("60"));
114+
A2AClientException ex = RestErrorMapper.mapRestError("", 429, headers);
115+
116+
A2AClientHTTPError httpError = (A2AClientHTTPError) ex.getCause();
117+
assertEquals(List.of("60"), httpError.getResponseHeaders().get("retry-after"));
118+
assertEquals(List.of("60"), httpError.getResponseHeaders().get("RETRY-AFTER"));
119+
}
120+
121+
@Test
122+
public void testTwoArgOverloadDefaultsToEmptyHeaders() {
123+
A2AClientException ex = RestErrorMapper.mapRestError("", 500);
124+
125+
assertInstanceOf(A2AClientHTTPError.class, ex.getCause());
126+
A2AClientHTTPError httpError = (A2AClientHTTPError) ex.getCause();
127+
assertNotNull(httpError.getResponseHeaders());
128+
assertTrue(httpError.getResponseHeaders().isEmpty());
129+
}
130+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package org.a2aproject.sdk.util;
2+
3+
import java.util.Collections;
4+
import java.util.List;
5+
import java.util.Map;
6+
import java.util.Objects;
7+
import java.util.TreeMap;
8+
9+
/**
10+
* Utilities for creating case-insensitive, immutable snapshots of HTTP header maps.
11+
*/
12+
public final class HttpHeaderUtils {
13+
14+
private HttpHeaderUtils() {
15+
}
16+
17+
/**
18+
* Creates an unmodifiable, case-insensitive copy of the given header map.
19+
*
20+
* <p>Null keys and null value lists are silently skipped (e.g. the {@code null}
21+
* status-line key from {@link java.net.HttpURLConnection}).
22+
* Null elements within value lists are filtered out, and the remaining values
23+
* are defensively copied.
24+
*
25+
* @param headers the source header map
26+
* @return an unmodifiable, case-insensitive map
27+
*/
28+
public static Map<String, List<String>> copyOfCaseInsensitive(Map<String, List<String>> headers) {
29+
TreeMap<String, List<String>> copy = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
30+
for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
31+
if (entry.getKey() != null && entry.getValue() != null) {
32+
List<String> cleanValues = entry.getValue().stream()
33+
.filter(Objects::nonNull)
34+
.toList();
35+
copy.put(entry.getKey(), cleanValues);
36+
}
37+
}
38+
return Collections.unmodifiableMap(copy);
39+
}
40+
}

0 commit comments

Comments
 (0)