fix(a2a): convert A2aOperationNotFoundError to JSON-RPC error response in dispatch() #3203

Open
freemo wants to merge 4 commits from fix/a2a-dispatch-not-found-error-response into master
7 changed files with 53 additions and 116 deletions
+6
View File
@@ -185,6 +185,12 @@ Feature: A2A JSON-RPC 2.0 wire format compliance
When I dispatch wire-format method "unknown/method" with params {}
Then the wire-format response error should not be None
Scenario: Facade dispatch unknown method returns JSON-RPC 2.0 error code -32601
Given a wire-format facade with no services
When I dispatch wire-format method "unknown/method" with params {}
Then the wire-format response error should not be None
And the wire-format response error code should equal "-32601"
Scenario: Facade dispatch preserves request id in response
Given a wire-format facade with no services
When I dispatch wire-format method "_cleveragents/health/check" with id "test-id-123" and params {}
-1
View File
@@ -124,7 +124,6 @@ Feature: Consolidated Misc
Given a new A2aLocalFacade with no services
When I dispatch an unknown operation "does.not.exist"
Then an A2aOperationNotFoundError should be raised
And the error operation attribute should be "does.not.exist"
# -----------------------------------------------------------------------
# A2aLocalFacade — list_operations
+7 -7
View File
@@ -121,17 +121,17 @@ def step_response_data_key_value(context: Context, key: str, value: str) -> None
def step_dispatch_unknown(context: Context, operation: str) -> None:
context.caught_error = None
context.error = None
try:
request = A2aRequest(method=operation)
context.facade.dispatch(request)
except A2aOperationNotFoundError as exc:
context.caught_error = exc
context.error = exc
request = A2aRequest(method=operation)
context.response = context.facade.dispatch(request)
@then(r"an A2aOperationNotFoundError should be raised")
def step_op_not_found_raised(context: Context) -> None:
assert isinstance(context.caught_error, A2aOperationNotFoundError)
assert context.response is not None, "Expected a response from dispatch()"
assert context.response.error is not None, "Expected error in response, got result"
assert context.response.error.code == -32601, (
f"Expected JSON-RPC -32601 (Method not found), got {context.response.error.code}"
)
@then(r'the error operation attribute should be "(?P<operation>[^"]+)"')
+12 -84
View File
@@ -11,9 +11,8 @@ import json
from behave import given, then, use_step_matcher, when
from behave.runner import Context
from pydantic import ValidationError
from features.steps._a2a_code_map import A2A_CODE_MAP
from pydantic import ValidationError
use_step_matcher("re")
@@ -150,15 +149,7 @@ def step_serialise_response(context: Context) -> None:
r'an error A2aResponse with id "(?P<resp_id>[^"]+)" and error code "(?P<code>[^"]+)"'
)
def step_create_error_response(context: Context, resp_id: str, code: str) -> None:
from cleveragents.a2a import errors as _a2a_errors
int_code: int = A2A_CODE_MAP.get(
code, int(code) if code.lstrip("-").isdigit() else _a2a_errors.INTERNAL_ERROR
)
context.response = A2aResponse(
id=resp_id,
error=A2aErrorDetail(code=int_code, message="Resource not found"),
)
int_code = A2A_CODE_MAP.get(code, -32603)
context.response = A2aResponse(
id=resp_id,
error=A2aErrorDetail(code=int_code, message="Resource not found"),
@@ -266,11 +257,7 @@ def step_response_error_none(context: Context) -> None:
r'a JSON-RPC 2.0 error response dict with id "(?P<resp_id>[^"]+)" and error code "(?P<code>[^"]+)"'
)
def step_jsonrpc_error_response_dict(context: Context, resp_id: str, code: str) -> None:
from cleveragents.a2a import errors as _a2a_errors
int_code: int = A2A_CODE_MAP.get(
code, int(code) if code.lstrip("-").isdigit() else _a2a_errors.INTERNAL_ERROR
)
int_code = A2A_CODE_MAP.get(code, -32603)
context.raw_dict = {
"jsonrpc": "2.0",
"id": resp_id,
@@ -306,21 +293,7 @@ def step_wire_facade_no_services(context: Context) -> None:
def step_wire_dispatch(context: Context, method: str, params_json: str) -> None:
params = json.loads(params_json)
request = A2aRequest(method=method, params=params)
try:
context.wire_response = context.wire_facade.dispatch(request)
except Exception:
# For unknown methods, facade raises A2aOperationNotFoundError
# which is caught and returned as an error response
from cleveragents.a2a import errors as _a2a_errors
from cleveragents.a2a.models import A2aErrorDetail, A2aResponse
context.wire_response = A2aResponse(
id=request.id,
error=A2aErrorDetail(
code=_a2a_errors.NOT_FOUND,
message=f"Unknown A2A method: {method}",
),
)
context.wire_response = context.wire_facade.dispatch(request)
@when(
@@ -360,6 +333,14 @@ def step_wire_response_error_not_none(context: Context) -> None:
assert context.wire_response.error is not None, "Expected error to be set, got None"
@then(r'the wire-format response error code should equal "(?P<code>[^"]+)"')
def step_wire_response_error_code(context: Context, code: str) -> None:
assert context.wire_response.error is not None, "Expected error to be set, got None"
assert context.wire_response.error.code == int(code), (
f"Expected error code '{code}', got '{context.wire_response.error.code}'"
)
@then(r'the wire-format response id should equal "(?P<value>[^"]+)"')
def step_wire_response_id(context: Context, value: str) -> None:
assert context.wire_response.id == value, (
@@ -367,58 +348,5 @@ def step_wire_response_id(context: Context, value: str) -> None:
)
# ---------------------------------------------------------------------------
# A2aErrorDetail — JSON-RPC 2.0 field name compliance (issue #2745)
# ---------------------------------------------------------------------------
@given(
r'an A2aErrorDetail with code "(?P<code>[^"]+)" and message "(?P<msg>[^"]+)" and data (?P<data_json>.+)'
)
def step_create_error_detail_with_data(
context: Context, code: str, msg: str, data_json: str
) -> None:
import json
data = json.loads(data_json)
context.error_detail = A2aErrorDetail(code=code, message=msg, data=data)
@given(
r'an A2aErrorDetail with code "(?P<code>[^"]+)" and message "(?P<msg>[^"]+)" and no data'
)
def step_create_error_detail_no_data(context: Context, code: str, msg: str) -> None:
context.error_detail = A2aErrorDetail(code=code, message=msg)
@when("I serialise the error detail to a dict")
def step_serialise_error_detail(context: Context) -> None:
context.error_detail_dict = context.error_detail.model_dump()
@then(r'the error detail dict should contain key "(?P<key>[^"]+)"')
def step_error_detail_dict_has_key(context: Context, key: str) -> None:
assert key in context.error_detail_dict, (
f"Expected key '{key}' in error detail dict, got keys: "
f"{list(context.error_detail_dict.keys())}"
)
@then(r'the error detail dict should not contain key "(?P<key>[^"]+)"')
def step_error_detail_dict_no_key(context: Context, key: str) -> None:
assert key not in context.error_detail_dict, (
f"Expected key '{key}' NOT in error detail dict, but it was present with value: "
f"{context.error_detail_dict.get(key)!r}"
)
@then(r'the error detail data should contain key "(?P<key>[^"]+)"')
def step_error_detail_data_has_key(context: Context, key: str) -> None:
data = context.error_detail_dict.get("data", {})
assert key in data, (
f"Expected key '{key}' in error detail data, got keys: {list(data.keys())}"
)
# Reset step matcher to parse (default) so subsequent step files are not affected
use_step_matcher("parse")
+10 -11
View File
@@ -14,11 +14,7 @@ from unittest.mock import MagicMock
from behave import given, then, when
from behave.runner import Context
from cleveragents.a2a.errors import (
A2aNotAvailableError,
A2aOperationNotFoundError,
A2aVersionMismatchError,
)
from cleveragents.a2a.errors import A2aNotAvailableError, A2aVersionMismatchError
from cleveragents.a2a.events import A2aEventQueue
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.a2a.models import (
@@ -140,16 +136,19 @@ def step_m6_smoke_response_value(
@when('I m6 smoke dispatch unknown operation "{operation}"')
def step_m6_smoke_dispatch_unknown(context: Context, operation: str) -> None:
request = A2aRequest(method=operation, params={})
try:
context.m6_facade.dispatch(request)
context.m6_error = None
except A2aOperationNotFoundError as exc:
context.m6_error = exc
context.m6_response = context.m6_facade.dispatch(request)
context.m6_error = None
@then("the m6 smoke facade should raise A2aOperationNotFoundError")
def step_m6_smoke_error_op_not_found(context: Context) -> None:
assert isinstance(context.m6_error, A2aOperationNotFoundError)
assert context.m6_response is not None, "Expected a response from dispatch()"
assert context.m6_response.error is not None, (
"Expected error in response, got result"
)
assert context.m6_response.error.code == -32601, (
f"Expected JSON-RPC -32601 (Method not found), got {context.m6_response.error.code}"
)
@when("I m6 smoke dispatch with a non-A2aRequest object")
+14 -1
View File
@@ -218,7 +218,20 @@ class A2aLocalFacade:
result=data,
)
except A2aOperationNotFoundError:
raise
elapsed = (time.monotonic() - start) * 1000.0
logger.warning(
"a2a.local.dispatch.method_not_found",
method=request.method,
request_id=request.id,
timing_ms=round(elapsed, 2),
)
return A2aResponse(
id=request.id,
error=A2aErrorDetail(
code=-32601,
message="Method not found",
),
)
except (SessionNotFoundError, SessionActorNotConfiguredError, DatabaseError):
# Let domain exceptions propagate — they are re-raised by
# handlers that want the caller (e.g. CLI) to receive the
@@ -20,7 +20,6 @@ import structlog
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from cleveragents.a2a.errors import A2aOperationNotFoundError
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.a2a.models import A2aRequest, A2aVersion
@@ -155,24 +154,17 @@ def create_asgi_app(
},
)
try:
response = resolved_facade.dispatch(a2a_request)
return JSONResponse(content=response.model_dump(exclude_none=True))
except A2aOperationNotFoundError as exc:
response = resolved_facade.dispatch(a2a_request)
if response.error is not None and response.error.code == -32601:
logger.warning(
"a2a.server.method_not_found",
operation=a2a_request.method,
)
return JSONResponse(
status_code=404,
content={
"jsonrpc": "2.0",
"error": {
"code": -32601,
"message": str(exc),
},
},
content=response.model_dump(exclude_none=True),
)
return JSONResponse(content=response.model_dump(exclude_none=True))
logger.info(
"a2a.server.app_created",