Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, List<String>> headers = httpError.getResponseHeaders();
assertNotNull(headers);
assertFalse(headers.isEmpty());

List<String> retryAfter = headers.getOrDefault("Retry-After",
headers.getOrDefault("retry-after", List.of()));
assertFalse(retryAfter.isEmpty(), "Expected Retry-After header");
assertEquals("120", retryAfter.get(0));

List<String> 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.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<String, List<String>> headers) {
try {
if (body != null && !body.isBlank()) {
JsonObject node = JsonUtil.fromJson(body, JsonObject.class);
Expand All @@ -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());
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, List<String>> 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<String, List<String>> 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<String, List<String>> 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<String, List<String>> 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<String> values = headerMap.get(name);
return values != null && !values.isEmpty() ? values.get(0) : null;
}

@Override
public List<String> allValues(String name) {
return headerMap.getOrDefault(name, List.of());
}

@Override
public Map<String, List<String>> 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<String, List<String>> 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -305,10 +311,52 @@ public A2AHttpResponse delete() throws IOException {
}
}

private record AndroidHttpResponse(int status, String body) implements A2AHttpResponse {
private static A2AHttpHeaders fromConnectionHeaders(@Nullable Map<String, List<String>> headerFields) {
Map<String, List<String>> filtered = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
if (headerFields != null) {
for (Map.Entry<String, List<String>> entry : headerFields.entrySet()) {
if (entry.getKey() != null && entry.getValue() != null) {
filtered.put(entry.getKey(), Collections.unmodifiableList(entry.getValue()));
}
}
}
Map<String, List<String>> immutable = Collections.unmodifiableMap(filtered);

return new A2AHttpHeaders() {
@Override
public @Nullable String firstValue(String name) {
if (name == null) {
return null;
}
List<String> values = immutable.get(name);
return (values != null && !values.isEmpty()) ? values.get(0) : null;
}

@Override
public List<String> allValues(String name) {
if (name == null) {
return List.of();
}
List<String> values = immutable.get(name);
return values != null ? values : List.of();
}
Comment thread
kabir marked this conversation as resolved.

@Override
public Map<String, List<String>> 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;
}
}
}
Loading
Loading