Skip to content

Commit 80baacf

Browse files
committed
feat(backend,ai,onchain): resolve #249 and #233 in one PR
#249 — Shared error-code taxonomy * docs/errors.yaml: single source of truth for every error code we emit across backend and AI service. * app/backend/src/common/errors/codes.ts: TypeScript enum + meta table + codeForHttpStatus/httpStatusForCode helpers. * app/backend/src/common/errors/codes.spec.ts: parity test that walks docs/errors.yaml and asserts the TS binding matches every entry, plus a first-declared-wins lookup lock. * app/ai-service/schemas/codes.py: Python mirror of the TS binding (enum + dataclass meta + identical reverse-lookup semantics). * app/ai-service/tests/test_codes.py: parity test mirroring codes.spec.ts. Both YAML parsers are byte-for-byte equivalent in shape and produce the same set of entries for docs/errors.yaml. * app/ai-service/main.py: HTTPException, validation, body-size, and fallback exception handlers now route through ErrorCode.<X>.value / code_for_http_status(...) instead of ad-hoc literal strings. * app/backend/src/common/filters/http-exception.filter.ts: doc comment pinning the wire-format contract (code: number, the existing public envelope shape) so a future PR cannot accidentally break it. #233 — Cap allowed_tokens at MAX_ALLOWED_TOKENS * app/onchain/contracts/aid_escrow/src/lib.rs: - new pub const MAX_ALLOWED_TOKENS: u32 = 32 (re-exported for tests). - new Error::TooManyAllowedTokens = 23 variant. - set_config rejects oversized allowlists with Error::TooManyAllowedTokens BEFORE the per-token validate_token loop, so a malicious admin can neither bloat wasm storage nor burn host cost. * app/onchain/contracts/aid_escrow/tests/aid_escrow_tests.rs: - set_config_rejects_too_many_allowed_tokens (vec of length MAX + 1, asserts TooManyAllowedTokens) - set_config_passes_cap_check_at_max_boundary (vec of length MAX made of dummy AidEscrow writes to lmts, asserts InvalidToken rather than TooManyAllowedTokens to prove the cap is exclusive). Both tests reference aid_escrow::MAX_ALLOWED_TOKENS directly so future tweaks to the cap cannot silently drift out of spec. Both contracts public wire formats are unchanged. Closes #249 Closes #233
1 parent 243f30d commit 80baacf

9 files changed

Lines changed: 1079 additions & 5 deletions

File tree

app/ai-service/main.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from fastapi.responses import JSONResponse, RedirectResponse, Response
1818
from exceptions import AIServiceError
1919
from schemas.errors import ErrorDetail, ErrorEnvelope
20+
from schemas.codes import ErrorCode, code_for_http_status # Issue #249
2021
import time
2122
import metrics
2223
import email.utils
@@ -348,7 +349,7 @@ async def _send_413(self, send, observed: int, reason: str, limit: Optional[int]
348349

349350
envelope = ErrorEnvelope(
350351
error=ErrorDetail(
351-
code="PAYLOAD_TOO_LARGE",
352+
code=ErrorCode.PAYLOAD_TOO_LARGE.value,
352353
message=msg,
353354
)
354355
).model_dump()
@@ -376,7 +377,7 @@ async def _send_400_mismatch(self, send, declared: int, observed: int):
376377
)
377378
envelope = ErrorEnvelope(
378379
error=ErrorDetail(
379-
code="CODE_BODY_LENGTH_MISMATCH",
380+
code=ErrorCode.CODE_BODY_LENGTH_MISMATCH.value,
380381
message=msg,
381382
)
382383
).model_dump()
@@ -1036,7 +1037,11 @@ async def http_exception_handler(request, exc: HTTPException):
10361037
return JSONResponse(
10371038
status_code=exc.status_code,
10381039
content=ErrorEnvelope(
1039-
error=ErrorDetail(code=f"HTTP_{exc.status_code}", message=str(exc.detail))
1040+
# Issue #249 — use the shared taxonomy. ``code_for_http_status``
1041+
# returns the canonical string ID from docs/errors.yaml. For
1042+
# unknown statuses it falls back to ``HTTP_{n}`` so legacy
1043+
# behaviour is preserved.
1044+
error=ErrorDetail(code=code_for_http_status(exc.status_code), message=str(exc.detail))
10401045
).model_dump(),
10411046
)
10421047

@@ -1053,7 +1058,8 @@ async def validation_exception_handler(request, exc: RequestValidationError):
10531058
status_code=422,
10541059
content=ErrorEnvelope(
10551060
error=ErrorDetail(
1056-
code="VALIDATION_ERROR",
1061+
# Issue #249 — use the shared taxonomy.
1062+
code=ErrorCode.VALIDATION_ERROR.value,
10571063
message="Request validation failed",
10581064
details=exc.errors(),
10591065
)
@@ -1078,7 +1084,8 @@ async def general_exception_handler(request, exc: Exception):
10781084
return JSONResponse(
10791085
status_code=500,
10801086
content=ErrorEnvelope(
1081-
error=ErrorDetail(code="INTERNAL_SERVER_ERROR", message="Internal server error")
1087+
# Issue #249 — use the shared taxonomy.
1088+
error=ErrorDetail(code=ErrorCode.INTERNAL_SERVER_ERROR.value, message="Internal server error")
10821089
).model_dump(),
10831090
)
10841091

app/ai-service/schemas/codes.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
"""
2+
app/ai-service/schemas/codes.py
3+
4+
Issue #249 — Shared error-code taxonomy consumed by both backend
5+
(NestJS, ``app/backend``) and AI service (FastAPI, ``app/ai-service``).
6+
7+
This file is the Python binding for ``docs/errors.yaml``, the single
8+
source of truth. The TypeScript binding lives in
9+
``app/backend/src/common/errors/codes.ts``. Parity between the two
10+
bindings is checked automatically by:
11+
12+
* ``app/backend/src/common/errors/codes.spec.ts`` (backend unit test)
13+
* ``app/ai-service/tests/test_codes.py`` (AI service unit test)
14+
15+
Both tests load ``docs/errors.yaml``, walk every entry, and assert that:
16+
17+
1. The string ``code`` from YAML matches ``ErrorCode.<NAME>.value`` here.
18+
2. The numeric ``http_status`` from YAML matches ``ErrorCodeMeta.http_status``.
19+
3. The ``description`` matches the meta table.
20+
21+
If you change this file you MUST update ``docs/errors.yaml`` AND
22+
``app/backend/src/common/errors/codes.ts`` in the same change. The parity
23+
tests will fail otherwise, which is the whole point of the issue.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
from dataclasses import dataclass
29+
from enum import Enum
30+
from typing import Optional
31+
32+
33+
class ErrorCode(str, Enum):
34+
"""Stable string codes emitted by the API surface.
35+
36+
String values are what the wire format publishes. Mirroring
37+
``ErrorCode`` in ``app/backend/src/common/errors/codes.ts`` exactly;
38+
parity is enforced by ``tests/test_codes.py``.
39+
"""
40+
41+
CODE_BODY_LENGTH_MISMATCH = "CODE_BODY_LENGTH_MISMATCH"
42+
HTTP_400 = "HTTP_400"
43+
HTTP_401 = "HTTP_401"
44+
HTTP_403 = "HTTP_403"
45+
HTTP_404 = "HTTP_404"
46+
HTTP_409 = "HTTP_409"
47+
HTTP_413 = "HTTP_413"
48+
HTTP_422 = "HTTP_422"
49+
HTTP_500 = "HTTP_500"
50+
HTTP_502 = "HTTP_502"
51+
HTTP_503 = "HTTP_503"
52+
INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR"
53+
PAYLOAD_TOO_LARGE = "PAYLOAD_TOO_LARGE"
54+
VALIDATION_ERROR = "VALIDATION_ERROR"
55+
AI_SERVICE_ERROR = "AI_SERVICE_ERROR"
56+
AI_TIMEOUT = "AI_TIMEOUT"
57+
58+
59+
@dataclass(frozen=True)
60+
class ErrorCodeMeta:
61+
"""Per-code metadata derived from ``docs/errors.yaml``.
62+
63+
Indexed by ``ErrorCode`` so consumers can look up HTTP status and
64+
description in O(1). Every entry here MUST be a 1:1 with
65+
``ErrorCode`` and ``docs/errors.yaml``; ``tests/test_codes.py`` fails
66+
the build if they are not.
67+
"""
68+
69+
code: str
70+
http_status: int
71+
description: str
72+
73+
74+
# Metadata table — keys here MUST exist in ``ErrorCode``; the parity
75+
# test enforces this.
76+
ERROR_CODE_META: dict[ErrorCode, ErrorCodeMeta] = {
77+
ErrorCode.HTTP_400: ErrorCodeMeta(
78+
code="HTTP_400",
79+
http_status=400,
80+
description="Bad request — the request was malformed or contained invalid parameters.",
81+
),
82+
ErrorCode.HTTP_401: ErrorCodeMeta(
83+
code="HTTP_401",
84+
http_status=401,
85+
description="Unauthorized — authentication is required to access this resource.",
86+
),
87+
ErrorCode.HTTP_403: ErrorCodeMeta(
88+
code="HTTP_403",
89+
http_status=403,
90+
description="Forbidden — the caller is authenticated but lacks permission.",
91+
),
92+
ErrorCode.HTTP_404: ErrorCodeMeta(
93+
code="HTTP_404",
94+
http_status=404,
95+
description="Not found — the requested resource does not exist.",
96+
),
97+
ErrorCode.HTTP_409: ErrorCodeMeta(
98+
code="HTTP_409",
99+
http_status=409,
100+
description="Conflict — the request conflicts with the current resource state.",
101+
),
102+
ErrorCode.HTTP_413: ErrorCodeMeta(
103+
code="HTTP_413",
104+
http_status=413,
105+
description="Payload too large — the request body exceeds the configured limit.",
106+
),
107+
ErrorCode.HTTP_422: ErrorCodeMeta(
108+
code="HTTP_422",
109+
http_status=422,
110+
description="Unprocessable entity — request payload failed schema validation.",
111+
),
112+
ErrorCode.HTTP_500: ErrorCodeMeta(
113+
code="HTTP_500",
114+
http_status=500,
115+
description="Internal server error — an unexpected exception escaped the handler.",
116+
),
117+
ErrorCode.HTTP_502: ErrorCodeMeta(
118+
code="HTTP_502",
119+
http_status=502,
120+
description="Bad gateway — an upstream/downstream dependency failed.",
121+
),
122+
ErrorCode.HTTP_503: ErrorCodeMeta(
123+
code="HTTP_503",
124+
http_status=503,
125+
description="Service unavailable — temporary degradation; retry with backoff.",
126+
),
127+
ErrorCode.VALIDATION_ERROR: ErrorCodeMeta(
128+
code="VALIDATION_ERROR",
129+
http_status=422,
130+
description="Validation failed — request payload does not match the expected schema.",
131+
),
132+
ErrorCode.AI_SERVICE_ERROR: ErrorCodeMeta(
133+
code="AI_SERVICE_ERROR",
134+
http_status=502,
135+
description="AI service error — the upstream LLM/OCR provider failed to respond.",
136+
),
137+
ErrorCode.AI_TIMEOUT: ErrorCodeMeta(
138+
code="AI_TIMEOUT",
139+
http_status=502,
140+
description="AI service timeout — the upstream LLM exceeded its time budget.",
141+
),
142+
ErrorCode.PAYLOAD_TOO_LARGE: ErrorCodeMeta(
143+
code="PAYLOAD_TOO_LARGE",
144+
http_status=413,
145+
description="Payload too large — the request body exceeded the configured size limit.",
146+
),
147+
ErrorCode.CODE_BODY_LENGTH_MISMATCH: ErrorCodeMeta(
148+
code="CODE_BODY_LENGTH_MISMATCH",
149+
http_status=400,
150+
description="Body length mismatch — streamed bytes exceeded the declared Content-Length.",
151+
),
152+
ErrorCode.INTERNAL_SERVER_ERROR: ErrorCodeMeta(
153+
code="INTERNAL_SERVER_ERROR",
154+
http_status=500,
155+
description="Internal server error — generic catch-all for unhandled exceptions.",
156+
),
157+
}
158+
159+
160+
def code_for_http_status(status: int) -> str:
161+
"""Reverse lookup: HTTP status → stable string code.
162+
163+
Mirrors ``codeForHttpStatus`` in
164+
``app/backend/src/common/errors/codes.ts``. If a status maps to
165+
multiple codes the FIRST one declared in ``ERROR_CODE_META`` wins
166+
(preserves insertion order); ``tests/test_codes.py`` asserts this
167+
matches the lookup in the TS module.
168+
"""
169+
for meta in ERROR_CODE_META.values():
170+
if meta.http_status == status:
171+
return meta.code
172+
return f"HTTP_{status}"
173+
174+
175+
def http_status_for_code(code: str) -> Optional[int]:
176+
"""Reverse lookup: stable string code → HTTP status.
177+
178+
Mirrors ``httpStatusForCode`` in
179+
``app/backend/src/common/errors/codes.ts``.
180+
"""
181+
for code_value, meta in ERROR_CODE_META.items():
182+
if code_value.value == code or meta.code == code:
183+
return meta.http_status
184+
return None

0 commit comments

Comments
 (0)