Skip to content
Merged
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()));
Comment thread
kabir marked this conversation as resolved.
}
return response.body();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,57 @@ 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", List.of());
assertFalse(retryAfter.isEmpty(), "Expected Retry-After header");
assertEquals("120", retryAfter.get(0));

List<String> rateLimitRemaining = 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());
Comment thread
kabir marked this conversation as resolved.
}

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 @@ -64,26 +70,40 @@ public static A2AClientException mapRestError(String body, int code) {
String errorMessage = errorObj.has("message") ? errorObj.get("message").getAsString() : "";
ReasonAndMetadata reasonAndMetadata = extractReasonAndMetadata(errorObj);
if (reasonAndMetadata != null) {
return mapRestErrorByReason(reasonAndMetadata.reason(), errorMessage, reasonAndMetadata.metadata());
A2AClientException known = mapRestErrorByReason(reasonAndMetadata.reason(), errorMessage, reasonAndMetadata.metadata());
if (known.getCause() != null) {
return known;
}
// Unrecognized reason — include HTTP status and headers
String msg = errorMessage.isEmpty() ? "Request failed with HTTP " + code : errorMessage;
return new A2AClientException(msg, new A2AClientHTTPError(code, msg, body, headers));
}
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);
if (!className.isEmpty()) {
A2AClientException known = mapRestErrorByClassName(className, errorMessage, code);
if (known.getCause() != null) {
return known;
}
}
// Unknown or empty class name — include HTTP status and headers
String msg = errorMessage.isEmpty() ? "Request failed with HTTP " + code : errorMessage;
return new A2AClientException(msg, new A2AClientHTTPError(code, msg, body, headers));
}
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());
String message = "Failed to parse error response: " + ex.getMessage();
return new A2AClientException(message, new A2AClientHTTPError(code, message, body, headers));
}
}

public static A2AClientException mapRestError(String className, String errorMessage, int code) {
return mapRestErrorByClassName(className, errorMessage, code);
}

/**
* Extracts the "reason" and "metadata" fields from the first entry in the "details" array.
*/
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());
}
}
40 changes: 40 additions & 0 deletions common/src/main/java/org/a2aproject/sdk/util/HttpHeaderUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package org.a2aproject.sdk.util;

import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;

/**
* Utilities for creating case-insensitive, immutable snapshots of HTTP header maps.
*/
public final class HttpHeaderUtils {

private HttpHeaderUtils() {
}

/**
* Creates an unmodifiable, case-insensitive copy of the given header map.
*
* <p>Null keys and null value lists are silently skipped (e.g. the {@code null}
* status-line key from {@link java.net.HttpURLConnection}).
* 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
*/
public static Map<String, List<String>> copyOfCaseInsensitive(Map<String, List<String>> headers) {
TreeMap<String, List<String>> copy = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
if (entry.getKey() != null && entry.getValue() != null) {
List<String> cleanValues = entry.getValue().stream()
.filter(Objects::nonNull)
.toList();
copy.put(entry.getKey(), cleanValues);
}
}
return Collections.unmodifiableMap(copy);
}
Comment thread
kabir marked this conversation as resolved.
}
Loading
Loading