Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions app/ai-service/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,29 @@
from typing import Any, Optional

from schemas.error_codes import ErrorCode


class AIServiceError(Exception):
"""Raised when a downstream AI/LLM call fails."""
"""Raised when a downstream AI/LLM call fails.

``code`` defaults to :attr:`ErrorCode.AI_SERVICE_ERROR` (Issue #249)
— the shared cross-service taxonomy identifier. Callers may pass
any ``ErrorCode`` member (its ``.value`` string is what the AI
service emits in the response envelope).
"""

def __init__(self, message: str, code: str = "AI_SERVICE_ERROR", details: Optional[Any] = None):
def __init__(
self,
message: str,
code: ErrorCode | str = ErrorCode.AI_SERVICE_ERROR,
details: Optional[Any] = None,
):
super().__init__(message)
self.message = message
self.code = code
# Accept either an ErrorCode member or a raw string so existing
# callers that pass ``code="AI_TIMEOUT"`` keep working; .value
# is already the literal string for ErrorCode members.
self.code = code.value if isinstance(code, ErrorCode) else code
self.details = details

def __str__(self) -> str:
Expand Down
19 changes: 14 additions & 5 deletions app/ai-service/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from fastapi.responses import JSONResponse, RedirectResponse, Response
from exceptions import AIServiceError
from schemas.errors import ErrorDetail, ErrorEnvelope
from schemas.error_codes import ErrorCode, error_code_for_http_status
import time
import metrics
import email.utils
Expand Down Expand Up @@ -348,7 +349,7 @@ async def _send_413(self, send, observed: int, reason: str, limit: Optional[int]

envelope = ErrorEnvelope(
error=ErrorDetail(
code="PAYLOAD_TOO_LARGE",
code=ErrorCode.PAYLOAD_TOO_LARGE.value,
message=msg,
)
).model_dump()
Expand Down Expand Up @@ -376,7 +377,7 @@ async def _send_400_mismatch(self, send, declared: int, observed: int):
)
envelope = ErrorEnvelope(
error=ErrorDetail(
code="CODE_BODY_LENGTH_MISMATCH",
code=ErrorCode.BODY_LENGTH_MISMATCH.value,
message=msg,
)
).model_dump()
Expand Down Expand Up @@ -1033,10 +1034,15 @@ async def _legacy_cancel_task(task_id: str):
@app.exception_handler(HTTPException)
async def http_exception_handler(request, exc: HTTPException):
logger.error(f"HTTP Exception: {exc.status_code} - {exc.detail}")
# Translate the HTTP status into the shared ErrorCode taxonomy
# (Issue #249) instead of emitting an ad-hoc ``HTTP_<status>`` string.
return JSONResponse(
status_code=exc.status_code,
content=ErrorEnvelope(
error=ErrorDetail(code=f"HTTP_{exc.status_code}", message=str(exc.detail))
error=ErrorDetail(
code=error_code_for_http_status(exc.status_code).value,
message=str(exc.detail),
)
).model_dump(),
)

Expand All @@ -1053,7 +1059,7 @@ async def validation_exception_handler(request, exc: RequestValidationError):
status_code=422,
content=ErrorEnvelope(
error=ErrorDetail(
code="VALIDATION_ERROR",
code=ErrorCode.VALIDATION_ERROR.value,
message="Request validation failed",
details=exc.errors(),
)
Expand All @@ -1078,7 +1084,10 @@ async def general_exception_handler(request, exc: Exception):
return JSONResponse(
status_code=500,
content=ErrorEnvelope(
error=ErrorDetail(code="INTERNAL_SERVER_ERROR", message="Internal server error")
error=ErrorDetail(
code=ErrorCode.INTERNAL_SERVER_ERROR.value,
message="Internal server error",
)
).model_dump(),
)

Expand Down
132 changes: 132 additions & 0 deletions app/ai-service/schemas/error_codes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""Cross-service error-code taxonomy (Issue #249).

Python mirror of ``docs/errors.yaml`` and
``app/backend/src/common/errors/codes.ts``. The parity test in
``tests/test_error_codes.py`` asserts every name listed in the YAML
appears here, and that every enum value here appears in the YAML.

Inheriting from ``str`` lets ``ErrorCode.VALIDATION_ERROR`` serialise
directly to ``"VALIDATION_ERROR"`` in JSON without any extra mapping
code, matching the TypeScript side exactly.
"""
from __future__ import annotations

from enum import Enum
from typing import Dict


class ErrorCode(str, Enum):
"""Canonical SCREAMING_SNAKE_CASE identifiers mirroring docs/errors.yaml.

Each member's ``value`` is the literal string form, e.g.::

ErrorCode.NOT_FOUND.value == "NOT_FOUND"

so the JSON serialised identifier is byte-identical to the
TypeScript-side emission.
"""

# Cross-service HTTP categories ----------------------------------
VALIDATION_ERROR = "VALIDATION_ERROR"
NOT_FOUND = "NOT_FOUND"
UNAUTHORIZED = "UNAUTHORIZED"
FORBIDDEN = "FORBIDDEN"
CONFLICT = "CONFLICT"
BAD_REQUEST = "BAD_REQUEST"
PAYLOAD_TOO_LARGE = "PAYLOAD_TOO_LARGE"
BODY_LENGTH_MISMATCH = "BODY_LENGTH_MISMATCH"
INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR"
UPSTREAM_TIMEOUT = "UPSTREAM_TIMEOUT"
UPSTREAM_UNAVAILABLE = "UPSTREAM_UNAVAILABLE"
UPSTREAM_ERROR = "UPSTREAM_ERROR"
AI_SERVICE_ERROR = "AI_SERVICE_ERROR"
AI_TIMEOUT = "AI_TIMEOUT"
RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED"

# Soroban contract errors (AidEscrow `Error` enum, 1..=23) --------
CONTRACT_NOT_INITIALIZED = "CONTRACT_NOT_INITIALIZED"
CONTRACT_ALREADY_INITIALIZED = "CONTRACT_ALREADY_INITIALIZED"
CONTRACT_NOT_AUTHORIZED = "CONTRACT_NOT_AUTHORIZED"
CONTRACT_INVALID_AMOUNT = "CONTRACT_INVALID_AMOUNT"
CONTRACT_PACKAGE_NOT_FOUND = "CONTRACT_PACKAGE_NOT_FOUND"
CONTRACT_PACKAGE_NOT_ACTIVE = "CONTRACT_PACKAGE_NOT_ACTIVE"
CONTRACT_PACKAGE_EXPIRED = "CONTRACT_PACKAGE_EXPIRED"
CONTRACT_PACKAGE_NOT_EXPIRED = "CONTRACT_PACKAGE_NOT_EXPIRED"
CONTRACT_INSUFFICIENT_FUNDS = "CONTRACT_INSUFFICIENT_FUNDS"
CONTRACT_PACKAGE_ID_EXISTS = "CONTRACT_PACKAGE_ID_EXISTS"
CONTRACT_INVALID_STATE = "CONTRACT_INVALID_STATE"
CONTRACT_MISMATCHED_ARRAYS = "CONTRACT_MISMATCHED_ARRAYS"
CONTRACT_INSUFFICIENT_SURPLUS = "CONTRACT_INSUFFICIENT_SURPLUS"
CONTRACT_PAUSED = "CONTRACT_PAUSED"
CONTRACT_CLAIM_TOO_EARLY = "CONTRACT_CLAIM_TOO_EARLY"
CONTRACT_INVALID_PROOF = "CONTRACT_INVALID_PROOF"
CONTRACT_INVALID_TOKEN = "CONTRACT_INVALID_TOKEN"
CONTRACT_TOKEN_TRANSFER_FAILED = "CONTRACT_TOKEN_TRANSFER_FAILED"
CONTRACT_ALLOWLIST_EXPIRED = "CONTRACT_ALLOWLIST_EXPIRED"
CONTRACT_PROOF_TOO_LARGE = "CONTRACT_PROOF_TOO_LARGE"
CONTRACT_NO_PENDING_ADMIN = "CONTRACT_NO_PENDING_ADMIN"
CONTRACT_ADMIN_ROTATION_EXPIRED = "CONTRACT_ADMIN_ROTATION_EXPIRED"
CONTRACT_TOO_MANY_ALLOWED_TOKENS = "CONTRACT_TOO_MANY_ALLOWED_TOKENS"


# Numeric Soroban contract error → shared ErrorCode. Mirrors the
# TypeScript-side table in app/backend/src/common/errors/codes.ts.
CONTRACT_ERROR_CODE_BY_NUMBER: Dict[int, ErrorCode] = {
1: ErrorCode.CONTRACT_NOT_INITIALIZED,
2: ErrorCode.CONTRACT_ALREADY_INITIALIZED,
3: ErrorCode.CONTRACT_NOT_AUTHORIZED,
4: ErrorCode.CONTRACT_INVALID_AMOUNT,
5: ErrorCode.CONTRACT_PACKAGE_NOT_FOUND,
6: ErrorCode.CONTRACT_PACKAGE_NOT_ACTIVE,
7: ErrorCode.CONTRACT_PACKAGE_EXPIRED,
8: ErrorCode.CONTRACT_PACKAGE_NOT_EXPIRED,
9: ErrorCode.CONTRACT_INSUFFICIENT_FUNDS,
10: ErrorCode.CONTRACT_PACKAGE_ID_EXISTS,
11: ErrorCode.CONTRACT_INVALID_STATE,
12: ErrorCode.CONTRACT_MISMATCHED_ARRAYS,
13: ErrorCode.CONTRACT_INSUFFICIENT_SURPLUS,
14: ErrorCode.CONTRACT_PAUSED,
15: ErrorCode.CONTRACT_CLAIM_TOO_EARLY,
16: ErrorCode.CONTRACT_INVALID_PROOF,
17: ErrorCode.CONTRACT_INVALID_TOKEN,
18: ErrorCode.CONTRACT_TOKEN_TRANSFER_FAILED,
19: ErrorCode.CONTRACT_ALLOWLIST_EXPIRED,
20: ErrorCode.CONTRACT_PROOF_TOO_LARGE,
21: ErrorCode.CONTRACT_NO_PENDING_ADMIN,
22: ErrorCode.CONTRACT_ADMIN_ROTATION_EXPIRED,
23: ErrorCode.CONTRACT_TOO_MANY_ALLOWED_TOKENS,
}


# HTTP status code → ErrorCode mapping. Used by main.py to translate
# raised HTTPExceptions to the new shared taxonomy without sprinkling
# string literals through the error handlers.
HTTP_STATUS_TO_ERROR_CODE: Dict[int, ErrorCode] = {
400: ErrorCode.BAD_REQUEST,
401: ErrorCode.UNAUTHORIZED,
403: ErrorCode.FORBIDDEN,
404: ErrorCode.NOT_FOUND,
409: ErrorCode.CONFLICT,
413: ErrorCode.PAYLOAD_TOO_LARGE,
422: ErrorCode.VALIDATION_ERROR,
429: ErrorCode.RATE_LIMIT_EXCEEDED,
500: ErrorCode.INTERNAL_SERVER_ERROR,
502: ErrorCode.UPSTREAM_ERROR,
503: ErrorCode.UPSTREAM_UNAVAILABLE,
504: ErrorCode.UPSTREAM_TIMEOUT,
}


def error_code_for_http_status(status_code: int) -> ErrorCode:
"""Best-effort cross-stack mapping for HTTPException path.

Falls back to ``BAD_REQUEST`` for unmapped 4xx, ``UPSTREAM_ERROR``
for unmapped 5xx — never invent a new identifier at runtime.
"""
if status_code in HTTP_STATUS_TO_ERROR_CODE:
return HTTP_STATUS_TO_ERROR_CODE[status_code]
if 400 <= status_code < 500:
return ErrorCode.BAD_REQUEST
if 500 <= status_code < 600:
return ErrorCode.UPSTREAM_ERROR
return ErrorCode.INTERNAL_SERVER_ERROR
77 changes: 77 additions & 0 deletions app/ai-service/tests/test_error_codes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Parity test for cross-service error codes (Issue #249).

Asserts that:

* every member of ``ErrorCode`` has a matching ``- name:`` entry in
``docs/errors.yaml``,
* every ``- name:`` entry in ``docs/errors.yaml`` has a matching
``ErrorCode`` member,
* the numeric Soroban mapping covers variants 1..=23.
"""
from __future__ import annotations

import re
from pathlib import Path

from schemas.error_codes import (
CONTRACT_ERROR_CODE_BY_NUMBER,
ErrorCode,
error_code_for_http_status,
)


REPO_ROOT = Path(__file__).resolve().parents[3]
YAML_PATH = REPO_ROOT / "docs" / "errors.yaml"


def load_yaml_error_names() -> set[str]:
text = YAML_PATH.read_text(encoding="utf-8")
return set(re.findall(r"^\s*-\s*name:\s*([A-Z0-9_]+)\s*$", text, flags=re.MULTILINE))


class TestErrorCodeParity:
def test_python_enum_mirrors_yaml(self):
yaml_names = load_yaml_error_names()
assert yaml_names, f"docs/errors.yaml had no `- name:` entries at {YAML_PATH}"

enum_values = {member.value for member in ErrorCode}

missing_from_enum = yaml_names - enum_values
extra_in_enum = enum_values - yaml_names

assert not missing_from_enum, (
f"docs/errors.yaml lists codes missing from Python ErrorCode: "
f"{sorted(missing_from_enum)}"
)
assert not extra_in_enum, (
f"Python ErrorCode has names not in docs/errors.yaml: "
f"{sorted(extra_in_enum)}"
)

def test_enum_value_matches_member_name(self):
for member in ErrorCode:
assert member.value == member.name, (
f"{member.name} serialises to {member.value!r} (expected {member.name!r})"
)

def test_contract_numeric_table_covers_all_23_variants(self):
for code in range(1, 24):
assert code in CONTRACT_ERROR_CODE_BY_NUMBER, (
f"Soroban error code {code} is missing from CONTRACT_ERROR_CODE_BY_NUMBER"
)
table_value = CONTRACT_ERROR_CODE_BY_NUMBER[code]
# Must be a real ErrorCode member (no sentinel/None)
assert isinstance(table_value, ErrorCode)

def test_http_status_map_is_well_formed(self):
# Re-maps a representative subset of HTTP statuses that the
# FastAPI app raises today.
assert error_code_for_http_status(401) == ErrorCode.UNAUTHORIZED
assert error_code_for_http_status(404) == ErrorCode.NOT_FOUND
assert error_code_for_http_status(413) == ErrorCode.PAYLOAD_TOO_LARGE
assert error_code_for_http_status(422) == ErrorCode.VALIDATION_ERROR
assert error_code_for_http_status(502) == ErrorCode.UPSTREAM_ERROR
assert error_code_for_http_status(504) == ErrorCode.UPSTREAM_TIMEOUT
# Unknown codes fall into the safest bucket, never a new name.
assert error_code_for_http_status(418) == ErrorCode.BAD_REQUEST
assert error_code_for_http_status(599) == ErrorCode.UPSTREAM_ERROR
19 changes: 12 additions & 7 deletions app/ai-service/tests/test_error_envelope.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
"""
Tests for the standardized error response envelope (Issue #244).
Tests for the standardized error response envelope (Issue #244, #249).

Every error response must conform to:
{"error": {"code": str, "message": str, "details": any|null}}

The ``code`` strings are sourced from ``schemas.error_codes.ErrorCode``
so the same identifier is emitted for the same error class on both
this service and the NestJS backend.
"""
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from main import app
from exceptions import AIServiceError
from schemas.error_codes import ErrorCode

client = TestClient(app, raise_server_exceptions=False)

Expand All @@ -27,7 +32,7 @@ class TestNotFound:
def test_404_shape(self):
r = client.get("/v1/ai/nonexistent-route-xyz")
assert r.status_code == 404
assert_envelope(r.json(), "HTTP_404")
assert_envelope(r.json(), ErrorCode.NOT_FOUND.value)

def test_404_message_is_string(self):
r = client.get("/v1/ai/nonexistent-route-xyz")
Expand All @@ -45,7 +50,7 @@ def test_422_shape(self):
r = client.post("/v1/ai/inference", content="not-json",
headers={"Content-Type": "application/json"})
assert r.status_code == 422
assert_envelope(r.json(), "VALIDATION_ERROR")
assert_envelope(r.json(), ErrorCode.VALIDATION_ERROR.value)

def test_422_details_present(self):
r = client.post("/v1/ai/inference", content="not-json",
Expand All @@ -67,7 +72,7 @@ async def _raise_401():

r = client.get("/_test/401")
assert r.status_code == 401
assert_envelope(r.json(), "HTTP_401")
assert_envelope(r.json(), ErrorCode.UNAUTHORIZED.value)


# ---------------------------------------------------------------------------
Expand All @@ -79,13 +84,13 @@ def test_ai_error_shape(self):
async def _raise_ai_error():
raise AIServiceError(
message="LLM request timed out",
code="AI_TIMEOUT",
code=ErrorCode.AI_TIMEOUT,
details={"provider": "openai", "timeout_seconds": 30},
)

r = client.get("/_test/ai-failure")
assert r.status_code == 502
assert_envelope(r.json(), "AI_TIMEOUT")
assert_envelope(r.json(), ErrorCode.AI_TIMEOUT.value)

def test_ai_error_details(self):
r = client.get("/_test/ai-failure")
Expand All @@ -103,7 +108,7 @@ async def _raise_500():

r = client.get("/_test/500")
assert r.status_code == 500
assert_envelope(r.json(), "INTERNAL_SERVER_ERROR")
assert_envelope(r.json(), ErrorCode.INTERNAL_SERVER_ERROR.value)

def test_500_no_leak(self):
"""Internal error details must not leak to the client."""
Expand Down
4 changes: 2 additions & 2 deletions app/ai-service/tests/test_request_body_limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ def test_oversized_chunk_alone_is_413(self):
def test_observed_body_larger_than_content_length_returns_400(self):
"""If the client declares a Content-Length but streams more bytes
than declared, the request must immediately fail with HTTP 400
and CODE_BODY_LENGTH_MISMATCH (Issue #216)."""
and BODY_LENGTH_MISMATCH (Issue #216)."""
middleware = MaxRequestBodySizeMiddleware(app=_PassthroughApp(), max_bytes=1024)
scope = _make_scope(
headers=[(b"content-length", b"10")],
Expand All @@ -246,7 +246,7 @@ def test_observed_body_larger_than_content_length_returns_400(self):
sent = _run_middleware(middleware, scope, chunks)
assert sent[0]["status"] == 400
body = json.loads(b"".join(m["body"] for m in sent if m["type"] == "http.response.body"))
assert body["error"]["code"] == "CODE_BODY_LENGTH_MISMATCH"
assert body["error"]["code"] == "BODY_LENGTH_MISMATCH"
assert "15 bytes" in body["error"]["message"]
assert "10 bytes" in body["error"]["message"]

Expand Down
Loading
Loading