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
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"""Tests for FastAPI error handling in the Workers runtime.

Exercises HTTPException (with string and dict details, custom headers),
Pydantic validation errors (body, query, path), custom exception handlers,
and unhandled exceptions. These are particularly relevant for Pyodide because:

- Pydantic v2 validation and error serialisation run through pydantic-core
(compiled to WASM); subtle issues in the WASM build would surface here.
- The ASGI adapter must correctly propagate error responses, including custom
status codes, headers, and JSON bodies through the Workers response path.
- Unhandled exceptions exercise the adapter's error branch (``asgi.py``
``process_request`` exception handling).
"""

import pytest
from _client import fetch, get_json, post_json, read_json

# -- HTTPException -----------------------------------------------------------


@pytest.mark.asyncio
async def test_http_exception_404(fastapi_app):
"""HTTPException with status_code=404 returns the correct status and detail."""
resp, data = await get_json(fastapi_app, "/errors/not-found")
assert resp.status == 404
assert data["detail"] == "Item not found"


@pytest.mark.asyncio
async def test_http_exception_dict_detail(fastapi_app):
"""HTTPException with a dict detail (FastAPI extension over Starlette)."""
resp, data = await get_json(fastapi_app, "/errors/dict-detail")
assert resp.status == 403
assert isinstance(data["detail"], dict)
assert data["detail"]["msg"] == "Access denied"
assert data["detail"]["reason"] == "insufficient_scope"


@pytest.mark.asyncio
async def test_http_exception_custom_headers(fastapi_app):
"""HTTPException propagates custom response headers."""
resp = await fetch(fastapi_app, "/errors/with-headers")
assert resp.status == 401
data = await read_json(resp)
assert data["detail"] == "Invalid token"
assert resp.headers.get("www-authenticate") == "Bearer"
assert resp.headers.get("x-error-code") == "TOKEN_EXPIRED"


# -- Pydantic validation errors ----------------------------------------------


@pytest.mark.asyncio
async def test_validation_error_missing_body_fields(fastapi_app):
"""POST with an empty body for a Pydantic model returns 422."""
resp = await post_json(fastapi_app, "/errors/validate-body", {})
assert resp.status == 422
data = await read_json(resp)
assert "detail" in data
errors = data["detail"]
assert isinstance(errors, list)
assert len(errors) > 0


@pytest.mark.asyncio
async def test_validation_error_wrong_body_types(fastapi_app):
"""POST with wrong types in the body returns 422 with field-level errors."""
resp = await post_json(
fastapi_app,
"/errors/validate-body",
{"name": 123, "price": "not-a-number", "quantity": "abc"},
)
assert resp.status == 422
data = await read_json(resp)
errors = data["detail"]
assert isinstance(errors, list)
# At least price and quantity should fail validation
error_fields = []
for err in errors:
assert "loc" in err
assert "msg" in err
assert "type" in err
# loc is a list like ["body", "price"]
if len(err["loc"]) > 1:
error_fields.append(err["loc"][-1])
assert "price" in error_fields or "quantity" in error_fields


@pytest.mark.asyncio
async def test_validation_error_body_loc(fastapi_app):
"""Validation error loc for body fields starts with 'body'."""
resp = await post_json(
fastapi_app,
"/errors/validate-body",
{"name": "ok", "price": "bad", "quantity": 1},
)
assert resp.status == 422
data = await read_json(resp)
errors = data["detail"]
assert any(err["loc"][0] == "body" for err in errors)


@pytest.mark.asyncio
async def test_validation_error_query_param(fastapi_app):
"""Non-integer query param triggers 422 with loc including 'query'."""
resp, data = await get_json(fastapi_app, "/errors/validate-query?count=abc")
assert resp.status == 422
errors = data["detail"]
assert isinstance(errors, list)
assert any("query" in err["loc"] for err in errors)


@pytest.mark.asyncio
async def test_validation_error_path_param(fastapi_app):
"""Non-integer path param triggers 422 with loc including 'path'."""
resp, data = await get_json(fastapi_app, "/errors/validate-path/not-a-number")
assert resp.status == 422
errors = data["detail"]
assert isinstance(errors, list)
assert any("path" in err["loc"] for err in errors)


@pytest.mark.asyncio
async def test_valid_body_passes(fastapi_app):
"""A valid Pydantic model body is accepted and echoed back."""
resp = await post_json(
fastapi_app,
"/errors/validate-body",
{"name": "Widget", "price": 9.99, "quantity": 5},
)
assert resp.status == 200
data = await read_json(resp)
assert data["name"] == "Widget"
assert data["price"] == 9.99


# -- Custom exception handler ------------------------------------------------


@pytest.mark.asyncio
async def test_custom_exception_handler(fastapi_app):
"""A registered exception handler returns its custom response."""
resp, data = await get_json(fastapi_app, "/errors/custom-exception")
assert resp.status == 418
assert data["error_code"] == "TEAPOT"
assert data["detail"] == "I'm a teapot"


# -- Unhandled exception -----------------------------------------------------


@pytest.mark.asyncio
async def test_unhandled_exception_returns_500(fastapi_app):
"""An unhandled exception in a handler results in a 500 response."""
resp = await fetch(fastapi_app, "/errors/unhandled")
assert resp.status == 500
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@

import pytest
from fastapi import Depends, FastAPI, File, Request, UploadFile
from fastapi.exceptions import HTTPException
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from fastapi.responses import Response as FastAPIResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from pyodide.webloop import WebLoop
from starlette.background import BackgroundTask

Expand Down Expand Up @@ -127,6 +129,88 @@ async def upload_multiple(files: list[UploadFile] = File(...)): # noqa: B008
return result


# --------------------------------------------------------------------------- #
# Error handling routes
# --------------------------------------------------------------------------- #


class AppError(Exception):
"""Custom application error for testing exception handlers."""

def __init__(self, code: str, detail: str):
self.code = code
self.detail = detail


@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError):
return JSONResponse(
status_code=418,
content={"error_code": exc.code, "detail": exc.detail},
)


class ItemModel(BaseModel):
name: str
price: float
quantity: int


@app.get("/errors/not-found")
async def error_not_found():
"""Raise HTTPException with 404."""
raise HTTPException(status_code=404, detail="Item not found")


@app.get("/errors/dict-detail")
async def error_dict_detail():
"""Raise HTTPException with a dict detail (FastAPI extension)."""
raise HTTPException(
status_code=403,
detail={"msg": "Access denied", "reason": "insufficient_scope"},
)


@app.get("/errors/with-headers")
async def error_with_headers():
"""Raise HTTPException with custom response headers."""
raise HTTPException(
status_code=401,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer", "X-Error-Code": "TOKEN_EXPIRED"},
)


@app.post("/errors/validate-body")
async def error_validate_body(item: ItemModel):
"""Accept a Pydantic model; invalid input triggers 422."""
return {"name": item.name, "price": item.price}


@app.get("/errors/validate-query")
async def error_validate_query(count: int):
"""Require an integer query param; non-integer triggers 422."""
return {"count": count}


@app.get("/errors/validate-path/{item_id}")
async def error_validate_path(item_id: int):
"""Require an integer path param; non-integer triggers 422."""
return {"item_id": item_id}


@app.get("/errors/custom-exception")
async def error_custom_exception():
"""Raise a custom exception handled by a registered handler."""
raise AppError(code="TEAPOT", detail="I'm a teapot")


@app.get("/errors/unhandled")
async def error_unhandled():
"""Raise an unhandled ValueError to trigger a 500."""
raise ValueError("something went wrong internally")


@app.get("/native-file")
async def native_file():
"""Serve a single bundled file with FastAPI's native FileResponse."""
Expand Down
Loading