fix(a2a): convert A2aOperationNotFoundError to JSON-RPC error response in dispatch() #3203
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>[^"]+)"')
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user