Skip to content

Commit 6573807

Browse files
committed
fix: Updating JSONRPC error format to comply with JSONRPC-ERR-003.
- Introduce a shared ErrorDetail record in the spec module - Update JSONRPCUtils to serialize error.data as a JSON array - Update the deserializer to accept both object and array forms. Signed-off-by: Emmanuel Hugonnet <ehugonne@redhat.com>
1 parent 971569a commit 6573807

4 files changed

Lines changed: 143 additions & 16 deletions

File tree

spec-grpc/src/main/java/org/a2aproject/sdk/grpc/utils/JSONRPCUtils.java

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package org.a2aproject.sdk.grpc.utils;
22

33
import org.a2aproject.sdk.spec.A2AErrorCodes;
4+
45
import static org.a2aproject.sdk.spec.A2AMethods.CANCEL_TASK_METHOD;
56
import static org.a2aproject.sdk.spec.A2AMethods.GET_EXTENDED_AGENT_CARD_METHOD;
67
import static org.a2aproject.sdk.spec.A2AMethods.SEND_STREAMING_MESSAGE_METHOD;
78

89
import java.io.IOException;
910
import java.io.StringWriter;
11+
import java.util.List;
1012
import java.util.Map;
1113
import java.util.UUID;
1214
import java.util.logging.Level;
@@ -66,6 +68,7 @@
6668
import org.a2aproject.sdk.spec.TaskNotFoundError;
6769
import org.a2aproject.sdk.spec.UnsupportedOperationError;
6870
import org.a2aproject.sdk.spec.VersionNotSupportedError;
71+
import org.a2aproject.sdk.util.ErrorDetail;
6972
import org.a2aproject.sdk.util.Utils;
7073
import org.jspecify.annotations.Nullable;
7174

@@ -393,8 +396,16 @@ private static A2AError processError(JsonObject error) {
393396
String message = error.has("message") ? error.get("message").getAsString() : null;
394397
Integer code = error.has("code") ? error.get("code").getAsInt() : null;
395398
Map<String, Object> details = null;
396-
if (error.has("data") && error.get("data").isJsonObject()) {
397-
details =GSON.fromJson(error.get("data"), Map.class);
399+
if (error.has("data")) {
400+
JsonElement data = error.get("data");
401+
if (data.isJsonObject()) {
402+
details = GSON.fromJson(data, Map.class);
403+
} else if (data.isJsonArray() && !data.getAsJsonArray().isEmpty()) {
404+
JsonElement first = data.getAsJsonArray().get(0);
405+
if (first.isJsonObject()) {
406+
details = GSON.fromJson(first.getAsJsonObject(), Map.class);
407+
}
408+
}
398409
}
399410
if (code != null) {
400411
A2AErrorCodes errorCode = A2AErrorCodes.fromCode(code);
@@ -606,10 +617,10 @@ public static String toJsonRPCErrorResponse(Object requestId, A2AError error) {
606617
output.beginObject();
607618
output.name("code").value(error.getCode());
608619
output.name("message").value(error.getMessage());
609-
if (!error.getDetails().isEmpty()) {
610-
output.name("data");
611-
GSON.toJson(error.getDetails(), Map.class, output);
612-
}
620+
A2AErrorCodes a2aErrorCode = A2AErrorCodes.fromCode(error.getCode());
621+
String reason = a2aErrorCode != null ? a2aErrorCode.name() : A2AErrorCodes.INTERNAL.name();
622+
output.name("data");
623+
GSON.toJson(List.of(ErrorDetail.of(reason, error.getDetails())), List.class, output);
613624
output.endObject();
614625
output.endObject();
615626
return result.toString();

spec-grpc/src/test/java/org/a2aproject/sdk/grpc/utils/JSONRPCUtilsTest.java

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@
77
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
88
import static org.junit.jupiter.api.Assertions.assertNotNull;
99
import static org.junit.jupiter.api.Assertions.assertThrows;
10+
import static org.junit.jupiter.api.Assertions.assertTrue;
1011
import static org.junit.jupiter.api.Assertions.fail;
1112

13+
import com.google.gson.JsonArray;
14+
import com.google.gson.JsonParser;
1215
import com.google.gson.JsonSyntaxException;
1316

1417
import org.a2aproject.sdk.jsonrpc.common.json.InvalidParamsJsonMappingException;
@@ -20,7 +23,9 @@
2023
import org.a2aproject.sdk.jsonrpc.common.wrappers.CreateTaskPushNotificationConfigRequest;
2124
import org.a2aproject.sdk.jsonrpc.common.wrappers.CreateTaskPushNotificationConfigResponse;
2225
import org.a2aproject.sdk.spec.InvalidParamsError;
26+
import org.a2aproject.sdk.util.ErrorDetail;
2327
import org.a2aproject.sdk.spec.JSONParseError;
28+
import org.a2aproject.sdk.spec.TaskNotFoundError;
2429
import org.a2aproject.sdk.spec.TaskPushNotificationConfig;
2530
import org.junit.jupiter.api.Test;
2631

@@ -391,4 +396,92 @@ public void testParseErrorResponse_ParseError() throws Exception {
391396
assertEquals(-32700, response.getError().getCode());
392397
assertEquals("Parse error", response.getError().getMessage());
393398
}
399+
400+
@Test
401+
public void testToJsonRPCErrorResponse_KnownErrorCode_ProducesDataArray() {
402+
TaskNotFoundError error = new TaskNotFoundError();
403+
404+
String json = JSONRPCUtils.toJsonRPCErrorResponse("req-1", error);
405+
406+
var jsonObject = JsonParser.parseString(json).getAsJsonObject();
407+
var errorObj = jsonObject.getAsJsonObject("error");
408+
assertTrue(errorObj.has("data"), "error should have a 'data' field");
409+
assertTrue(errorObj.get("data").isJsonArray(), "'data' field should be a JSON array");
410+
JsonArray dataArray = errorObj.getAsJsonArray("data");
411+
assertEquals(1, dataArray.size());
412+
var detail = dataArray.get(0).getAsJsonObject();
413+
assertEquals(ErrorDetail.ERROR_INFO_TYPE, detail.get("@type").getAsString());
414+
assertEquals("TASK_NOT_FOUND", detail.get("reason").getAsString());
415+
assertEquals(ErrorDetail.ERROR_DOMAIN, detail.get("domain").getAsString());
416+
}
417+
418+
@Test
419+
public void testProcessError_ArrayFormData_ExtractsFirstElement() throws Exception {
420+
String errorResponse = """
421+
{
422+
"jsonrpc": "2.0",
423+
"id": "8",
424+
"error": {
425+
"code": -32001,
426+
"message": "Task not found",
427+
"data": [
428+
{
429+
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
430+
"reason": "TASK_NOT_FOUND",
431+
"domain": "a2a-protocol.org",
432+
"metadata": {}
433+
}
434+
]
435+
}
436+
}
437+
""";
438+
439+
CreateTaskPushNotificationConfigResponse response =
440+
(CreateTaskPushNotificationConfigResponse) JSONRPCUtils.parseResponseBody(errorResponse, SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD);
441+
442+
assertNotNull(response);
443+
assertInstanceOf(TaskNotFoundError.class, response.getError());
444+
assertEquals(-32001, response.getError().getCode());
445+
assertEquals("Task not found", response.getError().getMessage());
446+
}
447+
448+
@Test
449+
public void testProcessError_ArrayFormData_NonObjectElement_DoesNotThrow() throws Exception {
450+
// Verifies that a non-object first array element does not cause a ClassCastException
451+
String errorResponse = """
452+
{
453+
"jsonrpc": "2.0",
454+
"id": "9",
455+
"error": {
456+
"code": -32001,
457+
"message": "Task not found",
458+
"data": ["unexpected-string-element"]
459+
}
460+
}
461+
""";
462+
463+
CreateTaskPushNotificationConfigResponse response =
464+
(CreateTaskPushNotificationConfigResponse) JSONRPCUtils.parseResponseBody(errorResponse, SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD);
465+
466+
assertNotNull(response);
467+
assertInstanceOf(TaskNotFoundError.class, response.getError());
468+
// details should be empty since the array element was not an object
469+
assertTrue(response.getError().getDetails().isEmpty());
470+
}
471+
472+
@Test
473+
public void testToJsonRPCErrorResponse_RoundTrip() throws Exception {
474+
TaskNotFoundError original = new TaskNotFoundError("Custom message", null);
475+
476+
String json = JSONRPCUtils.toJsonRPCErrorResponse("req-rt", original);
477+
CreateTaskPushNotificationConfigResponse response =
478+
(CreateTaskPushNotificationConfigResponse) JSONRPCUtils.parseResponseBody(
479+
json,
480+
SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD);
481+
482+
assertNotNull(response);
483+
assertInstanceOf(TaskNotFoundError.class, response.getError());
484+
assertEquals(-32001, response.getError().getCode());
485+
assertEquals("Custom message", response.getError().getMessage());
486+
}
394487
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package org.a2aproject.sdk.util;
2+
3+
import java.util.Map;
4+
5+
import com.google.gson.annotations.SerializedName;
6+
import org.jspecify.annotations.Nullable;
7+
8+
/**
9+
* Represents a single entry in the JSON-RPC {@code error.data} array, following
10+
* the Google {@code ErrorInfo} format ({@code type.googleapis.com/google.rpc.ErrorInfo}).
11+
*/
12+
public record ErrorDetail(
13+
@SerializedName("@type") String type,
14+
String reason,
15+
String domain,
16+
@Nullable Map<String, Object> metadata) {
17+
18+
public static final String ERROR_INFO_TYPE = "type.googleapis.com/google.rpc.ErrorInfo";
19+
public static final String ERROR_DOMAIN = "a2a-protocol.org";
20+
21+
public ErrorDetail {
22+
Assert.checkNotNullParam("type", type);
23+
Assert.checkNotNullParam("reason", reason);
24+
Assert.checkNotNullParam("domain", domain);
25+
}
26+
27+
/** Convenience factory using the standard A2A ErrorInfo type and domain. */
28+
public static ErrorDetail of(String reason, @Nullable Map<String, Object> metadata) {
29+
return new ErrorDetail(ERROR_INFO_TYPE, reason, ERROR_DOMAIN, metadata);
30+
}
31+
}

transport/rest/src/main/java/org/a2aproject/sdk/transport/rest/handler/RestHandler.java

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import com.google.protobuf.util.JsonFormat;
2727
import mutiny.zero.ZeroPublisher;
2828
import org.a2aproject.sdk.grpc.utils.ProtoUtils;
29+
import org.a2aproject.sdk.util.ErrorDetail;
2930
import org.a2aproject.sdk.jsonrpc.common.json.JsonProcessingException;
3031
import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil;
3132
import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksResult;
@@ -957,9 +958,6 @@ public Flow.Publisher<String> getPublisher() {
957958
}
958959
}
959960

960-
private static final String ERROR_INFO_TYPE = "type.googleapis.com/google.rpc.ErrorInfo";
961-
private static final String ERROR_DOMAIN = "a2a-protocol.org";
962-
963961
/**
964962
* Represents an HTTP error response containing A2A error details in the Google Cloud API error format.
965963
* <p>
@@ -995,7 +993,7 @@ private HTTPRestErrorResponse(A2AError a2aError) {
995993
String reason = errorCode != null ? errorCode.name() : "INTERNAL";
996994
String message = a2aError.getMessage() == null ? a2aError.getClass().getName() : a2aError.getMessage();
997995

998-
ErrorDetail detail = new ErrorDetail(ERROR_INFO_TYPE, reason, ERROR_DOMAIN, a2aError.getDetails());
996+
ErrorDetail detail = ErrorDetail.of(reason, a2aError.getDetails());
999997
this.error = new ErrorBody(httpCode, status, message, List.of(detail));
1000998
}
1001999

@@ -1014,11 +1012,5 @@ public String toString() {
10141012
}
10151013

10161014
private record ErrorBody(int code, String status, String message, List<ErrorDetail> details) {}
1017-
1018-
private record ErrorDetail(
1019-
@com.google.gson.annotations.SerializedName("@type") String type,
1020-
String reason,
1021-
String domain,
1022-
Map<String, Object> metadata) {}
10231015
}
10241016
}

0 commit comments

Comments
 (0)