Skip to content

Commit 26cb3ad

Browse files
authored
fix: update RestErrorMapper to handle RFC 7807 Problem Details error format (#737)
This covers the core fix: the client's RestErrorMapper now reads the type URI from Problem Details responses (introduced by the server-side changes on this branch) instead of the old error/message fields, with backward compatibility for the legacy format. Fixes #727 Fixes #730 🦕 --------- Signed-off-by: Emmanuel Hugonnet <ehugonne@redhat.com>
1 parent a73125f commit 26cb3ad

File tree

8 files changed

+745
-120
lines changed

8 files changed

+745
-120
lines changed

client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestErrorMapper.java

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,18 +36,63 @@ public static A2AClientException mapRestError(String body, int code) {
3636
try {
3737
if (body != null && !body.isBlank()) {
3838
JsonObject node = JsonUtil.fromJson(body, JsonObject.class);
39+
// Support RFC 7807 Problem Details format (type, title, details, status)
40+
if (node.has("type")) {
41+
String type = node.get("type").getAsString();
42+
String errorMessage = node.has("title") ? node.get("title").getAsString() : "";
43+
return mapRestErrorByType(type, errorMessage, code);
44+
}
45+
// Legacy format (error, message)
3946
String className = node.has("error") ? node.get("error").getAsString() : "";
4047
String errorMessage = node.has("message") ? node.get("message").getAsString() : "";
41-
return mapRestError(className, errorMessage, code);
48+
return mapRestErrorByClassName(className, errorMessage, code);
4249
}
43-
return mapRestError("", "", code);
50+
return mapRestErrorByClassName("", "", code);
4451
} catch (JsonProcessingException ex) {
4552
Logger.getLogger(RestErrorMapper.class.getName()).log(Level.SEVERE, null, ex);
4653
return new A2AClientException("Failed to parse error response: " + ex.getMessage());
4754
}
4855
}
4956

5057
public static A2AClientException mapRestError(String className, String errorMessage, int code) {
58+
return mapRestErrorByClassName(className, errorMessage, code);
59+
}
60+
61+
/**
62+
* Maps RFC 7807 Problem Details error type URIs to A2A exceptions.
63+
* <p>
64+
* Note: Error constructors receive null for code and data parameters because:
65+
* <ul>
66+
* <li>Error codes are defaulted by each error class (e.g., -32007 for ExtendedAgentCardNotConfiguredError)</li>
67+
* <li>The message comes from the RFC 7807 "title" field</li>
68+
* <li>The data field is optional and not included in basic RFC 7807 responses</li>
69+
* </ul>
70+
*
71+
* @param type the RFC 7807 error type URI (e.g., "https://a2a-protocol.org/errors/task-not-found")
72+
* @param errorMessage the error message from the "title" field
73+
* @param code the HTTP status code (currently unused, kept for consistency)
74+
* @return an A2AClientException wrapping the appropriate A2A error
75+
*/
76+
private static A2AClientException mapRestErrorByType(String type, String errorMessage, int code) {
77+
return switch (type) {
78+
case "https://a2a-protocol.org/errors/task-not-found" -> new A2AClientException(errorMessage, new TaskNotFoundError());
79+
case "https://a2a-protocol.org/errors/extended-agent-card-not-configured" -> new A2AClientException(errorMessage, new ExtendedAgentCardNotConfiguredError(null, errorMessage, null));
80+
case "https://a2a-protocol.org/errors/content-type-not-supported" -> new A2AClientException(errorMessage, new ContentTypeNotSupportedError(null, errorMessage, null));
81+
case "https://a2a-protocol.org/errors/internal-error" -> new A2AClientException(errorMessage, new InternalError(errorMessage));
82+
case "https://a2a-protocol.org/errors/invalid-agent-response" -> new A2AClientException(errorMessage, new InvalidAgentResponseError(null, errorMessage, null));
83+
case "https://a2a-protocol.org/errors/invalid-params" -> new A2AClientException(errorMessage, new InvalidParamsError());
84+
case "https://a2a-protocol.org/errors/invalid-request" -> new A2AClientException(errorMessage, new InvalidRequestError());
85+
case "https://a2a-protocol.org/errors/method-not-found" -> new A2AClientException(errorMessage, new MethodNotFoundError());
86+
case "https://a2a-protocol.org/errors/push-notification-not-supported" -> new A2AClientException(errorMessage, new PushNotificationNotSupportedError());
87+
case "https://a2a-protocol.org/errors/task-not-cancelable" -> new A2AClientException(errorMessage, new TaskNotCancelableError());
88+
case "https://a2a-protocol.org/errors/unsupported-operation" -> new A2AClientException(errorMessage, new UnsupportedOperationError());
89+
case "https://a2a-protocol.org/errors/extension-support-required" -> new A2AClientException(errorMessage, new ExtensionSupportRequiredError(null, errorMessage, null));
90+
case "https://a2a-protocol.org/errors/version-not-supported" -> new A2AClientException(errorMessage, new VersionNotSupportedError(null, errorMessage, null));
91+
default -> new A2AClientException(errorMessage);
92+
};
93+
}
94+
95+
private static A2AClientException mapRestErrorByClassName(String className, String errorMessage, int code) {
5196
return switch (className) {
5297
case "io.a2a.spec.TaskNotFoundError" -> new A2AClientException(errorMessage, new TaskNotFoundError());
5398
case "io.a2a.spec.ExtendedCardNotConfiguredError" -> new A2AClientException(errorMessage, new ExtendedAgentCardNotConfiguredError(null, errorMessage, null));

reference/rest/src/main/java/io/a2a/server/rest/quarkus/A2AServerRoutes.java

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import io.a2a.server.extensions.A2AExtensions;
3232
import io.a2a.server.util.async.Internal;
3333
import io.a2a.spec.A2AError;
34+
import io.a2a.spec.ContentTypeNotSupportedError;
3435
import io.a2a.spec.InternalError;
3536
import io.a2a.spec.InvalidParamsError;
3637
import io.a2a.spec.MethodNotFoundError;
@@ -165,8 +166,11 @@ public class A2AServerRoutes {
165166
* @param body the JSON request body
166167
* @param rc the Vert.x routing context
167168
*/
168-
@Route(regex = "^\\/(?<tenant>[^\\/]*\\/?)message:send$", order = 1, methods = {Route.HttpMethod.POST}, consumes = {APPLICATION_JSON}, type = Route.HandlerType.BLOCKING)
169+
@Route(regex = "^\\/(?<tenant>[^\\/]*\\/?)message:send$", order = 1, methods = {Route.HttpMethod.POST}, type = Route.HandlerType.BLOCKING)
169170
public void sendMessage(@Body String body, RoutingContext rc) {
171+
if(!validateContentType(rc)) {
172+
return;
173+
}
170174
ServerCallContext context = createCallContext(rc, SEND_MESSAGE_METHOD);
171175
HTTPRestResponse response = null;
172176
try {
@@ -198,8 +202,11 @@ public void sendMessage(@Body String body, RoutingContext rc) {
198202
* @param body the JSON request body
199203
* @param rc the Vert.x routing context
200204
*/
201-
@Route(regex = "^\\/(?<tenant>[^\\/]*\\/?)message:stream$", order = 1, methods = {Route.HttpMethod.POST}, consumes = {APPLICATION_JSON}, type = Route.HandlerType.BLOCKING)
205+
@Route(regex = "^\\/(?<tenant>[^\\/]*\\/?)message:stream$", order = 1, methods = {Route.HttpMethod.POST}, type = Route.HandlerType.BLOCKING)
202206
public void sendMessageStreaming(@Body String body, RoutingContext rc) {
207+
if(!validateContentType(rc)) {
208+
return;
209+
}
203210
ServerCallContext context = createCallContext(rc, SEND_STREAMING_MESSAGE_METHOD);
204211
HTTPRestStreamingResponse streamingResponse = null;
205212
HTTPRestResponse error = null;
@@ -339,6 +346,9 @@ public void getTask(RoutingContext rc) {
339346
*/
340347
@Route(regex = "^\\/(?<tenant>[^\\/]*\\/?)tasks\\/(?<taskId>[^/]+):cancel$", order = 1, methods = {Route.HttpMethod.POST}, type = Route.HandlerType.BLOCKING)
341348
public void cancelTask(@Body String body, RoutingContext rc) {
349+
if (!validateContentType(rc)) {
350+
return;
351+
}
342352
String taskId = rc.pathParam("taskId");
343353
ServerCallContext context = createCallContext(rc, CANCEL_TASK_METHOD);
344354
HTTPRestResponse response = null;
@@ -443,8 +453,11 @@ public void subscribeToTask(RoutingContext rc) {
443453
* @param body the JSON request body with notification configuration
444454
* @param rc the Vert.x routing context (taskId extracted from path)
445455
*/
446-
@Route(regex = "^\\/(?<tenant>[^\\/]*\\/?)tasks\\/(?<taskId>[^/]+)\\/pushNotificationConfigs$", order = 1, methods = {Route.HttpMethod.POST}, consumes = {APPLICATION_JSON}, type = Route.HandlerType.BLOCKING)
447-
public void CreateTaskPushNotificationConfiguration(@Body String body, RoutingContext rc) {
456+
@Route(regex = "^\\/(?<tenant>[^\\/]*\\/?)tasks\\/(?<taskId>[^/]+)\\/pushNotificationConfigs$", order = 1, methods = {Route.HttpMethod.POST}, type = Route.HandlerType.BLOCKING)
457+
public void createTaskPushNotificationConfiguration(@Body String body, RoutingContext rc) {
458+
if(!validateContentType(rc)) {
459+
return;
460+
}
448461
String taskId = rc.pathParam("taskId");
449462
ServerCallContext context = createCallContext(rc, SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD);
450463
HTTPRestResponse response = null;
@@ -597,6 +610,20 @@ private String extractTenant(RoutingContext rc) {
597610
return tenantPath;
598611
}
599612

613+
/**
614+
* Check if the request content type is application/json.
615+
* @param rc
616+
* @return true if the content type is application/json - false otherwise.
617+
*/
618+
private boolean validateContentType(RoutingContext rc) {
619+
String contentType = rc.request().getHeader(CONTENT_TYPE);
620+
if (contentType == null || !contentType.trim().startsWith(APPLICATION_JSON)) {
621+
sendResponse(rc, jsonRestHandler.createErrorResponse(new ContentTypeNotSupportedError(null, null, null)));
622+
return false;
623+
}
624+
return true;
625+
}
626+
600627
/**
601628
* Retrieves the public agent card for agent discovery.
602629
*

reference/rest/src/test/java/io/a2a/server/rest/quarkus/A2AServerRoutesTest.java

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import static org.mockito.ArgumentMatchers.anyString;
1919
import static org.mockito.ArgumentMatchers.eq;
2020
import static org.mockito.Mockito.mock;
21+
import static org.mockito.Mockito.never;
2122
import static org.mockito.Mockito.verify;
2223
import static org.mockito.Mockito.when;
2324

@@ -26,6 +27,7 @@
2627
import jakarta.enterprise.inject.Instance;
2728

2829
import io.a2a.server.ServerCallContext;
30+
import io.a2a.spec.ContentTypeNotSupportedError;
2931
import io.a2a.transport.rest.handler.RestHandler;
3032
import io.a2a.transport.rest.handler.RestHandler.HTTPRestResponse;
3133
import io.vertx.core.Future;
@@ -80,6 +82,7 @@ public void setUp() {
8082
when(mockRoutingContext.user()).thenReturn(null);
8183
when(mockRequest.headers()).thenReturn(mockHeaders);
8284
when(mockRequest.params()).thenReturn(mockParams);
85+
when(mockRequest.getHeader(any(CharSequence.class))).thenReturn("application/json");
8386
when(mockRoutingContext.body()).thenReturn(mockRequestBody);
8487
when(mockRequestBody.asString()).thenReturn("{}");
8588
when(mockResponse.setStatusCode(any(Integer.class))).thenReturn(mockResponse);
@@ -358,7 +361,7 @@ public void testCreateTaskPushNotificationConfiguration_MethodNameSetInContext()
358361
ArgumentCaptor<ServerCallContext> contextCaptor = ArgumentCaptor.forClass(ServerCallContext.class);
359362

360363
// Act
361-
routes.CreateTaskPushNotificationConfiguration("{}", mockRoutingContext);
364+
routes.createTaskPushNotificationConfiguration("{}", mockRoutingContext);
362365

363366
// Assert
364367
verify(mockRestHandler).createTaskPushNotificationConfiguration(contextCaptor.capture(), anyString(), eq("{}"), eq("task123"));
@@ -438,6 +441,61 @@ public void testDeleteTaskPushNotificationConfiguration_MethodNameSetInContext()
438441
assertEquals(DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD, capturedContext.getState().get(METHOD_NAME_KEY));
439442
}
440443

444+
@Test
445+
public void testSendMessage_UnsupportedContentType_ReturnsContentTypeNotSupportedError() {
446+
// Arrange
447+
HTTPRestResponse mockErrorResponse = mock(HTTPRestResponse.class);
448+
when(mockErrorResponse.getStatusCode()).thenReturn(415);
449+
when(mockErrorResponse.getContentType()).thenReturn("application/problem+json");
450+
when(mockErrorResponse.getBody()).thenReturn("{\"type\":\"https://a2a-protocol.org/errors/content-type-not-supported\"}");
451+
when(mockRestHandler.createErrorResponse(any(ContentTypeNotSupportedError.class))).thenReturn(mockErrorResponse);
452+
when(mockRequest.getHeader(any(CharSequence.class))).thenReturn("text/plain");
453+
454+
// Act
455+
routes.sendMessage("{}", mockRoutingContext);
456+
457+
// Assert: createErrorResponse called with ContentTypeNotSupportedError, sendMessage NOT called
458+
verify(mockRestHandler).createErrorResponse(any(ContentTypeNotSupportedError.class));
459+
verify(mockRestHandler, never()).sendMessage(any(ServerCallContext.class), anyString(), anyString());
460+
}
461+
462+
@Test
463+
public void testSendMessageStreaming_UnsupportedContentType_ReturnsContentTypeNotSupportedError() {
464+
// Arrange
465+
HTTPRestResponse mockErrorResponse = mock(HTTPRestResponse.class);
466+
when(mockErrorResponse.getStatusCode()).thenReturn(415);
467+
when(mockErrorResponse.getContentType()).thenReturn("application/problem+json");
468+
when(mockErrorResponse.getBody()).thenReturn("{\"type\":\"https://a2a-protocol.org/errors/content-type-not-supported\"}");
469+
when(mockRestHandler.createErrorResponse(any(ContentTypeNotSupportedError.class))).thenReturn(mockErrorResponse);
470+
when(mockRequest.getHeader(any(CharSequence.class))).thenReturn("text/plain");
471+
472+
// Act
473+
routes.sendMessageStreaming("{}", mockRoutingContext);
474+
475+
// Assert: createErrorResponse called with ContentTypeNotSupportedError, sendStreamingMessage NOT called
476+
verify(mockRestHandler).createErrorResponse(any(ContentTypeNotSupportedError.class));
477+
verify(mockRestHandler, never()).sendStreamingMessage(any(ServerCallContext.class), anyString(), anyString());
478+
}
479+
480+
@Test
481+
public void testSendMessage_UnsupportedProtocolVersion_ReturnsVersionNotSupportedError() {
482+
// Arrange: content type is OK, but RestHandler returns a VersionNotSupportedError response
483+
HTTPRestResponse mockErrorResponse = mock(HTTPRestResponse.class);
484+
when(mockErrorResponse.getStatusCode()).thenReturn(400);
485+
when(mockErrorResponse.getContentType()).thenReturn("application/problem+json");
486+
when(mockErrorResponse.getBody()).thenReturn("{\"type\":\"https://a2a-protocol.org/errors/version-not-supported\"}");
487+
when(mockRequest.getHeader(any(CharSequence.class))).thenReturn("application/json");
488+
when(mockRestHandler.sendMessage(any(ServerCallContext.class), anyString(), anyString()))
489+
.thenReturn(mockErrorResponse);
490+
491+
// Act
492+
routes.sendMessage("{}", mockRoutingContext);
493+
494+
// Assert: sendMessage was called and error response forwarded
495+
verify(mockRestHandler).sendMessage(any(ServerCallContext.class), anyString(), eq("{}"));
496+
verify(mockResponse).setStatusCode(400);
497+
}
498+
441499
/**
442500
* Helper method to set a field via reflection for testing purposes.
443501
*/

reference/rest/src/test/java/io/a2a/server/rest/quarkus/QuarkusA2ARestTest.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,39 @@ protected String getTransportUrl() {
3030
@Override
3131
protected abstract void configureTransport(ClientBuilder builder);
3232

33+
@Test
34+
public void testSendMessageWithUnsupportedContentType() throws Exception {
35+
HttpClient client = HttpClient.newBuilder()
36+
.version(HttpClient.Version.HTTP_2)
37+
.build();
38+
HttpRequest request = HttpRequest.newBuilder()
39+
.uri(URI.create("http://localhost:" + serverPort + "/message:send"))
40+
.POST(HttpRequest.BodyPublishers.ofString("test body"))
41+
.header("Content-Type", "text/plain")
42+
.build();
43+
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
44+
Assertions.assertEquals(415, response.statusCode());
45+
Assertions.assertTrue(response.body().contains("content-type-not-supported"),
46+
"Expected content-type-not-supported in response body: " + response.body());
47+
}
48+
49+
@Test
50+
public void testSendMessageWithUnsupportedProtocolVersion() throws Exception {
51+
HttpClient client = HttpClient.newBuilder()
52+
.version(HttpClient.Version.HTTP_2)
53+
.build();
54+
HttpRequest request = HttpRequest.newBuilder()
55+
.uri(URI.create("http://localhost:" + serverPort + "/message:send"))
56+
.POST(HttpRequest.BodyPublishers.ofString("{}"))
57+
.header("Content-Type", APPLICATION_JSON)
58+
.header("A2A-Version", "0.4.0")
59+
.build();
60+
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
61+
Assertions.assertEquals(400, response.statusCode());
62+
Assertions.assertTrue(response.body().contains("version-not-supported"),
63+
"Expected version-not-supported in response body: " + response.body());
64+
}
65+
3366
@Test
3467
public void testMethodNotFound() throws Exception {
3568
// Create the client

spec/src/main/java/io/a2a/spec/ContentTypeNotSupportedError.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@
4040
*/
4141
public class ContentTypeNotSupportedError extends A2AProtocolError {
4242

43+
public ContentTypeNotSupportedError() {
44+
this(null, null, null);
45+
}
4346
/**
4447
* Constructs a content type not supported error.
4548
*

0 commit comments

Comments
 (0)