RamshaAnwar commited on
Commit
d2d4409
·
unverified ·
1 Parent(s): 3e36c5e

feat(api): error contract, request id, and test harness (#2)

Browse files

* feat(errors): add AppError and error envelope builder

* feat(api): register error handlers and request id middleware

* test: add conftest harness with test database safety check

Files changed (4) hide show
  1. app/errors.py +30 -0
  2. app/main.py +59 -2
  3. pytest.ini +2 -0
  4. tests/conftest.py +46 -0
app/errors.py CHANGED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any
2
+ from fastapi import Request
3
+ from fastapi.responses import JSONResponse
4
+
5
+ class AppError(Exception):
6
+ def __init__(self, status_code: int, code: str, message: str, details: list[dict] | None = None, headers: dict | None = None):
7
+ self.status_code = status_code
8
+ self.code = code
9
+ self.message = message
10
+ self.details = details or []
11
+ self.headers = headers
12
+
13
+ def error_response(request: Request, status_code: int, code: str, message: str, details: list[dict] | None = None, headers: dict | None = None) -> JSONResponse:
14
+ request_id = getattr(request.state, "request_id", "unknown")
15
+
16
+ envelope = {
17
+ "error": {
18
+ "code": code,
19
+ "message": message,
20
+ "status": status_code,
21
+ "request_id": request_id,
22
+ "details": details or []
23
+ }
24
+ }
25
+
26
+ return JSONResponse(
27
+ status_code=status_code,
28
+ content=envelope,
29
+ headers=headers
30
+ )
app/main.py CHANGED
@@ -1,5 +1,11 @@
1
- from fastapi import FastAPI
 
 
 
 
 
2
 
 
3
 
4
  def create_app() -> FastAPI:
5
  app = FastAPI(
@@ -9,11 +15,62 @@ def create_app() -> FastAPI:
9
  redoc_url=None,
10
  )
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  @app.get("/healthz", tags=["system"])
13
  def healthz() -> dict[str, str]:
14
  return {"status": "ok"}
15
 
16
  return app
17
 
18
-
19
  app = create_app()
 
1
+ import uuid
2
+ import logging
3
+ from fastapi import FastAPI, Request
4
+ from fastapi.exceptions import RequestValidationError
5
+ from starlette.exceptions import HTTPException as StarletteHTTPException
6
+ from app.errors import AppError, error_response
7
 
8
+ logger = logging.getLogger("uvicorn.error")
9
 
10
  def create_app() -> FastAPI:
11
  app = FastAPI(
 
15
  redoc_url=None,
16
  )
17
 
18
+ # 1. Catch-all middleware (must be inside Request-ID)
19
+ @app.middleware("http")
20
+ async def catch_unhandled(request: Request, call_next):
21
+ try:
22
+ return await call_next(request)
23
+ except Exception:
24
+ logger.exception("unhandled error", extra={"request_id": getattr(request.state, "request_id", "unknown")})
25
+ return error_response(request, 500, "INTERNAL_ERROR", "Unexpected server error.")
26
+
27
+ # 2. Request-ID middleware
28
+ @app.middleware("http")
29
+ async def add_request_id(request: Request, call_next):
30
+ request.state.request_id = str(uuid.uuid4())
31
+ response = await call_next(request)
32
+ response.headers["X-Request-ID"] = request.state.request_id
33
+ return response
34
+
35
+ # 3. Exception Handlers
36
+ @app.exception_handler(AppError)
37
+ async def app_error_handler(request: Request, exc: AppError):
38
+ return error_response(request, exc.status_code, exc.code, exc.message, exc.details, exc.headers)
39
+
40
+ @app.exception_handler(RequestValidationError)
41
+ async def validation_error_handler(request: Request, exc: RequestValidationError):
42
+ details = []
43
+ for error in exc.errors():
44
+ # error["loc"] looks like ("body", "name")
45
+ loc = error.get("loc", [])
46
+ field = ".".join(str(x) for x in loc) if loc else "unknown"
47
+
48
+ details.append({
49
+ "field": field,
50
+ "reason": error.get("type", "invalid"),
51
+ "message": error.get("msg", "Invalid value")
52
+ })
53
+
54
+ return error_response(
55
+ request,
56
+ 422,
57
+ "VALIDATION_ERROR",
58
+ "Request validation failed.",
59
+ details=details
60
+ )
61
+
62
+ @app.exception_handler(StarletteHTTPException)
63
+ async def http_exception_handler(request: Request, exc: StarletteHTTPException):
64
+ if exc.status_code == 404:
65
+ return error_response(request, 404, "ROUTE_NOT_FOUND", "The requested route does not exist.")
66
+ if exc.status_code == 405:
67
+ return error_response(request, 405, "METHOD_NOT_ALLOWED", "Method not allowed for this route.")
68
+ return error_response(request, exc.status_code, "HTTP_ERROR", str(exc.detail))
69
+
70
  @app.get("/healthz", tags=["system"])
71
  def healthz() -> dict[str, str]:
72
  return {"status": "ok"}
73
 
74
  return app
75
 
 
76
  app = create_app()
pytest.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [pytest]
2
+ pythonpath = .
tests/conftest.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import subprocess
3
+ from pathlib import Path
4
+ from dotenv import load_dotenv
5
+ import pytest
6
+ from fastapi.testclient import TestClient
7
+ from sqlalchemy import text
8
+
9
+ # Load .env so we get TEST_DATABASE_URL
10
+ load_dotenv(Path(__file__).resolve().parents[1] / ".env")
11
+
12
+ # Safety check: Refuse to run if we aren't on a test DB
13
+ test_url = os.environ.get("TEST_DATABASE_URL")
14
+ if not test_url:
15
+ raise RuntimeError("TEST_DATABASE_URL is not set in .env")
16
+
17
+ db_name = test_url.rsplit("/", 1)[-1].split("?")[0]
18
+ if not (db_name.endswith("_test") or "localhost" in test_url or "127.0.0.1" in test_url):
19
+ raise RuntimeError(f"Refusing to run tests on non-test database: {db_name}")
20
+
21
+ # Override environment variables BEFORE importing app
22
+ os.environ["DATABASE_URL"] = test_url
23
+ os.environ["DIRECT_DATABASE_URL"] = test_url
24
+ os.environ["JWT_SECRET_KEY"] = "test-secret-test-secret-test-secret-123"
25
+ os.environ["GEMINI_API_KEY"] = "test-key"
26
+
27
+ from app.main import app
28
+ from app.db import engine
29
+
30
+ @pytest.fixture(scope="session", autouse=True)
31
+ def setup_database():
32
+ # Run Alembic migrations to set up the schema exactly like production
33
+ subprocess.run(["alembic", "upgrade", "head"], check=True)
34
+ yield
35
+
36
+ @pytest.fixture(autouse=True)
37
+ def clean_tables():
38
+ # Truncate tables between tests
39
+ with engine.begin() as conn:
40
+ conn.execute(text("TRUNCATE idempotency_keys, chunks, documents, hubs, users CASCADE"))
41
+ yield
42
+
43
+ @pytest.fixture
44
+ def client():
45
+ with TestClient(app) as c:
46
+ yield c