Skip to content

Commit 47a78a0

Browse files
olivermeyerclaude
andcommitted
fix(api): propagate exception handlers to versioned apps
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7612f23 commit 47a78a0

4 files changed

Lines changed: 70 additions & 16 deletions

File tree

ATTRIBUTIONS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ SOFTWARE.
360360

361361
```
362362

363-
## aignostics-foundry-core (0.7.1) - MIT License
363+
## aignostics-foundry-core (0.8.0) - MIT License
364364

365365
🏭 Foundational infrastructure for Foundry components.
366366

src/aignostics_foundry_core/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei
139139
- `build_versioned_api_tags(version_name, *, context=None)` — OpenAPI tags for a single versioned sub-app; reads `repository_url` from *context*
140140
- `build_root_api_tags(base_url, versions)` — OpenAPI tags for the root app linking to each version's docs
141141
- `get_versioned_api_instances(versions, *, context=None)` — loads project modules (resolved via context), calls `build_api_metadata(context=ctx)` to configure each `FastAPI` instance, routes registered `VersionedAPIRouter` instances to the matching version
142-
- `init_api(root_path, lifespan, exception_handler_registrations, versions=None, version_exception_handler_registrations=None, **fastapi_kwargs)` — creates a `FastAPI` with the standard Foundry exception handlers (`ApiException`, `RequestValidationError`, `ValidationError`, `Exception`) pre-registered; when *versions* is supplied, calls `get_versioned_api_instances` internally, optionally applies *version_exception_handler_registrations* to each sub-app, and mounts them at `/{version}` on the root app
142+
- `init_api(root_path, lifespan, exception_handler_registrations, versions=None, **fastapi_kwargs)` — creates a `FastAPI` with the standard Foundry exception handlers (`ApiException`, `RequestValidationError`, `ValidationError`, `Exception`) pre-registered; when *versions* is supplied, calls `get_versioned_api_instances` internally and mounts each sub-app at `/{version}` on the root app; all handlers (custom + standard) are registered on **every** app instance (root and each versioned sub-app) so that mounted sub-apps handle exceptions correctly
143143
- **Location**: `aignostics_foundry_core/api/core.py`
144144
- **Dependencies**: `fastapi>=0.110,<1` (mandatory); `aignostics_foundry_core.di` (`load_modules`)
145145
- **Import**: `from aignostics_foundry_core.api.core import VersionedAPIRouter, init_api, build_api_metadata, …` or `from aignostics_foundry_core.api import …`

src/aignostics_foundry_core/api/core.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -390,29 +390,32 @@ def init_api(
390390
lifespan: Any | None = None, # noqa: ANN401
391391
exception_handler_registrations: list[tuple[type[Exception], Any]] | None = None,
392392
versions: list[str] | None = None,
393-
version_exception_handler_registrations: list[tuple[type[Exception], Any]] | None = None,
394393
**fastapi_kwargs: Any, # noqa: ANN401
395394
) -> FastAPI:
396395
"""Initialise a FastAPI application with standard exception handlers.
397396
398397
This is a generic factory that creates a ``FastAPI`` instance and registers
399398
the standard Foundry exception handlers. When *versions* is supplied the
400399
function also creates versioned sub-applications via
401-
``get_versioned_api_instances``, optionally applies per-version exception
402-
handlers, and mounts each sub-app at ``/{version}`` on the root app.
400+
``get_versioned_api_instances`` and mounts each sub-app at ``/{version}``
401+
on the root app.
402+
403+
All exception handlers — both the custom ``exception_handler_registrations``
404+
entries and the 4 standard handlers (``ApiException``,
405+
``RequestValidationError``, ``ValidationError``, ``Exception``) — are
406+
registered on the root app **and** on every versioned sub-app. This is
407+
necessary because FastAPI mounted sub-apps handle exceptions independently;
408+
the root app's handlers never fire for requests matched inside a sub-app.
403409
404410
Args:
405411
root_path: ASGI root path (useful for reverse-proxy setups).
406412
lifespan: Optional async context manager for application lifespan.
407413
exception_handler_registrations: Additional ``(exc_class, handler)`` pairs
408-
to register before the standard handlers.
414+
to register on all app instances before the standard handlers.
409415
versions: Optional list of API version names (e.g. ``["v1", "v2"]``).
410416
When provided, ``get_versioned_api_instances`` is called internally
411417
and each resulting sub-app is mounted at ``/{version}`` on the root
412418
app.
413-
version_exception_handler_registrations: ``(exc_class, handler)`` pairs
414-
to register on *every* versioned sub-app before mounting. Only used
415-
when *versions* is also provided.
416419
**fastapi_kwargs: Extra keyword arguments forwarded to ``FastAPI()``.
417420
418421
Returns:
@@ -437,8 +440,19 @@ def init_api(
437440
if versions:
438441
versioned_apps = get_versioned_api_instances(versions)
439442
for version_name, version_app in versioned_apps.items():
440-
for exc_class, handler in version_exception_handler_registrations or []:
443+
for exc_class, handler in exception_handler_registrations or []:
441444
version_app.add_exception_handler(exc_class_or_status_code=exc_class, handler=handler)
445+
version_app.add_exception_handler( # type: ignore[arg-type]
446+
exc_class_or_status_code=ApiException,
447+
handler=api_exception_handler, # pyright: ignore[reportArgumentType]
448+
)
449+
version_app.add_exception_handler(
450+
exc_class_or_status_code=RequestValidationError, handler=validation_exception_handler
451+
)
452+
version_app.add_exception_handler(
453+
exc_class_or_status_code=ValidationError, handler=validation_exception_handler
454+
)
455+
version_app.add_exception_handler(exc_class_or_status_code=Exception, handler=unhandled_exception_handler)
442456
api.mount(f"/{version_name}", version_app)
443457

444458
return api

tests/aignostics_foundry_core/api/core_test.py

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -293,10 +293,10 @@ def fake_get_versioned(versions: list[str], **_: Any) -> dict[str, FastAPI]: #
293293

294294

295295
@pytest.mark.unit
296-
def test_init_api_applies_version_exception_handlers(monkeypatch: pytest.MonkeyPatch) -> None:
297-
"""init_api applies version_exception_handler_registrations to each versioned sub-app."""
296+
def test_init_api_propagates_custom_exception_handlers_to_versioned_apps(monkeypatch: pytest.MonkeyPatch) -> None:
297+
"""init_api registers exception_handler_registrations on each versioned sub-app."""
298298
from typing import Any
299-
from unittest.mock import MagicMock
299+
from unittest.mock import MagicMock, call
300300

301301
import aignostics_foundry_core.api.core as core_module
302302
from aignostics_foundry_core.api.core import init_api
@@ -311,10 +311,50 @@ def fake_get_versioned(versions: list[str], **_: Any) -> dict[str, MagicMock]:
311311

312312
def my_handler(request: object, exc: Exception) -> None: ...
313313

314-
init_api(versions=[VERSION_V1, VERSION_V2], version_exception_handler_registrations=[(ValueError, my_handler)])
314+
init_api(versions=[VERSION_V1, VERSION_V2], exception_handler_registrations=[(ValueError, my_handler)])
315315

316-
stub_v1.add_exception_handler.assert_called_once_with(exc_class_or_status_code=ValueError, handler=my_handler)
317-
stub_v2.add_exception_handler.assert_called_once_with(exc_class_or_status_code=ValueError, handler=my_handler)
316+
custom_call = call(exc_class_or_status_code=ValueError, handler=my_handler)
317+
assert custom_call in stub_v1.add_exception_handler.call_args_list
318+
assert custom_call in stub_v2.add_exception_handler.call_args_list
319+
320+
321+
@pytest.mark.unit
322+
def test_init_api_registers_standard_handlers_on_versioned_apps(monkeypatch: pytest.MonkeyPatch) -> None:
323+
"""init_api registers the 4 standard handlers on each versioned sub-app."""
324+
from typing import Any
325+
from unittest.mock import MagicMock, call
326+
327+
from fastapi.exceptions import RequestValidationError
328+
from pydantic import ValidationError
329+
330+
import aignostics_foundry_core.api.core as core_module
331+
from aignostics_foundry_core.api.core import (
332+
ApiException,
333+
api_exception_handler,
334+
init_api,
335+
unhandled_exception_handler,
336+
validation_exception_handler,
337+
)
338+
339+
stub_v1 = MagicMock()
340+
stub_v2 = MagicMock()
341+
342+
def fake_get_versioned(versions: list[str], **_: Any) -> dict[str, MagicMock]: # noqa: ANN401
343+
return {VERSION_V1: stub_v1, VERSION_V2: stub_v2}
344+
345+
monkeypatch.setattr(core_module, "get_versioned_api_instances", fake_get_versioned)
346+
347+
init_api(versions=[VERSION_V1, VERSION_V2])
348+
349+
expected_calls = [
350+
call(exc_class_or_status_code=ApiException, handler=api_exception_handler),
351+
call(exc_class_or_status_code=RequestValidationError, handler=validation_exception_handler),
352+
call(exc_class_or_status_code=ValidationError, handler=validation_exception_handler),
353+
call(exc_class_or_status_code=Exception, handler=unhandled_exception_handler),
354+
]
355+
for expected in expected_calls:
356+
assert expected in stub_v1.add_exception_handler.call_args_list
357+
assert expected in stub_v2.add_exception_handler.call_args_list
318358

319359

320360
@pytest.mark.unit

0 commit comments

Comments
 (0)