Skip to content

Commit 4746b39

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 60b9261 commit 4746b39

File tree

11 files changed

+156
-50
lines changed

11 files changed

+156
-50
lines changed

extras/http-client-vertx/src/main/java/org/a2aproject/sdk/client/http/VertxA2AHttpClient.java

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import org.jspecify.annotations.Nullable;
1919

2020
import org.a2aproject.sdk.common.A2AErrorMessages;
21+
import org.a2aproject.sdk.util.Assert;
2122
import io.vertx.core.AsyncResult;
2223
import io.vertx.core.Future;
2324
import io.vertx.core.Handler;
@@ -187,13 +188,10 @@ private Vertx createVertx() {
187188
* such as Quarkus applications.
188189
*
189190
* @param vertx the Vert.x instance to use; must not be null
190-
* @throws NullPointerException if vertx is null
191+
* @throws IllegalArgumentException if vertx is null
191192
*/
192193
public VertxA2AHttpClient(Vertx vertx) {
193-
if (vertx == null) {
194-
throw new NullPointerException("vertx must not be null");
195-
}
196-
this.vertx = vertx;
194+
this.vertx = Assert.checkNotNullParam("vertx", vertx);
197195
this.ownsVertx = false;
198196
WebClientOptions options = new WebClientOptions()
199197
.setFollowRedirects(true)

extras/http-client-vertx/src/test/java/org/a2aproject/sdk/client/http/VertxA2AHttpClientTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ public void testVertxParameterConstructor() {
2525

2626
@Test
2727
public void testVertxParameterConstructorNullThrows() {
28-
assertThrows(NullPointerException.class, () -> {
28+
assertThrows(IllegalArgumentException.class, () -> {
2929
new VertxA2AHttpClient(null);
3030
});
3131
}

extras/opentelemetry/server/src/main/java/org/a2aproject/sdk/extras/opentelemetry/OpenTelemetryRequestHandlerDecorator.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,7 @@ public void onDeleteTaskPushNotificationConfig(DeleteTaskPushNotificationConfigP
457457
}
458458
}
459459

460+
@Override
460461
public void validateRequestedTask(@Nullable String requestedTaskId) throws A2AError {
461462
delegate.validateRequestedTask(requestedTaskId);
462463
}

http-client/src/main/java/org/a2aproject/sdk/client/http/JdkA2AHttpClient.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,6 @@ private HttpRequest.Builder createRequestBuilder(boolean SSE) throws IOException
315315
@Override
316316
public A2AHttpResponse post() throws IOException, InterruptedException {
317317
HttpRequest request = createRequestBuilder(false)
318-
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
319318
.build();
320319
HttpResponse<String> response =
321320
httpClient.send(request, BodyHandlers.ofString(StandardCharsets.UTF_8));

reference/jsonrpc/src/main/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutes.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -604,7 +604,7 @@ private String extractTenant(RoutingContext rc) {
604604
* "error": {
605605
* "code": -32602,
606606
* "message": "Invalid params",
607-
* "details": { ... }
607+
* "data": [ ... ]
608608
* }
609609
* }
610610
* }</pre>

server-common/src/main/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandler.java

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ public ListTasksResult onListTasks(ListTasksParams params, ServerCallContext con
339339
Instant now = Instant.now();
340340
if (params.statusTimestampAfter().isAfter(now)) {
341341
Map<String, Object> errorData = new HashMap<>();
342-
errorData.put("parameter", "lastUpdatedAfter");
342+
errorData.put("parameter", "statusTimestampAfter");
343343
errorData.put("reason", "Timestamp cannot be in the future");
344344
throw new InvalidParamsError(null, "Invalid params", errorData);
345345
}
@@ -1179,24 +1179,5 @@ private void logThreadStats(String label) {
11791179
THREAD_STATS_LOGGER.debug("=== END THREAD STATS ===");
11801180
}
11811181

1182-
/**
1183-
* Check if an event represents a final task state.
1184-
*
1185-
* @param eventKind the event to check
1186-
* @return true if the event represents a final state (COMPLETED, FAILED, CANCELED, REJECTED, UNKNOWN)
1187-
*/
1188-
private boolean isFinalEvent(EventKind eventKind) {
1189-
if (!(eventKind instanceof Event event)) {
1190-
return false;
1191-
}
1192-
if (event instanceof Task task) {
1193-
return task.status() != null && task.status().state() != null
1194-
&& task.status().state().isFinal();
1195-
} else if (event instanceof TaskStatusUpdateEvent statusUpdate) {
1196-
return statusUpdate.isFinal();
1197-
}
1198-
return false;
1199-
}
1200-
12011182
private record MessageSendSetup(TaskManager taskManager, @Nullable Task task, RequestContext requestContext) {}
12021183
}

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: 97 additions & 4 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

@@ -143,7 +148,7 @@ public void testParseInvalidProtoStructure_ThrowsInvalidParamsJsonMappingExcepti
143148

144149
@Test
145150
public void testParseNumericalTimestampThrowsInvalidParamsJsonMappingException() {
146-
String valideRequest = """
151+
String validRequest = """
147152
{
148153
"jsonrpc": "2.0",
149154
"method": "ListTasks",
@@ -165,7 +170,7 @@ public void testParseNumericalTimestampThrowsInvalidParamsJsonMappingException()
165170
""";
166171

167172
try {
168-
A2ARequest<?> request = JSONRPCUtils.parseRequestBody(valideRequest, null);
173+
A2ARequest<?> request = JSONRPCUtils.parseRequestBody(validRequest, null);
169174
assertEquals(1, request.getId());
170175
} catch (JsonProcessingException e) {
171176
fail(e);
@@ -208,7 +213,7 @@ public void testParseMissingField_ThrowsInvalidParamsError() throws JsonMappingE
208213

209214
@Test
210215
public void testParseUnknownField_ThrowsJsonMappingException() throws JsonMappingException {
211-
String unkownFieldMessage= """
216+
String unknownFieldMessage= """
212217
{
213218
"jsonrpc":"2.0",
214219
"method":"SendMessage",
@@ -232,7 +237,7 @@ public void testParseUnknownField_ThrowsJsonMappingException() throws JsonMappin
232237
}""";
233238
JsonMappingException exception = assertThrows(
234239
JsonMappingException.class,
235-
() -> JSONRPCUtils.parseRequestBody(unkownFieldMessage, null)
240+
() -> JSONRPCUtils.parseRequestBody(unknownFieldMessage, null)
236241
);
237242
assertEquals(ERROR_MESSAGE.formatted("unknown in message lf.a2a.v1.Message"), exception.getMessage());
238243
}
@@ -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/jsonrpc/src/main/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandler.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -431,7 +431,7 @@ public GetTaskPushNotificationConfigResponse getPushNotificationConfig(
431431
requestHandler.onGetTaskPushNotificationConfig(request.getParams(), context);
432432
return new GetTaskPushNotificationConfigResponse(request.getId(), config);
433433
} catch (A2AError e) {
434-
return new GetTaskPushNotificationConfigResponse(request.getId().toString(), e);
434+
return new GetTaskPushNotificationConfigResponse(request.getId(), e);
435435
} catch (Throwable t) {
436436
return new GetTaskPushNotificationConfigResponse(request.getId(), new InternalError(t.getMessage()));
437437
}
@@ -471,7 +471,7 @@ public CreateTaskPushNotificationConfigResponse setPushNotificationConfig(
471471
try {
472472
TaskPushNotificationConfig config =
473473
requestHandler.onCreateTaskPushNotificationConfig(request.getParams(), context);
474-
return new CreateTaskPushNotificationConfigResponse(request.getId().toString(), config);
474+
return new CreateTaskPushNotificationConfigResponse(request.getId(), config);
475475
} catch (A2AError e) {
476476
return new CreateTaskPushNotificationConfigResponse(request.getId(), e);
477477
} catch (Throwable t) {

0 commit comments

Comments
 (0)