fix(a2a): change A2aErrorDetail.code to int and map error constants to JSON-RPC 2.0 integer codes
CI / lint (pull_request) Failing after 30s
CI / typecheck (pull_request) Successful in 51s
CI / quality (pull_request) Successful in 46s
CI / security (pull_request) Successful in 53s
CI / coverage (pull_request) Has been skipped
CI / helm (pull_request) Successful in 23s
CI / build (pull_request) Successful in 47s
CI / unit_tests (pull_request) Successful in 6m30s
CI / docker (pull_request) Has been skipped
CI / e2e_tests (pull_request) Successful in 18m51s
CI / integration_tests (pull_request) Successful in 23m20s
CI / status-check (pull_request) Failing after 1s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Has been skipped
CI / lint (pull_request) Failing after 30s
CI / typecheck (pull_request) Successful in 51s
CI / quality (pull_request) Successful in 46s
CI / security (pull_request) Successful in 53s
CI / coverage (pull_request) Has been skipped
CI / helm (pull_request) Successful in 23s
CI / build (pull_request) Successful in 47s
CI / unit_tests (pull_request) Successful in 6m30s
CI / docker (pull_request) Has been skipped
CI / e2e_tests (pull_request) Successful in 18m51s
CI / integration_tests (pull_request) Successful in 23m20s
CI / status-check (pull_request) Failing after 1s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Has been skipped
Per JSON-RPC 2.0 specification (Section 5.1), error codes must be integers.
This commit fixes the protocol compliance defect where A2aErrorDetail.code
was typed as str and error constants were string literals.
Changes:
- src/cleveragents/a2a/models.py: Change A2aErrorDetail.code from str to int;
update field_validator to only validate 'message' (code no longer needs
non-empty string check; Pydantic enforces int type)
- src/cleveragents/a2a/errors.py: Change all error code constants from string
literals to JSON-RPC 2.0 integer codes per docs/reference/a2a.md taxonomy:
NOT_FOUND = -32001, AUTH_ERROR = -32002, FORBIDDEN = -32003,
INVALID_STATE = -32004, PLAN_ERROR = -32008, CONFIGURATION_ERROR = -32009,
VALIDATION_ERROR = -32602, INTERNAL_ERROR = -32603
Update map_domain_error() return type from tuple[str, str] to tuple[int, str]
- features/steps/a2a_facade_steps.py: Update A2aErrorDetail construction to
map symbolic string names to integer codes via _CODE_MAP
- features/steps/a2a_facade_wiring_steps.py: Update error code assertion to
map symbolic names to integers for comparison
- features/steps/a2a_facade_coverage_boost_steps.py: Same as above
- features/steps/a2a_jsonrpc_wire_format_steps.py: Update all A2aErrorDetail
constructions and JSON-RPC dict payloads to use integer codes
- robot/helper_a2a_facade_wiring.py: Update wired_error_mapping() to compare
against integer codes
- robot/helper_a2a_jsonrpc_wire_format.py: Update response_error_wire_format()
to use integer code -32001 instead of string 'NOT_FOUND'
Wire format now produces {"code": -32001, ...} instead of {"code": "NOT_FOUND", ...},
making it compliant with JSON-RPC 2.0 and interoperable with standards-conformant clients.
ISSUES CLOSED: #2746
This commit is contained in:
@@ -326,6 +326,7 @@ Domain exceptions are mapped to JSON-RPC 2.0 error codes:
|
||||
| `-32006` | `BudgetExceededError` | Budget exceeded |
|
||||
| `-32007` | `A2aVersionMismatchError` | Version mismatch |
|
||||
| `-32008` | `PlanError` | Plan lifecycle error |
|
||||
| `-32009` | `ConfigurationError` | Configuration error |
|
||||
|
||||
### Example Error Response
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Shared A2A error code mapping for Behave step definitions.
|
||||
|
||||
Provides a single source of truth for the mapping between symbolic error
|
||||
name strings (used in Gherkin feature files) and their corresponding
|
||||
JSON-RPC 2.0 integer codes (defined in ``cleveragents.a2a.errors``).
|
||||
|
||||
All step files that need to translate a symbolic name like ``"NOT_FOUND"``
|
||||
to its integer code ``-32001`` should import :data:`A2A_CODE_MAP` from
|
||||
this module rather than defining a local ``_CODE_MAP`` dictionary.
|
||||
|
||||
This eliminates the DRY violation identified in code review where the
|
||||
same mapping was duplicated across 4 step files (6 definitions total).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cleveragents.a2a import errors as _a2a_errors
|
||||
|
||||
#: Maps symbolic error name strings (as used in Gherkin feature files) to
|
||||
#: their JSON-RPC 2.0 integer error codes. Derived directly from the
|
||||
#: ``cleveragents.a2a.errors`` module constants so that any future change
|
||||
#: to the integer values is automatically reflected here.
|
||||
A2A_CODE_MAP: dict[str, int] = {
|
||||
"NOT_FOUND": _a2a_errors.NOT_FOUND,
|
||||
"AUTH_ERROR": _a2a_errors.AUTH_ERROR,
|
||||
"FORBIDDEN": _a2a_errors.FORBIDDEN,
|
||||
"INVALID_STATE": _a2a_errors.INVALID_STATE,
|
||||
"PLAN_ERROR": _a2a_errors.PLAN_ERROR,
|
||||
"CONFIGURATION_ERROR": _a2a_errors.CONFIGURATION_ERROR,
|
||||
"VALIDATION_ERROR": _a2a_errors.VALIDATION_ERROR,
|
||||
"INTERNAL_ERROR": _a2a_errors.INTERNAL_ERROR,
|
||||
}
|
||||
|
||||
__all__ = ["A2A_CODE_MAP"]
|
||||
@@ -25,6 +25,7 @@ try:
|
||||
from cleveragents.a2a.models import A2aRequest
|
||||
except ImportError:
|
||||
pass # a2a module not available
|
||||
from features.steps._a2a_code_map import A2A_CODE_MAP
|
||||
|
||||
use_step_matcher("re")
|
||||
|
||||
@@ -117,9 +118,13 @@ def step_cb_response_status(context: Context, status: str) -> None:
|
||||
|
||||
@then(r'the coverage-boost response error code should be "(?P<code>[^"]+)"')
|
||||
def step_cb_error_code(context: Context, code: str) -> None:
|
||||
expected_code: int = A2A_CODE_MAP.get(
|
||||
code, int(code) if code.lstrip("-").isdigit() else -1
|
||||
)
|
||||
assert context.cb_response.error is not None, "No error in response"
|
||||
assert context.cb_response.error.code == code, (
|
||||
f"Expected error code '{code}', got '{context.cb_response.error.code}'"
|
||||
assert context.cb_response.error.code == expected_code, (
|
||||
f"Expected error code {expected_code} ('{code}'), "
|
||||
f"got '{context.cb_response.error.code}'"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ try:
|
||||
except ImportError:
|
||||
pass # a2a module not available
|
||||
from cleveragents.core.exceptions import CleverAgentsError
|
||||
from features.steps._a2a_code_map import A2A_CODE_MAP
|
||||
|
||||
use_step_matcher("re")
|
||||
|
||||
@@ -401,10 +402,12 @@ def step_create_response(context: Context, status: str, rid: str) -> None:
|
||||
if status == "ok":
|
||||
context.response = A2aResponse(id=rid, result={})
|
||||
else:
|
||||
from cleveragents.a2a import errors as _a2a_errors
|
||||
from cleveragents.a2a.models import A2aErrorDetail
|
||||
|
||||
int_code: int = A2A_CODE_MAP.get(status, _a2a_errors.INTERNAL_ERROR)
|
||||
context.response = A2aResponse(
|
||||
id=rid, error=A2aErrorDetail(code=status, message=status)
|
||||
id=rid, error=A2aErrorDetail(code=int_code, message=status)
|
||||
)
|
||||
|
||||
|
||||
@@ -433,7 +436,12 @@ def step_create_response_invalid(context: Context, status: str) -> None:
|
||||
r'I create an A2aErrorDetail with code "(?P<code>[^"]+)" and message "(?P<msg>[^"]+)"'
|
||||
)
|
||||
def step_create_error_detail(context: Context, code: str, msg: str) -> None:
|
||||
context.error_detail = A2aErrorDetail(code=code, message=msg)
|
||||
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.error_detail = A2aErrorDetail(code=int_code, message=msg)
|
||||
|
||||
|
||||
@then(r"the error detail should be valid")
|
||||
|
||||
@@ -28,6 +28,7 @@ from cleveragents.core.exceptions import (
|
||||
ResourceNotFoundError,
|
||||
ValidationError,
|
||||
)
|
||||
from features.steps._a2a_code_map import A2A_CODE_MAP
|
||||
|
||||
use_step_matcher("re")
|
||||
|
||||
@@ -257,9 +258,13 @@ def step_wired_data_key_not_empty(context: Context, key: str) -> None:
|
||||
|
||||
@then(r'wired response error code should be "(?P<code>[^"]+)"')
|
||||
def step_wired_error_code(context: Context, code: str) -> None:
|
||||
expected_code: int = A2A_CODE_MAP.get(
|
||||
code, int(code) if code.lstrip("-").isdigit() else -1
|
||||
)
|
||||
assert context.wired_response.error is not None, "No error in response"
|
||||
assert context.wired_response.error.code == code, (
|
||||
f"Expected error code '{code}', got '{context.wired_response.error.code}'"
|
||||
assert context.wired_response.error.code == expected_code, (
|
||||
f"Expected error code {expected_code} ('{code}'), "
|
||||
f"got '{context.wired_response.error.code}'"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ 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
|
||||
|
||||
use_step_matcher("re")
|
||||
|
||||
try:
|
||||
@@ -148,9 +150,18 @@ 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=code, message="Resource not found"),
|
||||
error=A2aErrorDetail(code=int_code, message="Resource not found"),
|
||||
)
|
||||
context.response = A2aResponse(
|
||||
id=resp_id,
|
||||
error=A2aErrorDetail(code=int_code, message="Resource not found"),
|
||||
)
|
||||
|
||||
|
||||
@@ -175,7 +186,7 @@ def step_create_response_both_result_and_error(context: Context) -> None:
|
||||
A2aResponse(
|
||||
id="test-id",
|
||||
result={"status": "ok"},
|
||||
error=A2aErrorDetail(code="ERR", message="oops"),
|
||||
error=A2aErrorDetail(code=-32603, message="oops"),
|
||||
)
|
||||
except (ValidationError, ValueError) as exc:
|
||||
context.caught_error = exc
|
||||
@@ -255,10 +266,15 @@ 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
|
||||
)
|
||||
context.raw_dict = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": resp_id,
|
||||
"error": {"code": code, "message": "Resource not found"},
|
||||
"error": {"code": int_code, "message": "Resource not found"},
|
||||
}
|
||||
|
||||
|
||||
@@ -295,12 +311,13 @@ def step_wire_dispatch(context: Context, method: str, params_json: str) -> None:
|
||||
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="NOT_FOUND",
|
||||
code=_a2a_errors.NOT_FOUND,
|
||||
message=f"Unknown A2A method: {method}",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -215,11 +215,13 @@ def wired_event_subscribe() -> None:
|
||||
|
||||
def wired_error_mapping() -> None:
|
||||
"""Verify domain-to-A2A error code mapping."""
|
||||
cases: list[tuple[Exception, str]] = [
|
||||
(ResourceNotFoundError(resource_type="x", resource_id="1"), "NOT_FOUND"),
|
||||
(ValidationError("bad"), "VALIDATION_ERROR"),
|
||||
(PlanError("fail"), "PLAN_ERROR"),
|
||||
(BusinessRuleViolation("invalid"), "INVALID_STATE"),
|
||||
from cleveragents.a2a import errors as _a2a_errors
|
||||
cases: list[tuple[Exception, int]] = [
|
||||
(ResourceNotFoundError(resource_type="x", resource_id="1"),
|
||||
_a2a_errors.NOT_FOUND),
|
||||
(ValidationError("bad"), _a2a_errors.VALIDATION_ERROR),
|
||||
(PlanError("fail"), _a2a_errors.PLAN_ERROR),
|
||||
(BusinessRuleViolation("invalid"), _a2a_errors.INVALID_STATE),
|
||||
]
|
||||
for exc, expected_code in cases:
|
||||
code, _ = map_domain_error(exc)
|
||||
|
||||
@@ -130,7 +130,7 @@ def response_error_wire_format() -> None:
|
||||
"""Verify A2aResponse (error) serialises to JSON-RPC 2.0 wire format."""
|
||||
resp = A2aResponse(
|
||||
id="REQ-002",
|
||||
error=A2aErrorDetail(code="NOT_FOUND", message="Plan not found"),
|
||||
error=A2aErrorDetail(code=-32001, message="Plan not found"),
|
||||
)
|
||||
wire = resp.model_dump(exclude_none=True)
|
||||
|
||||
@@ -154,7 +154,7 @@ def response_error_wire_format() -> None:
|
||||
|
||||
# Check error structure
|
||||
error = wire["error"]
|
||||
if error.get("code") != "NOT_FOUND":
|
||||
if error.get("code") != -32001:
|
||||
print(f"FAIL: error code mismatch: {error.get('code')}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -276,7 +276,7 @@ def response_deserialise() -> None:
|
||||
error_payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "resp-43",
|
||||
"error": {"code": "NOT_FOUND", "message": "Plan not found"},
|
||||
"error": {"code": -32001, "message": "Plan not found"},
|
||||
}
|
||||
err_resp = A2aResponse.model_validate(error_payload)
|
||||
if err_resp.error is None:
|
||||
|
||||
@@ -26,17 +26,35 @@ from cleveragents.core.exceptions import (
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A2A error code constants
|
||||
# A2A error code constants — JSON-RPC 2.0 integer codes
|
||||
#
|
||||
# Standard JSON-RPC 2.0 codes (Section 5.1):
|
||||
# -32700 Parse error
|
||||
# -32600 Invalid request
|
||||
# -32601 Method not found
|
||||
# -32602 Invalid params
|
||||
# -32603 Internal error
|
||||
#
|
||||
# Application-defined codes (per docs/reference/a2a.md §Error Code Taxonomy):
|
||||
# -32001 Resource not found
|
||||
# -32002 Authentication required
|
||||
# -32003 Authorization forbidden
|
||||
# -32004 Invalid state / business rule violation
|
||||
# -32005 Duplicate entity
|
||||
# -32006 Budget exceeded
|
||||
# -32007 Version mismatch
|
||||
# -32008 Plan error
|
||||
# -32009 Configuration error
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
NOT_FOUND: str = "NOT_FOUND"
|
||||
VALIDATION_ERROR: str = "VALIDATION_ERROR"
|
||||
INVALID_STATE: str = "INVALID_STATE"
|
||||
PLAN_ERROR: str = "PLAN_ERROR"
|
||||
AUTH_ERROR: str = "AUTH_ERROR"
|
||||
FORBIDDEN: str = "FORBIDDEN"
|
||||
CONFIGURATION_ERROR: str = "CONFIGURATION_ERROR"
|
||||
INTERNAL_ERROR: str = "INTERNAL_ERROR"
|
||||
NOT_FOUND: int = -32001
|
||||
AUTH_ERROR: int = -32002
|
||||
FORBIDDEN: int = -32003
|
||||
INVALID_STATE: int = -32004
|
||||
PLAN_ERROR: int = -32008
|
||||
CONFIGURATION_ERROR: int = -32009
|
||||
VALIDATION_ERROR: int = -32602
|
||||
INTERNAL_ERROR: int = -32603
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -99,7 +117,7 @@ class A2aOperationNotFoundError(A2aError):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def map_domain_error(exc: Exception) -> tuple[str, str]:
|
||||
def map_domain_error(exc: Exception) -> tuple[int, str]:
|
||||
"""Map a domain exception to an A2A error code and message.
|
||||
|
||||
Returns a ``(code, message)`` tuple suitable for constructing an
|
||||
@@ -111,11 +129,16 @@ def map_domain_error(exc: Exception) -> tuple[str, str]:
|
||||
generic ``INVALID_STATE`` that would match its ``DomainError``
|
||||
ancestor.
|
||||
|
||||
Per JSON-RPC 2.0 specification (Section 5.1), error codes are integers.
|
||||
The returned ``code`` is always a negative integer matching the
|
||||
JSON-RPC 2.0 standard taxonomy defined in ``docs/reference/a2a.md``.
|
||||
|
||||
Args:
|
||||
exc: The exception raised by a domain or application service.
|
||||
|
||||
Returns:
|
||||
A two-element tuple of ``(error_code, error_message)``.
|
||||
A two-element tuple of ``(error_code, error_message)`` where
|
||||
``error_code`` is a JSON-RPC 2.0 compliant integer error code.
|
||||
"""
|
||||
if not isinstance(exc, Exception):
|
||||
raise TypeError("exc must be an Exception instance")
|
||||
|
||||
@@ -62,15 +62,19 @@ class A2aErrorDetail(BaseModel):
|
||||
|
||||
Matches the JSON-RPC 2.0 error object structure:
|
||||
``{"code": ..., "message": ..., "data": {...}}``
|
||||
|
||||
Per JSON-RPC 2.0 specification (Section 5.1), ``code`` must be an integer.
|
||||
Standard JSON-RPC 2.0 error codes are negative integers in the range
|
||||
-32768 to -32000. Application-defined codes must be outside this range.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(strict=False)
|
||||
|
||||
code: str
|
||||
code: int
|
||||
message: str
|
||||
details: dict[str, Any] = {}
|
||||
|
||||
@field_validator("code", "message")
|
||||
@field_validator("message")
|
||||
@classmethod
|
||||
def _must_be_non_empty(cls, value: str) -> str:
|
||||
if not value:
|
||||
|
||||
Reference in New Issue
Block a user