diff --git a/app/ai-service/exceptions.py b/app/ai-service/exceptions.py index ff15ecd4..675d80ea 100644 --- a/app/ai-service/exceptions.py +++ b/app/ai-service/exceptions.py @@ -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: diff --git a/app/ai-service/main.py b/app/ai-service/main.py index e20f8bd5..b09ae360 100644 --- a/app/ai-service/main.py +++ b/app/ai-service/main.py @@ -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 @@ -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() @@ -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() @@ -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_`` 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(), ) @@ -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(), ) @@ -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(), ) diff --git a/app/ai-service/schemas/error_codes.py b/app/ai-service/schemas/error_codes.py new file mode 100644 index 00000000..d2d11e0d --- /dev/null +++ b/app/ai-service/schemas/error_codes.py @@ -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 diff --git a/app/ai-service/tests/test_error_codes.py b/app/ai-service/tests/test_error_codes.py new file mode 100644 index 00000000..00ed3907 --- /dev/null +++ b/app/ai-service/tests/test_error_codes.py @@ -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 diff --git a/app/ai-service/tests/test_error_envelope.py b/app/ai-service/tests/test_error_envelope.py index faeb74d4..ef3948ed 100644 --- a/app/ai-service/tests/test_error_envelope.py +++ b/app/ai-service/tests/test_error_envelope.py @@ -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) @@ -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") @@ -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", @@ -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) # --------------------------------------------------------------------------- @@ -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") @@ -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.""" diff --git a/app/ai-service/tests/test_request_body_limit.py b/app/ai-service/tests/test_request_body_limit.py index 97b796da..7e783d5d 100644 --- a/app/ai-service/tests/test_request_body_limit.py +++ b/app/ai-service/tests/test_request_body_limit.py @@ -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")], @@ -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"] diff --git a/app/backend/ERROR_HANDLING.md b/app/backend/ERROR_HANDLING.md index 2f137f5a..f9ebb153 100644 --- a/app/backend/ERROR_HANDLING.md +++ b/app/backend/ERROR_HANDLING.md @@ -13,20 +13,41 @@ Every error response follows this shape: "code": 400, "message": "Human-readable error message", "details": { "...additional context..." }, + "errorCode": "VALIDATION_ERROR", "traceId": "M1ABC2DEF3G", "timestamp": "2026-01-23T12:30:00.000Z", "path": "/api/v1/resource" } ``` -| Field | Type | Description | -|-------------|------------------|---------------------------------------------------| -| `code` | `number` | HTTP status code | -| `message` | `string` | Human-readable error message | -| `details` | `object \| null` | Additional error-specific information (optional) | -| `traceId` | `string` | Request trace ID from `X-Request-ID` header | -| `timestamp` | `string` | ISO 8601 timestamp of when the error occurred | -| `path` | `string` | The API endpoint that caused the error | +| Field | Type | Description | +|-------------|------------------|------------------------------------------------------------------------------------------| +| `code` | `number` | HTTP status code | +| `message` | `string` | Human-readable error message | +| `details` | `object \| null` | Additional error-specific information (optional) | +| `errorCode` | `string` | Cross-stack taxonomy identifier (Issue #249). Optional. Mirrors the AI-service `ErrorEnvelope.code`. | +| `traceId` | `string` | Request trace ID from `X-Request-ID` header | +| `timestamp` | `string` | ISO 8601 timestamp of when the error occurred | +| `path` | `string` | The API endpoint that caused the error | + +### Cross-stack taxonomy (`errorCode`) + +`errorCode` is the shared identifier emitted by **both** this backend +and the AI service (FastAPI). The canonical list lives in +`docs/errors.yaml` and is mirrored by: + +- `src/common/errors/codes.ts` (`ErrorCode` enum on the backend) +- `app/ai-service/schemas/error_codes.py` (`ErrorCode` enum on the AI service) + +Both apps ship parity tests that assert every name in `docs/errors.yaml` +is present in both enums. When HTTP status alone is insufficient for +mobile/web branching on a stable error class — e.g. distinguishing +`CONTRACT_TOO_MANY_ALLOWED_TOKENS` from a generic 413 — clients should +prefer `errorCode` over `code`. + +> Any new error identifier **must** be added to `docs/errors.yaml` *and* +> both enums (`src/common/errors/codes.ts`, `app/ai-service/schemas/error_codes.py`) +> in the same PR — the parity tests will fail otherwise. ## Architecture diff --git a/app/backend/src/common/dto/api-response.dto.ts b/app/backend/src/common/dto/api-response.dto.ts index a07d6553..430b3295 100644 --- a/app/backend/src/common/dto/api-response.dto.ts +++ b/app/backend/src/common/dto/api-response.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ErrorCode } from '../errors/codes'; export class ApiResponseDto { @ApiProperty({ @@ -24,6 +25,22 @@ export class ApiResponseDto { }) error?: unknown; + /** + * Cross-stack taxonomy identifier (Issue #249). + * + * Optional companion to `error` that mirrors the AI-service + * `ErrorEnvelope.code` field. Same string is emitted by the AI + * service for the same error class so mobile/web clients can branch + * on a stable identifier regardless of which backend raised it. + * See `docs/errors.yaml` for the canonical list. + */ + @ApiPropertyOptional({ + description: + 'Cross-stack taxonomy identifier (mirrors AI-service ErrorEnvelope.code).', + example: ErrorCode.VALIDATION_ERROR, + }) + errorCode?: ErrorCode; + static ok(data: T, message?: string): ApiResponseDto { return { success: true, message, data }; } diff --git a/app/backend/src/common/errors/codes.spec.ts b/app/backend/src/common/errors/codes.spec.ts new file mode 100644 index 00000000..c3dc1c1c --- /dev/null +++ b/app/backend/src/common/errors/codes.spec.ts @@ -0,0 +1,55 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { ErrorCode, CONTRACT_ERROR_CODE_BY_NUMBER } from './codes'; + +/** + * Reads `docs/errors.yaml` from the repo root and pulls every entry that + * looks like ` - name: FOO` out of it. We deliberately do this without + * a YAML dependency so the test remains cheap and dependency-free. + */ +function loadErrorNamesFromYaml(): Set { + const file = path.resolve(__dirname, '../../../../../docs/errors.yaml'); + const text = fs.readFileSync(file, 'utf8'); + const names = new Set(); + const regex = /^\s*-\s*name:\s*([A-Z0-9_]+)\s*$/gm; + let match: RegExpExecArray | null; + while ((match = regex.exec(text)) !== null) { + names.add(match[1]); + } + return names; +} + +describe('ErrorCode enum parity with docs/errors.yaml (Issue #249)', () => { + const enumValues = new Set(Object.values(ErrorCode)); + + it('every ErrorCode enum value mirrors a `- name:` entry in docs/errors.yaml', () => { + const yamlNames = loadErrorNamesFromYaml(); + expect(yamlNames.size).toBeGreaterThan(0); + + for (const value of enumValues) { + expect(yamlNames.has(value)).toBe(true); + } + }); + + it('every `- name:` entry in docs/errors.yaml is mirrored by ErrorCode', () => { + const yamlNames = loadErrorNamesFromYaml(); + for (const name of yamlNames) { + expect(enumValues.has(name)).toBe(true); + } + }); + + it('every ErrorCode enum value is the exact string of its key', () => { + for (const [key, value] of Object.entries(ErrorCode)) { + expect(value).toBe(key); + } + }); + + it('CONTRACT_ERROR_CODE_BY_NUMBER covers all numeric slope variants 1..=23', () => { + for (let i = 1; i <= 23; i++) { + expect(CONTRACT_ERROR_CODE_BY_NUMBER[i]).toBeDefined(); + expect(Object.values(ErrorCode)).toContain( + CONTRACT_ERROR_CODE_BY_NUMBER[i], + ); + } + }); +}); diff --git a/app/backend/src/common/errors/codes.ts b/app/backend/src/common/errors/codes.ts new file mode 100644 index 00000000..063726b2 --- /dev/null +++ b/app/backend/src/common/errors/codes.ts @@ -0,0 +1,100 @@ +/** + * Cross-service error-code taxonomy (Issue #249). + * + * Canonical source: docs/errors.yaml. This enum is a hand-mirrored + * view of that file. The parity test in `codes.spec.ts` asserts every + * name in docs/errors.yaml appears here and vice-versa, so a typo or + * forgotten rename fails CI rather than silently drifting apart. + * + * Naming convention: SCREAMING_SNAKE_CASE strings. Each value is the + * literal string `""` so a JSON serialised enum value is + * directly comparable across stacks. + * + * Backend usage: + * The NestJS exception filter attaches the corresponding `ErrorCode` + * to every `ErrorResponse` via the optional `errorCode` field + * alongside the HTTP numeric `code`. + * + * Cross-service parity: + * app/ai-service/schemas/error_codes.py mirrors this enum verbatim. + * Tests on both sides assert same name → same string. + */ +export enum ErrorCode { + // 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 code (the `#[repr(u32)]` discriminant + * from `aid_escrow::Error`) → shared `ErrorCode`. + * + * Mirrors `app/backend/contracts/aid_escrow/src/lib.rs::Error` and is + * the single source of truth for translating numeric contract errors + * to the cross-service taxonomy before forwarding to a downstream + * service or external client. + */ +export const CONTRACT_ERROR_CODE_BY_NUMBER: Readonly> = + Object.freeze({ + 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, + }); diff --git a/app/backend/src/common/filters/http-exception.filter.ts b/app/backend/src/common/filters/http-exception.filter.ts index 0a015d00..9c5bba02 100644 --- a/app/backend/src/common/filters/http-exception.filter.ts +++ b/app/backend/src/common/filters/http-exception.filter.ts @@ -9,6 +9,7 @@ import { import { Request, Response } from 'express'; import { ValidationError } from 'class-validator'; import { LoggerService } from '../../logger/logger.service'; +import { ErrorCode } from '../errors/codes'; export interface ErrorResponse { code: number; @@ -17,6 +18,13 @@ export interface ErrorResponse { traceId?: string; timestamp: string; path: string; + /** + * Cross-service taxonomy identifier (Issue #249). Same identifier is + * emitted by the AI service's `ErrorEnvelope.code` so callers can + * match errors across stacks. See `src/common/errors/codes.ts` for + * the canonical list and `docs/errors.yaml` for the source of truth. + */ + errorCode?: ErrorCode; } @Injectable() @@ -74,6 +82,7 @@ export class AllExceptionsFilter implements ExceptionFilter { message: typeof message === 'string' ? message : JSON.stringify(message), details: typeof exceptionResponse === 'object' ? exceptionResponse : undefined, + errorCode: statusToErrorCode(status), traceId, timestamp: new Date().toISOString(), path: request.url, @@ -96,12 +105,15 @@ export class AllExceptionsFilter implements ExceptionFilter { let code = HttpStatus.INTERNAL_SERVER_ERROR; let message = 'Database error occurred'; let details: any = null; + // Cross-service taxonomy code (Issue #249). Defaulted below per branch. + let errorCode: ErrorCode = ErrorCode.INTERNAL_SERVER_ERROR; // Map common Prisma errors if (exception.code === 'P2002') { // Unique constraint failed code = HttpStatus.CONFLICT; message = 'Unique constraint violation'; + errorCode = ErrorCode.CONFLICT; details = { target: exception.meta?.target, field: Array.isArray(exception.meta?.target) @@ -112,6 +124,7 @@ export class AllExceptionsFilter implements ExceptionFilter { // Record not found code = HttpStatus.NOT_FOUND; message = 'Record not found'; + errorCode = ErrorCode.NOT_FOUND; details = { cause: exception.meta?.cause, }; @@ -119,6 +132,7 @@ export class AllExceptionsFilter implements ExceptionFilter { // Foreign key constraint failed code = HttpStatus.BAD_REQUEST; message = 'Foreign key constraint violation'; + errorCode = ErrorCode.BAD_REQUEST; details = { field_name: exception.meta?.field_name, }; @@ -126,6 +140,7 @@ export class AllExceptionsFilter implements ExceptionFilter { // Value too long for column code = HttpStatus.BAD_REQUEST; message = 'Value too long for column'; + errorCode = ErrorCode.BAD_REQUEST; details = { column_name: exception.meta?.column_name, }; @@ -140,6 +155,7 @@ export class AllExceptionsFilter implements ExceptionFilter { code, message, details, + errorCode, traceId, timestamp: new Date().toISOString(), path: request.url, @@ -166,6 +182,7 @@ export class AllExceptionsFilter implements ExceptionFilter { details: { errors: validationErrors, }, + errorCode: ErrorCode.VALIDATION_ERROR, traceId, timestamp: new Date().toISOString(), path: request.url, @@ -198,9 +215,56 @@ export class AllExceptionsFilter implements ExceptionFilter { stack: exception.stack, }), }, + errorCode: ErrorCode.INTERNAL_SERVER_ERROR, traceId, timestamp: new Date().toISOString(), path: request.url, }; } } + +/** + * Maps an HTTP status code to a shared `ErrorCode` for the new + * cross-service taxonomy (Issue #249). Stable mapping; never invents + * a new identifier at runtime. + */ +function statusToErrorCode(status: number): ErrorCode { + switch (status) { + case 400: + return ErrorCode.BAD_REQUEST; + case 401: + return ErrorCode.UNAUTHORIZED; + case 403: + return ErrorCode.FORBIDDEN; + case 404: + return ErrorCode.NOT_FOUND; + case 408: + return ErrorCode.UPSTREAM_TIMEOUT; + case 409: + return ErrorCode.CONFLICT; + case 413: + return ErrorCode.PAYLOAD_TOO_LARGE; + case 422: + return ErrorCode.VALIDATION_ERROR; + case 429: + return ErrorCode.RATE_LIMIT_EXCEEDED; + case 500: + return ErrorCode.INTERNAL_SERVER_ERROR; + case 501: + return ErrorCode.UPSTREAM_ERROR; + case 502: + return ErrorCode.UPSTREAM_ERROR; + case 503: + return ErrorCode.UPSTREAM_UNAVAILABLE; + case 504: + return ErrorCode.UPSTREAM_TIMEOUT; + default: + if (status >= 400 && status < 500) { + return ErrorCode.BAD_REQUEST; + } + if (status >= 500 && status < 600) { + return ErrorCode.UPSTREAM_ERROR; + } + return ErrorCode.INTERNAL_SERVER_ERROR; + } +} diff --git a/app/backend/src/onchain/utils/soroban-error.mapper.spec.ts b/app/backend/src/onchain/utils/soroban-error.mapper.spec.ts index 8ed38f6a..3b75b9df 100644 --- a/app/backend/src/onchain/utils/soroban-error.mapper.spec.ts +++ b/app/backend/src/onchain/utils/soroban-error.mapper.spec.ts @@ -61,4 +61,127 @@ describe('SorobanErrorMapper', () => { }, }); }); + + // ---------------------------------------------------------------------- + // Issue #233: numeric code 23 (`TooManyAllowedTokens`) is mapped so the + // backend never falls back to the catch-all 500 for `set_config` calls + // that exceed the length cap. + // ---------------------------------------------------------------------- + it('maps too-many-allowed-tokens (#23) to HTTP 413', () => { + expect(mapper.mapError({ errorCode: 23 })).toEqual({ + statusCode: 413, + message: 'Allowed-tokens list exceeds maximum length', + details: { + error_code: 23, + error_type: 'contract_error', + }, + }); + }); + + it('maps merkle allowlist expiry (#19) to HTTP 410', () => { + expect(mapper.mapError({ errorCode: 19 })).toEqual({ + statusCode: 410, + message: 'Merkle allowlist has expired', + details: { + error_code: 19, + error_type: 'contract_error', + }, + }); + }); + + it('maps oversized proof (#20) to HTTP 400', () => { + expect(mapper.mapError({ errorCode: 20 })).toEqual({ + statusCode: 400, + message: 'Claim proof exceeds maximum depth', + details: { + error_code: 20, + error_type: 'contract_error', + }, + }); + }); + + it('maps no pending admin (#21) to HTTP 400', () => { + expect(mapper.mapError({ errorCode: 21 })).toEqual({ + statusCode: 400, + message: 'No admin rotation is pending', + details: { + error_code: 21, + error_type: 'contract_error', + }, + }); + }); + + it('maps admin rotation expired (#22) to HTTP 400', () => { + expect(mapper.mapError({ errorCode: 22 })).toEqual({ + statusCode: 400, + message: 'Admin rotation deadline has passed', + details: { + error_code: 22, + error_type: 'contract_error', + }, + }); + }); + + // ---------------------------------------------------------------------- + // String-based fallback paths for codes 19..23. The numeric mapper + // ({errorCode: N}) is the primary path; these tests pin the secondary + // mapContractErrorMessage() fallback so the new errors never silently + // fall through to the catch-all 500 when an upstream layer surfaces + // them as a textual `Error(Contract, #N)` or + // `Error(Contract, VariantName)` form. + // ---------------------------------------------------------------------- + it.each([ + [ + 19, + 'AllowlistExpired', + 410, + 'Merkle allowlist has expired', + ], + [20, 'ProofTooLarge', 400, 'Claim proof exceeds maximum depth'], + [21, 'NoPendingAdmin', 400, 'No admin rotation is pending'], + [ + 22, + 'AdminRotationExpired', + 400, + 'Admin rotation deadline has passed', + ], + [ + 23, + 'TooManyAllowedTokens', + 413, + 'Allowed-tokens list exceeds maximum length', + ], + ])( + 'maps code #%i variant %s by name (string fallback) to HTTP %i', + (code, variant, expectedStatus, expectedMessage) => { + expect( + mapper.mapError( + new Error(`HostError: Error(Contract, #${code}) ${variant}`), + ), + ).toMatchObject({ + statusCode: expectedStatus, + message: expectedMessage, + details: { + error_name: variant, + error_type: 'contract_error', + }, + }); + }, + ); + + it('falls back to numeric regex for #23 even when the variant name is absent', () => { + // Simulate a Soroban RPC that surfaces only the numeric token, + // no Rust variant name — second loop of mapContractErrorMessage + // catches it via `#N(?!\\d)`. + expect( + mapper.mapError(new Error('HostError: Error(Contract, #23)')), + ).toMatchObject({ + statusCode: 413, + message: 'Allowed-tokens list exceeds maximum length', + details: { + error_code: 23, + error_type: 'contract_error', + }, + }); + }); }); diff --git a/app/backend/src/onchain/utils/soroban-error.mapper.ts b/app/backend/src/onchain/utils/soroban-error.mapper.ts index 720f996c..36782a20 100644 --- a/app/backend/src/onchain/utils/soroban-error.mapper.ts +++ b/app/backend/src/onchain/utils/soroban-error.mapper.ts @@ -26,6 +26,12 @@ interface SorobanErrorLike { export class SorobanErrorMapper { /** * Soroban contract error codes from AidEscrow (Rust contract) + * + * Numeric identifiers are the `#[repr(u32)]` discriminants from + * `aid_escrow::Error` in + * `app/onchain/contracts/aid_escrow/src/lib.rs`. The taxonomy is + * intentionally comprehensive — every variant (1..=23) is mapped so + * runtime errors never silently fall back to the catch-all 500. */ private readonly contractErrors: Record< number, @@ -52,6 +58,13 @@ export class SorobanErrorMapper { 16: { code: 400, message: 'Invalid claim proof' }, 17: { code: 400, message: 'Invalid token contract address' }, 18: { code: 502, message: 'Token transfer failed' }, + // Merkle allowlist / proof-size errors (added with admin handover). + 19: { code: 410, message: 'Merkle allowlist has expired' }, + 20: { code: 400, message: 'Claim proof exceeds maximum depth' }, + 21: { code: 400, message: 'No admin rotation is pending' }, + 22: { code: 400, message: 'Admin rotation deadline has passed' }, + // Issue #233: `Config.allowed_tokens` exceeded the maximum length. + 23: { code: 413, message: 'Allowed-tokens list exceeds maximum length' }, }; /** @@ -241,6 +254,31 @@ export class SorobanErrorMapper { InvalidProof: { code: 400, message: 'Invalid claim proof' }, InvalidToken: { code: 400, message: 'Invalid token contract address' }, TokenTransferFailed: { code: 502, message: 'Token transfer failed' }, + // Merkle allowlist / proof-size errors (added with admin handover). + // Without these string-form fallbacks, the JSON-RPC error path + // and ordinary Error(Contract, #N) log messages silently fall + // through to the catch-all 500 instead of the proper status. + AllowlistExpired: { + code: 410, + message: 'Merkle allowlist has expired', + }, + ProofTooLarge: { + code: 400, + message: 'Claim proof exceeds maximum depth', + }, + NoPendingAdmin: { + code: 400, + message: 'No admin rotation is pending', + }, + AdminRotationExpired: { + code: 400, + message: 'Admin rotation deadline has passed', + }, + // Issue #233: ``Config.allowed_tokens`` exceeded the cap. + TooManyAllowedTokens: { + code: 413, + message: 'Allowed-tokens list exceeds maximum length', + }, }; for (const [errorKey, errorInfo] of Object.entries(errorMap)) { diff --git a/app/onchain/contracts/aid_escrow/src/lib.rs b/app/onchain/contracts/aid_escrow/src/lib.rs index 981e1fb7..2b7f5d07 100644 --- a/app/onchain/contracts/aid_escrow/src/lib.rs +++ b/app/onchain/contracts/aid_escrow/src/lib.rs @@ -44,6 +44,14 @@ const KEY_PENDING_ADMIN: Symbol = symbol_short!("pendadm"); const KEY_ADMIN_DEADLINE: Symbol = symbol_short!("admdln"); const DEFAULT_ADMIN_DEADLINE: u64 = 7 * 24 * 60 * 60; // 7 days in seconds +/// Maximum number of tokens that can appear in `Config.allowed_tokens`. +/// +/// Bound keeps `set_config` from bloating the contract's WASM instance +/// storage (Soroban charges per-byte for instance entries). 32 covers +/// all sensible humanitarian-aid corridors without forcing admins to +/// shard the allowlist across multiple contracts. +pub const MAX_ALLOWED_TOKENS: u32 = 32; + // --- Data Types --- #[contracttype] @@ -114,6 +122,8 @@ pub enum Error { ProofTooLarge = 20, NoPendingAdmin = 21, AdminRotationExpired = 22, + /// `Config.allowed_tokens` exceeded [`MAX_ALLOWED_TOKENS`]. + TooManyAllowedTokens = 23, } // --- Contract Events (indexer-friendly; stable topics & payloads) --- @@ -424,6 +434,13 @@ impl AidEscrow { return Err(Error::InvalidAmount); } + // Bound the allowlist before invoking any token contract — this + // check is cheap (just a vector length comparison) and bailing + // out here avoids 33+ host invocations on bogus addresses. + if config.allowed_tokens.len() > MAX_ALLOWED_TOKENS { + return Err(Error::TooManyAllowedTokens); + } + for i in 0..config.allowed_tokens.len() { let token = config.allowed_tokens.get(i).ok_or(Error::InvalidToken)?; Self::validate_token(&env, &token)?; diff --git a/app/onchain/contracts/aid_escrow/tests/aid_escrow_tests.rs b/app/onchain/contracts/aid_escrow/tests/aid_escrow_tests.rs index e7b29212..dc18e9b5 100644 --- a/app/onchain/contracts/aid_escrow/tests/aid_escrow_tests.rs +++ b/app/onchain/contracts/aid_escrow/tests/aid_escrow_tests.rs @@ -667,3 +667,63 @@ fn test_claim_with_proof_oversized_fails() { assert_eq!(result, Err(Ok(crate::Error::ProofTooLarge))); } + +// =========================================================================== +// set_config — allowed_tokens length cap (Issue #233) +// =========================================================================== + +/// `set_config` MUST reject an `allowed_tokens` list longer than +/// [`aid_escrow::MAX_ALLOWED_TOKENS`] with `Error::TooManyAllowedTokens` +/// — even when the addresses are not valid token contracts. The new +/// length gate runs BEFORE per-token validation so the rejected test +/// inputs cannot accidentally inflate host-invocation costs. +#[test] +fn set_config_rejects_too_many_allowed_tokens() { + let t = TestSetup::new(); + + let mut too_many = Vec::new(&t.env); + for _ in 0..(super::MAX_ALLOWED_TOKENS + 1) { + too_many.push_back(Address::generate(&t.env)); + } + + let result = t.client.try_set_config(&Config { + min_amount: 1, + max_expires_in: 0, + allowed_tokens: too_many, + }); + assert_eq!(result, Err(Ok(Error::TooManyAllowedTokens))); +} + +/// Exactly `MAX_ALLOWED_TOKENS` random addresses must pass the new +/// length gate. Per-token validation still fails (random addresses are +/// not contracts), but the failure type MUST be `InvalidToken` rather +/// than `TooManyAllowedTokens`, pinning the threshold at exactly 32. +#[test] +fn set_config_threshold_is_exactly_max_allowed_tokens() { + let t = TestSetup::new(); + + let mut exactly_max = Vec::new(&t.env); + for _ in 0..super::MAX_ALLOWED_TOKENS { + exactly_max.push_back(Address::generate(&t.env)); + } + + let result = t.client.try_set_config(&Config { + min_amount: 1, + max_expires_in: 0, + allowed_tokens: exactly_max, + }); + assert!( + matches!(result, Err(Ok(Error::InvalidToken))), + "expected per-token InvalidToken at length {}, got {:?}", + super::MAX_ALLOWED_TOKENS, + result, + ); +} + +/// `MAX_ALLOWED_TOKENS` must equal 32 — the value the issue tracker +/// accepted. This test guards against accidental drift if someone +/// tweaks the constant in a future PR. +#[test] +fn max_allowed_tokens_constant_is_32() { + assert_eq!(super::MAX_ALLOWED_TOKENS, 32); +} diff --git a/docs/errors.yaml b/docs/errors.yaml new file mode 100644 index 00000000..8fd7a9d5 --- /dev/null +++ b/docs/errors.yaml @@ -0,0 +1,107 @@ +# docs/errors.yaml +# +# Canonical cross-service error-code taxonomy (Issue #249). +# +# This file is the human-readable single source of truth for error codes +# that cross the backend (NestJS / TypeScript) and the AI service +# (FastAPI / Python). Both apps hand-mirror the names listed here in: +# +# - app/backend/src/common/errors/codes.ts (TypeScript `ErrorCode` enum) +# - app/ai-service/schemas/error_codes.py (Python `ErrorCode` enum) +# +# Each service ships a parity test that asserts every name below is +# represented in both enums. New codes must be added here FIRST, then +# mirrored in both enums. +# +# Scope notes: +# * `cross_service` — generic HTTP / cross-stack error classes emitted +# by both services (validation, not_found, payload_too_large, etc.). +# * `contract` — semantic identifiers for every `Error` variant in +# the on-chain `aid_escrow` Soroban contract (lib.rs). These mirror +# numeric codes 1..=23 emitted on-chain. +# +# Numeric HTTP status codes are advisory only; the in-tree exception +# filters own the final status mapping. + +cross_service: + - name: VALIDATION_ERROR + http_status: 422 + - name: NOT_FOUND + http_status: 404 + - name: UNAUTHORIZED + http_status: 401 + - name: FORBIDDEN + http_status: 403 + - name: CONFLICT + http_status: 409 + - name: BAD_REQUEST + http_status: 400 + - name: PAYLOAD_TOO_LARGE + http_status: 413 + - name: BODY_LENGTH_MISMATCH + http_status: 400 + - name: INTERNAL_SERVER_ERROR + http_status: 500 + - name: UPSTREAM_TIMEOUT + http_status: 504 + - name: UPSTREAM_UNAVAILABLE + http_status: 503 + - name: UPSTREAM_ERROR + http_status: 502 + - name: AI_SERVICE_ERROR + http_status: 502 + - name: AI_TIMEOUT + http_status: 504 + - name: RATE_LIMIT_EXCEEDED + http_status: 429 + +contract: + # Numeric identifiers correspond to `Error` discriminant values in + # app/onchain/contracts/aid_escrow/src/lib.rs. Update both this list + # AND the Rust enum together. + - name: CONTRACT_NOT_INITIALIZED # 1 + http_status: 400 + - name: CONTRACT_ALREADY_INITIALIZED # 2 + http_status: 409 + - name: CONTRACT_NOT_AUTHORIZED # 3 + http_status: 403 + - name: CONTRACT_INVALID_AMOUNT # 4 + http_status: 400 + - name: CONTRACT_PACKAGE_NOT_FOUND # 5 + http_status: 404 + - name: CONTRACT_PACKAGE_NOT_ACTIVE # 6 + http_status: 400 + - name: CONTRACT_PACKAGE_EXPIRED # 7 + http_status: 410 + - name: CONTRACT_PACKAGE_NOT_EXPIRED # 8 + http_status: 400 + - name: CONTRACT_INSUFFICIENT_FUNDS # 9 + http_status: 400 + - name: CONTRACT_PACKAGE_ID_EXISTS # 10 + http_status: 409 + - name: CONTRACT_INVALID_STATE # 11 + http_status: 400 + - name: CONTRACT_MISMATCHED_ARRAYS # 12 + http_status: 400 + - name: CONTRACT_INSUFFICIENT_SURPLUS # 13 + http_status: 400 + - name: CONTRACT_PAUSED # 14 + http_status: 503 + - name: CONTRACT_CLAIM_TOO_EARLY # 15 + http_status: 400 + - name: CONTRACT_INVALID_PROOF # 16 + http_status: 400 + - name: CONTRACT_INVALID_TOKEN # 17 + http_status: 400 + - name: CONTRACT_TOKEN_TRANSFER_FAILED # 18 + http_status: 502 + - name: CONTRACT_ALLOWLIST_EXPIRED # 19 + http_status: 410 + - name: CONTRACT_PROOF_TOO_LARGE # 20 + http_status: 400 + - name: CONTRACT_NO_PENDING_ADMIN # 21 + http_status: 400 + - name: CONTRACT_ADMIN_ROTATION_EXPIRED # 22 + http_status: 400 + - name: CONTRACT_TOO_MANY_ALLOWED_TOKENS # 23 (Issue #233) + http_status: 413