# lauren — Full Reference for LLMs A production-grade, metadata-first Python web framework for ASGI services. This document is a single self-contained reference intended for ingestion by AI coding assistants. It covers every public API, idiomatic usage, and common mistakes. --- ## Table of Contents 1. [Installation](#installation) 2. [Minimal Example](#minimal-example) 3. [Application Factory & Lifecycle](#application-factory--lifecycle) 4. [Decorators](#decorators) 5. [Types (Request / Response / State / Headers)](#types-request--response--state--headers) 6. [Extractors](#extractors) 7. [Validation](#validation) 8. [Pipes](#pipes) 9. [Custom Extractors (plugin API)](#custom-extractors-plugin-api) 10. [Dependency Injection](#dependency-injection) 11. [Custom Providers](#custom-providers) 12. [Modules](#modules) 13. [Guards & Middleware](#guards--middleware) 14. [Interceptors](#interceptors) 15. [Exception Handlers](#exception-handlers) 16. [`@propagate_metadata` — copy Lauren decorator metadata between objects](#propagate_metadata--copy-lauren-decorator-metadata-between-objects) 17. [Auto-Serialization](#auto-serialization) 18. [Response Contracts](#response-contracts) 19. [Pagination](#pagination) 20. [Sync vs Async Handlers](#sync-vs-async-handlers) 21. [Background Tasks](#background-tasks) 22. [Hosted Services & Scheduling](#hosted-services--scheduling) 23. [Standalone Context & Commands](#standalone-context--commands) 24. [Error Catalog](#error-catalog) 25. [Debug Error Pages](#debug-error-pages) 26. [Testing](#testing) 27. [Strict Decorator Inheritance](#strict-decorator-inheritance) 28. [OpenAPI 3.1 Generation](#openapi-31-generation) 29. [Logging](#logging) 30. [Graceful Shutdown & Signals](#graceful-shutdown--signals) 31. [Health & Readiness](#health--readiness) 32. [Application Events](#application-events) 33. [WebSockets](#websockets) 34. [Server-Sent Events (`EventStream`)](#server-sent-events-eventstream) 35. [Typed Streaming](#typed-streaming) 36. [Socket.IO Compatibility](#socketio-compatibility) 37. [Sessions](#sessions) 38. [Stores & Caching](#stores--caching) 39. [Content-type policy (`ContentConfig`)](#content-type-policy-contentconfig) 40. [Configuration](#configuration) 41. [Static Files](#static-files) 42. [Request Arena](#request-arena) 43. [Reflect — Metadata Introspection API](#reflect--metadata-introspection-api) 44. [AI Documentation Access](#ai-documentation-access) 45. [Common Patterns & Anti-Patterns](#common-patterns--anti-patterns) 46. [Companion Packages](#companion-packages) 47. [Appendix: Cheat Sheet](#appendix-cheat-sheet) --- ## Installation ```bash pip install lauren # core only (anyio required) pip install "lauren[pydantic]" # adds pydantic>=2.0 pip install "lauren[msgspec]" # adds msgspec>=0.18 pip install "lauren[full]" # pydantic + msgspec ``` Python 3.11+ required. `anyio>=4.0` is the only hard dependency (used to offload sync handlers to a thread pool). Pydantic and msgspec are optional — the framework validates request bodies with whichever library is installed. `py.typed` marker ships for full static-analysis support. --- ## Minimal Example ```python run import asyncio from pydantic import BaseModel from lauren import ( LaurenFactory, controller, get, post, module, Path, Json, Response, ) class CreateUser(BaseModel): name: str age: int @controller("/users") class UserController: @get("/{id}") async def get_user(self, id: Path[int]) -> dict: return {"id": id, "found": True} @post("/", status_code=201) # the declared success status (documented in OpenAPI) async def create(self, body: Json[CreateUser]) -> dict: return body.model_dump() @module(controllers=[UserController]) class AppModule: pass app = LaurenFactory.create(AppModule) # `app` is an ASGI callable — use uvicorn/hypercorn to serve. # startup() is called automatically via the ASGI lifespan protocol. # Exercise it in-process (TestClient runs startup() for you): from lauren.testing import TestClient client = TestClient(app) assert client.get("/users/7").json() == {"id": 7, "found": True} assert client.post("/users/", json={"name": "Ada", "age": 36}).status_code == 201 ``` --- ## Application Factory & Lifecycle ### `LaurenFactory.create(root_module, *, strict_lifecycle=True, global_middlewares=None, global_guards=None, global_interceptors=None, global_ws_guards=None, global_ws_interceptors=None, global_exception_handlers=None, global_providers=None, max_body_size=1048576, app_state=None, logger=None, openapi_info=None, openapi_servers=None, openapi_security_schemes=None, openapi_url=None, docs_url=None, redoc_url=None, arena=None, arena_capacity=None, json_encoder=None, signals=None, error_format="default", root_path="", mounts=None, sessions=None, content=None, problem_type_base=None, debug=False, lazy_startup=True, hosted_services=True, scheduler=True, validation=None, strict_request_scope=False, response_validation="off", json_fallback="warn", pagination=None)` Synchronous factory. Returns a `LaurenApp` ready to be served. Executes seven phases: 1. **Module graph construction** — walk `@module(imports=[...])` edges, detect cycles. 2. **Provider collection** — gather every `@injectable` from the graph; then (Phase 2b) load and validate every `ConfigModule` settings class, raising one `ConfigValidationError` for all problems (see [Configuration](#configuration)). 3. **Protocol binding** — resolve `provides=[Protocol]` declarations. 4. **DI graph compilation** — detect circular deps, scope violations, protocol ambiguities. 5. **Router compilation** — walk controllers, compile handler signatures into extraction plans (every path / query / header / cookie / body / form / stream parameter and WebSocket payload type is compiled into a validator by the app's type compiler — see [Validation](#validation)), insert into the radix tree. Each route's declared responses (`status_code=`, typed results such as `Created[T]`, `Annotated[T, Status(206)]`, `response_model=`) are read here — contradictions raise `DecoratorUsageError` — and routes that enforce a response contract get their projector compiled (see [Response Contracts](#response-contracts)). 6. **Lifecycle order computation** — build the topological `@post_construct` / `@pre_destruct` schedule. Just before it (Phase 6a) one discovery pass indexes provider methods and classes carrying feature markers; hosted services and scheduled jobs are validated there (singleton scope, unique names, `ScheduleModule` imported — see [Hosted Services & Scheduling](#hosted-services--scheduling)). 7. **Readiness** — build the `LaurenApp` object. `startup()` (singleton construction, `@post_construct` hooks, `StartupComplete`) runs on the ASGI lifespan `startup` event. When the driver sends no lifespan events — e.g. `httpx.ASGITransport` — the app starts itself on its first HTTP or WebSocket request (once, behind a single-flight latch, with a logged warning) unless built with `lazy_startup=False`; `lauren.testing.TestClient` starts it explicitly. Failures in phases 1–6 raise a `StartupError` subclass immediately — the app never begins serving traffic with an invalid graph. **Full parameter reference:** | Parameter | Type | Default | Purpose | |---|---|---|---| | `root_module` | `type` | required | Root `@module` class | | `strict_lifecycle` | `bool` | `True` | Enforce strict lifecycle ordering | | `global_middlewares` | `Iterable[type]` | `None` | Middleware classes applied to every request (onion order) | | `global_guards` | `Iterable[type]` | `None` | Guard classes applied to every HTTP request | | `global_interceptors` | `Iterable[type]` | `None` | Interceptor classes applied to every HTTP handler | | `global_ws_guards` | `Iterable[type]` | `None` | Guard classes applied before every WebSocket `@on_connect` (registered with DI automatically) | | `global_ws_interceptors` | `Iterable[type]` | `None` | Interceptor classes wrapping every WebSocket `@on_connect` | | `global_exception_handlers` | `Iterable[Any]` | `None` | Exception handler classes/functions for every request | | `global_providers` | `Iterable[Any]` | `None` | Extra providers added to the root module | | `max_body_size` | `int` | `1048576` | Maximum request body size in bytes (413 on exceed) | | `app_state` | `AppState` | `None` | Pre-populated app-level state | | `logger` | `Logger` | `None` | Logger instance; `NullLogger` if not provided | | `openapi_info` | `dict` | `None` | OpenAPI `info` block (`title`, `version`, `description`) | | `openapi_servers` | `list[dict]` | `None` | OpenAPI `servers` list | | `openapi_security_schemes` | `dict` | `None` | OpenAPI `components.securitySchemes` | | `openapi_url` | `str` | `None` | Path for OpenAPI JSON (e.g. `"/openapi.json"`) | | `docs_url` | `str` | `None` | Path for Swagger UI (e.g. `"/docs"`) | | `redoc_url` | `str` | `None` | Path for ReDoc (e.g. `"/redoc"`) | | `arena` | `RequestArena` | `None` | Custom arena for per-request object pooling | | `arena_capacity` | `int` | `None` | Override arena pool capacity | | `json_encoder` | `JSONEncoder` | `None` | Encoder for all JSON output (responses, errors, SSE, WS) | | `signals` | `SignalBus` | `None` | Shared signal bus for lifecycle events | | `error_format` | `str` | `"default"` | Error response format: `"default"`, `"rfc9457"` (RFC 9457 problem details) or `"rfc7807"` (legacy problem details) — see [Error Catalog](#error-catalog) | | `root_path` | `str` | `""` | ASGI root path for reverse proxy prefix stripping | | `mounts` | `dict[str, Any]` | `None` | Sub-applications mounted at path prefixes | | `sessions` | `SessionConfig` | `None` | Enable signed-cookie sessions (see [Sessions](#sessions)) | | `content` | `ContentConfig` | `None` | Request-body content policy: `ContentConfig(strict_content_type=None \| False \| "warn" \| True)` (see [Content-type policy](#content-type-policy-contentconfig)) | | `problem_type_base` | `str` | `None` | Problem-details `type` prefix: `type = base + code` for errors without a `problem_type` (`StartupError` if not a non-empty string) | | `debug` | `bool \| DebugConfig` | `False` | Development error pages for unhandled errors (see [Debug Error Pages](#debug-error-pages)); never enabled implicitly | | `lazy_startup` | `bool` | `True` | Run `startup()` on the first HTTP / WebSocket request when the ASGI server never sent lifespan events (logs a warning); `False` keeps such an app cold until `await app.startup()` | | `hosted_services` | `bool` | `True` | `False` discovers and validates `@hosted_service` classes but does not start them (tests, one-off commands) | | `scheduler` | `bool` | `True` | `False` keeps scheduled jobs discovered and validated (and `SchedulerRegistry.trigger` working) but fires nothing | | `validation` | `ValidationPolicy` | `None` | Validation policy: `ValidationPolicy(stdlib_models="warn", json_mode="lax", extra="ignore", include_input=None, max_issues=50)` (see [Validation](#validation); `StartupError` with `reason="invalid_validation_policy"` for anything else) | | `strict_request_scope` | `bool` | `False` | `True` makes resolving a `Scope.REQUEST` provider outside any request, WebSocket connection or `app.scope()` raise `DIScopeViolationError` (`reason="no_active_scope"`); the default falls back to an untracked transient and logs a warning once per provider (see [Operation scopes](#operation-scopes-appscope)) | | `response_validation` | `str` | `"off"` | Default response contract of every route: `"off"`, `"filter"` (undeclared fields never leave) or `"strict"` (also validates) — see [Response Contracts](#response-contracts); `StartupError` with `reason="invalid_response_validation"` for anything else | | `json_fallback` | `str` | `"warn"` | What encoders do with an object no rule covers: `"dict"` (public attributes), `"warn"` (same, logged once per type) or `"error"` (500) — see [Response Contracts](#response-contracts); `StartupError` with `reason="invalid_json_fallback"` | | `pagination` | `PaginationConfig` | `None` | App-wide paging defaults, maxima, parameter names, page envelope and cursor secret (see [Pagination](#pagination); `StartupError` with `reason="invalid_pagination_config"` / `"pagination_secret_missing"`) | `LaurenFactory.create_context(root_module, *, hosted_services=False, scheduler=False, drain_timeout=10.0, **create_kwargs)` builds the same app and wraps it in an `ApplicationContext` — the DI graph and lifecycle without HTTP, for scripts, commands and workers (see [Standalone Context & Commands](#standalone-context--commands)). ### `Lauren` (imperative API) `Lauren` is a higher-level wrapper around `LaurenFactory` that provides an imperative, chainable API for building applications without the full NestJS-style module system: ```python run from dataclasses import asdict, dataclass from lauren import Json, Lauren from lauren.testing import TestClient @dataclass class CreateUser: name: str app = Lauren() @app.get("/health") async def health() -> dict: return {"status": "ok"} @app.post("/users", status_code=201) async def create_user(body: Json[CreateUser]) -> dict: return asdict(body) # `app` itself is the ASGI callable (`uvicorn main:app`); it compiles lazily. client = TestClient(app) assert client.post("/users", json={"name": "ada"}).status_code == 201 ``` `Lauren(...)` accepts every `LaurenFactory.create` option — the OpenAPI ones under friendlier names (`title` / `version` / `description` → `openapi_info`, `servers` → `openapi_servers`, `security_schemes` → `openapi_security_schemes`) — and differs in defaults: `/docs`, `/redoc` and `/openapi.json` are enabled. `debug=True` (or a `DebugConfig`) is forwarded to the factory when the app compiles — see [Debug Error Pages](#debug-error-pages); `app.debug` reports whether it is on. It internally creates a root module from the registered routes and modules. Mutators — `include_module`, `add_middleware`, `add_guard`, `add_interceptor`, `add_exception_handler`, `add_provider`, `mount(path, asgi_app)` and the route decorators — must run before the app compiles (first request or `await app.startup()`); afterwards they raise `LifecycleViolationError`. `on_startup` hooks run inside `LaurenApp.startup()` (the ASGI lifespan, `TestClient`, an `ApplicationContext`) after every `@post_construct` hook and before hosted services start or a request is served; a failing hook raises `StartupError` (`detail={"handler": name}`). `await app.startup()` on a `Lauren` app only compiles it. `app.lifecycle_state` and `app.begin_draining()` forward to the compiled `LaurenApp` (`CREATED` / `False` before it compiles). ### `LaurenApp` Attributes & methods: - `app.router: Router` — the compiled radix tree. - `app.container: DIContainer` — compiled DI container. - `app.app_state: AppState` — sealed, read-only app-level state. - `app.routes()` — list of `RouteEntry` objects. - `app.openapi()` — OpenAPI 3.1 schema dict. - `await app.startup()` — builds singletons, runs `@post_construct` hooks, starts hosted services and arms the scheduler (jobs fire after `StartupComplete`). Invoked by the ASGI lifespan protocol (uvicorn, hypercorn), by `TestClient`, or — for drivers without lifespan events such as `httpx.ASGITransport` — lazily by the first request (`lazy_startup=True`, the default). A second call raises `LifecycleViolationError` unless the app was built with `strict_lifecycle=False`. - `await app.shutdown(drain_timeout=10.0)` — stops the scheduler firing, awaits in-flight requests and job runs, stops hosted services (reverse start order), awaits running `@on_event` handler tasks, runs `on_shutdown` callbacks, then `@pre_destruct` hooks. - `app.lifecycle_state: AppLifecycleState` — `CREATED` → `STARTING` → `READY` → `DRAINING` → `STOPPED`, or `FAILED` when `startup()` raised (until it is retried; see [Lifecycle state](#lifecycle-state)). - `app.begin_draining(*, reason="") -> bool` — `READY → DRAINING`: readiness fails while requests are still served. Synchronous, idempotent; `True` only when it changed the state. - `await app(scope, receive, send)` — standard ASGI callable (HTTP + lifespan). - `app.mount(path, app)` — mount a sub-ASGI application at a path prefix. - `app.scope(name="operation", *, values=None)` — open an `OperationScope` for request-scoped providers outside a request (see [Operation scopes](#operation-scopes-appscope)). - `app.type_compiler: TypeCompiler` — the type compiler `create()` used; `app.type_compiler.compile(T)` gives services the HTTP layer's validation (see [Validation](#validation)). - `app.validation_policy: ValidationPolicy` — the app's validation policy. - `app.context(*, hosted_services=False, scheduler=False, drain_timeout=10.0)` — wrap the built, not yet started app in an `ApplicationContext` (see [Standalone Context & Commands](#standalone-context--commands)). - `app.response_validation: str` — the default response contract (`"off"` / `"filter"` / `"strict"`). - `app.json_fallback: str` — the `json_fallback` policy (`"dict"` / `"warn"` / `"error"`). ### Mounting Sub-Applications ```python sub_app = LaurenFactory.create(SubModule) app.mount("/api/v2", sub_app) ``` Mounts are sorted by longest prefix first. The ASGI `root_path` is propagated to the sub-application, and the request path is stripped by the prefix before dispatch. Multiple mounts can coexist: ```python app.mount("/api/v1", v1_app) app.mount("/api/v2", v2_app) app.mount("/admin", admin_app) ``` Any ASGI callable (not just `LaurenApp`) can be mounted — this includes Starlette, FastAPI, or raw ASGI apps. --- ## Decorators ### `@injectable(*, scope=Scope.SINGLETON, provides=None, multi=False)` Marks a class as a DI provider. ```python run from lauren import Scope, injectable @injectable(scope=Scope.SINGLETON) class Clock: def now(self) -> float: ... @injectable(scope=Scope.REQUEST) class DbSession: def __init__(self, clock: Clock) -> None: ... ``` - `scope`: - `Scope.SINGLETON` — one instance per app (default) - `Scope.REQUEST` — one instance per request, shared within that request's handler tree - `Scope.TRANSIENT` — new instance every resolution - `provides=[Protocol, ...]` — register the class as an implementation of one or more Protocols. - `multi=True` — permit multiple providers for the same token; resolution returns a list. **Rule:** subclasses of `@injectable` classes are NOT automatically injectable. You must re-decorate explicitly: ```python run @injectable() class Base: ... class NotAnInjectable(Base): pass # startup raises MetadataInheritanceError if you try to register this @injectable() class Child(Base): # explicit opt-in pass ``` ### `@module(*, controllers=None, providers=None, imports=None, exports=None)` Declares a module boundary (NestJS-style). ```python @module( controllers=[UserController, ProjectController], providers=[Clock, DbSession], imports=[SharedModule], exports=[DbSession], # must be in providers or imported ) class AppModule: pass ``` - `controllers` — classes to expose via the HTTP router. - `providers` — classes to register with the DI container. - `imports` — other `@module` classes; their `exports` become visible here. - `exports` — subset of providers/imports re-exported to modules that import this one. Violations raised at startup: - `CircularModuleError` — an import cycle exists. - `ModuleExportViolation` — an export is neither declared nor imported. ### `@controller(prefix="", *, tags=None, summary=None, description=None, deprecated=False, security=None)` Declares a controller class. Automatically makes the class `@injectable(scope=Scope.SINGLETON)` (NestJS behaviour): one instance for the app, so a controller may depend only on singletons. For per-request construction (request-scoped dependencies, per-request `@post_construct`), add `@injectable(scope=Scope.REQUEST)` *below* `@controller`. ```python run @controller("/api/v1/users", tags=["users"]) @injectable(scope=Scope.REQUEST) # needed: DbSession is request-scoped class UserController: def __init__(self, session: DbSession) -> None: ... ``` ### Route decorators - `@get(path="", *, summary=None, description=None, response_model=None, responses=None, deprecated=False, operation_id=None, include_in_schema=True, tags=None, status_code=None, response_validation=None, response_headers=None)` - `@post(...)`, `@put(...)`, `@patch(...)`, `@delete(...)`, `@head(...)`, `@options(...)` Each decorator attaches a `RouteMeta` to the method. Multiple route decorators on the same method register multiple routes. - `status_code` — the declared success status (int, 100–599): plain return values are sent with it, `None` sends it with an empty body, and OpenAPI documents it. - `response_validation` — `"off"` / `"filter"` / `"strict"` for this route; `None` follows the app's `response_validation=` (see [Response Contracts](#response-contracts)). - `response_headers` — documentation-only success-response headers: `{"Location": "URL of the user"}` (a description or an OpenAPI Header Object dict). - `response_model` — the declared body type (any model kind, or a container of them); it wins over the return annotation for OpenAPI and for the response contract. Invalid option values fail at decoration time with `DecoratorUsageError` (`detail = {"decorator", "target", "option", "reason"}`, e.g. `reason="invalid_status_code"`). `@raises(*http_errors, description=None)` declares the `HTTPError` subclasses a route (or, on a class, every route of the controller) raises — see [Response Contracts](#response-contracts). ```python run @controller("/p") class PingController: @get("/ping") @get("/health") # same handler, two routes async def ping(self) -> dict: return {"ok": True} ``` ### Lifecycle hook decorators ```python run from lauren import injectable, post_construct, pre_destruct @injectable() class Db: @post_construct async def connect(self) -> None: ... @pre_destruct async def disconnect(self) -> None: ... ``` - `@post_construct` — runs after DI construction, in topological order (deps first). Singletons are built and their hooks run in `LaurenApp.startup()` (the ASGI lifespan `startup` event), not in `LaurenFactory.create`. A sync hook runs inline on the event loop and there is no timeout. A class may declare several hooks of each kind and all of them run: `@post_construct` base class first, then definition order; `@pre_destruct` subclass first, then definition order. An override without the decorator is no longer a hook. - `@pre_destruct` — runs at shutdown in reverse-topological order, with a per-hook timeout (default 10 s). Both sync and async hooks are supported: - **Async hooks** are cancelled via `asyncio.wait_for` if they exceed the timeout. - **Sync hooks** run in a thread pool via `asyncio.to_thread` — they never block the event loop and receive the same timeout protection as async hooks. A sync hook that never returns is cancelled at the timeout boundary. Failures are collected (shutdown is always best-effort) as `DestructError` / `DestructTimeoutError`. ### Middleware & guard decorators - `@middleware()` — marks a class with a `dispatch(request, call_next)` method as middleware. Always call it with parentheses: bare `@middleware` raises `DecoratorUsageError`. - `@use_middlewares(*classes)` — attach middleware to a **controller class** OR a **handler method**. - `@use_guards(*classes)` — attach guards to a **controller class** OR a **handler method**. - `@set_metadata(key, value)` — attach free-form metadata readable by guards via `ExecutionContext.get_metadata(key, default)`. Both decoration orders work for class-level `@use_guards` and `@use_middlewares`: ```python # Order A @use_guards(AuthenticatedGuard) @controller("/x") class A: ... # Order B @controller("/x") @use_guards(AuthenticatedGuard) class B: ... ``` --- ## Types (Request / Response / State / Headers) ### `Request` Properties: `method`, `path`, `url`, `path_params`, `query_params`, `headers`, `cookies`, `client`, `server`, `state`, `app_state`, `extensions` (the ASGI `scope["extensions"]`, read-only; empty when the server sent none). Body methods (async): `await request.body() -> bytes`, `await request.text()`, `await request.json()`, `await request.form()`, `request.stream()`. Introspection: `get_handler_class()`, `get_route_handler_func()`, `get_route_template()`, `get_matched_route()`. ### `Response` Immutable value object. All `with_*` methods return new instances. **Factories:** - `Response.json(data, *, status=200, headers=None)` - `Response.text(data, *, status=200, headers=None)` - `Response.html(data, *, status=200, headers=None)` - `Response.xml(data, *, status=200, headers=None)` — `Content-Type: application/xml`; accepts `str` or `bytes` - `Response.bytes(data, *, status=200, media_type="application/octet-stream", headers=None)` - `Response.empty(status=204)`, `Response.no_content()`, `Response.created(data=None, *, location=None)`, `Response.accepted(data=None)` - `Response.redirect(location, *, status=307)` - `Response.stream(async_iterable, *, status=200, media_type=..., headers=None)` - `Response.sse(async_iterable_of_events)` - `await Response.file(path, *, media_type=None, filename=None, inline=False, chunk_size=65536, headers=None)` — async; streams a file via `anyio`; auto-detects MIME; sets `Content-Disposition`; raises `FileNotFoundError` if path absent **Builders (return new instance, preserving subclass type):** - `with_status(status)`, `with_header(k, v)`, `with_headers({k: v, ...})`, `without_header(k)`, `with_media_type(mt)`, `with_body(bytes|str)` - `with_cookie(key, value, *, max_age=None, path="/", domain=None, secure=False, http_only=False, same_site=None)` - `delete_cookie(key, *, path="/")` **Subclassing:** `Response` has no `__slots__` and is designed to be subclassed. Any `Response` subclass returned from a handler is passed through `_coerce_to_response` unchanged (the `isinstance` check fires immediately). All `with_*` builders preserve the concrete subclass type via `type(self).__new__(type(self))` in `_clone`. Add domain factory classmethods, extra instance attributes, or computed properties freely. ```python run class JsonApiResponse(Response): @classmethod def resource(cls, data: dict, *, status: int = 200) -> "JsonApiResponse": import json body = json.dumps({"data": data}, separators=(",", ":")).encode() return cls(body, status=status, media_type="application/vnd.api+json") @get("/{id}") async def get_user(self, id: int) -> JsonApiResponse: return JsonApiResponse.resource({"id": id, "name": "Alice"}) ``` ### `State` (request-scoped) and `AppState` (sealed after startup) ```python # Writes request.state.user_id = 42 request.state.set("scope", "admin") # Reads (safe) val = request.state.get_typed("user_id", int) # None if missing, raises StateTypeError if wrong type val = request.state.require("user_id", int) # raises MissingStateError if absent request.state.has("user_id") # bool ``` `AppState` raises `RuntimeError` on writes after `app.startup()` has sealed it. ### `Headers` Case-insensitive, ordered, multi-value mapping. Use `.getall(name)` to get a list of values (e.g. multiple `Set-Cookie` lines). ### Types & Connection Info `ClientInfo` and `ServerInfo` are small dataclasses with `host` and `port` fields, available on `Request.client` and `Request.server` respectively. `MutableHeaders` is the writable headers mapping used when constructing or mutating a `Response`. `ByteStream` is the zero-copy streaming body extractor: `body: ByteStream` receives an async iterable of raw `bytes` chunks so the handler can process the body without buffering it into memory. `UploadFile` is the multipart file-upload extractor — FastAPI-compatible: ```python from lauren import UploadFile @post("/upload") async def upload(self, file: UploadFile) -> dict: content = await file.read() return {"filename": file.filename, "size": len(content)} ``` `StateExtractor` is the typed extractor for `request.state` fields declared by guards or middleware. --- ## Extractors Extractors convert request data into typed Python values. Built-in: | Extractor | Source | Reads body | |---|---|---| | `Path[T]` | URL path param | no | | `Query[T]` | query string | no | | `Header[T]` | request header | no | | `Cookie[T]` | cookie | no | | `Json[T]` | JSON body, validated (see [Validation](#validation)) | yes | | `Json[Discriminated[A \| B, "key"]]` | discriminated union body | yes | | `Form[T]` | form-urlencoded body | yes | | `Bytes` | raw body bytes (buffered) | yes | | `ByteStream` | streaming body chunks (zero-copy) | yes | | `UploadFile` | multipart file upload | yes | | `State` | `request.state` | no | | `Depends[T]` | DI container | no | | `ExtractionMarker` | custom user-defined | varies | Handler parameters whose name matches a path variable are auto-promoted to `Path[...]`: ```python @get("/users/{id}") async def show(self, id: int) -> dict: # `id` is treated as Path[int] return {"id": id} ``` ### Field descriptors For validation metadata and OpenAPI hints, use `QueryField` / `HeaderField` / `CookieField`: ```python run from lauren import Query, QueryField @get("/search") async def search( self, q: Query[str], page: Query[int] = QueryField(default=1, ge=1, le=100), tags: Query[list[str]] = QueryField(default=[]), per_page: Query[int] = QueryField(default=25, ge=1, le=200, alias="pp"), ): ... ``` Supported constraints: `ge`, `le`, `gt`, `lt`, `min_length`, `max_length`, `pattern`, `alias`, `default`. They are lowered onto the validation engine's checks, so a violation is a `less_than_equal` / `string_too_short` / … issue (see [Validation](#validation)). As before, length and pattern checks apply to strings and numeric bounds to numbers; one that cannot apply is dropped with a startup warning (`UnresolvableParameterError` under `ValidationPolicy(stdlib_models="enforce")`). Constraint markers work on parameters too: `q: Query[str, MinLen(2)]`, `size: Annotated[int, Le(100)] = 10`. ### JSON body with Pydantic ```python run class CreatePost(BaseModel): title: str body: str @post("/posts", status_code=201) async def create(self, body: Json[CreatePost]) -> dict: return body.model_dump() ``` Validation errors are raised as `ExtractorError` and mapped to HTTP 422. Every 422 carries `detail["issues"]` — a list of `{"loc", "type", "msg", "ctx"?}` (pydantic errors translated field for field, `url` dropped) — next to the 1.x `detail.field` / `detail.errors` keys, and all failing parameters of a request are reported in one response (see [Validation](#validation)). ### Discriminated union body `Discriminated[A | B, "key"]` dispatches tagged-union JSON bodies to the correct variant class — no pydantic required. Works with `@dataclass`, `TypedDict`, `msgspec.Struct`, and `pydantic.BaseModel` variants: ```python run from dataclasses import dataclass from typing import Literal from lauren import Discriminated, Json, post @dataclass class Cat: kind: Literal["cat"] = "cat" name: str = "" @dataclass class Dog: kind: Literal["dog"] = "dog" name: str = "" Animal = Discriminated[Cat | Dog, "kind"] @post("/animals") async def create(body: Json[Animal]) -> dict: return {"type": type(body).__name__, "name": body.name} ``` - **Missing discriminator field** → `422`, issue `union_tag_not_found` (1.x `detail.errors` text: `"missing discriminator field 'kind'"`) - **Unknown tag value** → `422`, issue `union_tag_invalid` (`"unknown discriminator value 'fish'"`) - **Non-dict payload** → `422`, issue `model_type` (`"expected a JSON object"`) - **Invalid variant fields** → the variant's issues, nested under the tag: `["body", "cat", "lives"]` - **OpenAPI output**: `oneOf` array + `discriminator.propertyName` + `discriminator.mapping` Auto-promotion: a bare `body: Animal` parameter (no `Json[…]`) is also recognized and auto-promoted to a JSON body extraction. ### Streaming body (zero-copy) `ByteStream` provides an async iterable of raw `bytes` chunks, avoiding buffering the entire body into memory: ```python run from lauren import ByteStream @post("/ingest") async def ingest(self, stream: ByteStream) -> dict: total = 0 async for chunk in stream: total += len(chunk) return {"bytes_received": total} ``` The body is bounded by `max_body_size` (default 1 MB). An oversize body raises `RequestBodyTooLarge` (413) — for `ByteStream`, `Json[T]`, `Bytes` and form parameters alike. ### File uploads `UploadFile` is the FastAPI-compatible multipart file upload extractor: ```python from lauren import UploadFile @post("/upload") async def upload(self, file: UploadFile) -> dict: content = await file.read() return {"filename": file.filename, "size": len(content)} ``` `UploadFile` holds the part **in memory** (bounded by `max_body_size`) and provides `.filename`, `.content_type`, `.headers`, `.name` (the form field), `.size`, `async .read()` and `.read_sync()`. There is no `.file` handle and no `.write()`; spool to disk yourself if you need to. --- ## Validation Lauren validates request data natively — no pydantic needed. Dataclasses (nested, defaults, `default_factory`, `__post_init__`, `InitVar`, frozen), `TypedDict` (`total`, `Required` / `NotRequired`), `NamedTuple`, `Enum`, `Literal`, `int` / `float` / `bool` / `str` / `bytes` / `complex`, `Decimal`, `UUID`, `datetime` / `date` / `time` / `timedelta`, paths, `list` / `tuple` / `set` / `frozenset` / `dict`, `X | None`, unions, `Discriminated[...]`, recursive types and `Any` are compiled into validators at startup by the app's `TypeCompiler`. Pydantic models (and pydantic dataclasses / `Field(discriminator=)` unions) are validated by pydantic, `msgspec.Struct` by msgspec — chosen per type at compile time, so a dataclass validates the same whether or not pydantic is installed, over HTTP, WebSocket frames and `Stream[T]` items. Public names live in `lauren.validation`; `ValidationPolicy`, `ValidationIssue` and `validation_config` are also exported from `lauren`. ```python run from dataclasses import dataclass, field from typing import Annotated from lauren import Json, LaurenFactory, Query, QueryField, controller, get, module, post from lauren.testing import TestClient from lauren.validation import Ge, Le, MaxLen, Pattern, ValidationPolicy, validation_config @dataclass class Address: city: Annotated[str, MaxLen(80)] zip: Annotated[str, Pattern(r"^\d{5}$")] @validation_config(extra="forbid") @dataclass class CreateUser: email: Annotated[str, Pattern(r"^[^@\s]+@[^@\s]+$"), MaxLen(254)] age: Annotated[int, Ge(13), Le(130)] tags: Annotated[list[str], MaxLen(10)] = field(default_factory=list) address: Address | None = None def __post_init__(self) -> None: # invariants → a value_error issue if "admin" in self.tags: raise ValueError("tag 'admin' is reserved") @controller("/users") class UserController: @post("/") async def create(self, body: Json[CreateUser]) -> dict: return {"age": body.age, "city": body.address.city if body.address else None} @get("/") async def search(self, page: int, size: int = QueryField(default=10, le=100)) -> dict: return {"page": page, "size": size} @module(controllers=[UserController]) class UsersAppModule: pass client = TestClient(LaurenFactory.create(UsersAppModule, validation=ValidationPolicy(stdlib_models="enforce"))) ok = client.post("/users/", json={"email": "a@b.co", "age": "30", "address": {"city": "Lyon", "zip": "69001"}}) assert ok.json() == {"age": 30, "city": "Lyon"} # "30" coerced; address is an Address bad = client.get("/users/?page=abc&size=1000").json()["error"] assert bad["code"] == "extractor_error" and bad["message"] == "2 validation errors" assert bad["detail"]["issues"] == [ {"loc": ["query", "page"], "type": "int_parsing", "msg": "Input should be a valid integer, unable to parse string as an integer"}, {"loc": ["query", "size"], "type": "less_than_equal", "msg": "Input should be less than or equal to 100", "ctx": {"le": 100}}, ] assert bad["detail"]["field"] == "page" # 1.x keys kept until 2.0 ``` ### Coercion Path / query / header / cookie / urlencoded-form values are strings and are always parsed laxly. JSON bodies, WebSocket frames and stream items follow `ValidationPolicy(json_mode="lax" | "strict")`; `Strict()` on a field or `@validation_config(strict=True)` on a model forces strict. * `int`: lax accepts `"12"` and `12.0` (not `True`, not `"12.0"`); strict only `int`. * `float`: `int` / `float`; lax adds numeric strings. `bool`: lax accepts `true/false/1/0/yes/no/on/off` (any case) and `0` / `1`; **`"maybe"` is `bool_parsing`**. * `Decimal`, `UUID` (strict: canonical form), `datetime` / `date` / `time` (ISO 8601 only — no Unix timestamps), `timedelta` (ISO 8601 duration; lax also seconds), `bytes` (UTF-8 `str`), `Enum` (member value; lax also its string form), `Literal` (exact; lax also the string form of `int` / `bool`). * Collections from JSON arrays; from the query string or a form they read **repeated keys** (`?tag=a&tag=b`). `Path[list[int]]` / `Header[list[str]]` split one value on commas. * `X | None`: an empty query/form value (`?n=`) is `None` when `X` is not `str`. * Unions: JSON values match an exact type first, then members strictly, then laxly; query values try members laxly left to right (`int | str` reads `"5"` as `5`). Failing members report their issues under the member name. * Models: `missing`, `extra_forbidden` (with `extra="forbid"`), `model_type`; a dataclass is built by calling it, so `__post_init__` runs — a `ValueError` / `TypeError` / `AssertionError` becomes `value_error` at the model's `loc`. Query and form models (`Query[Filters]`, `Form[Signup]`) read one key per field. ### Constraints and per-type settings Markers in `Annotated` metadata (model fields and parameters): `Ge(n)`, `Gt(n)`, `Le(n)`, `Lt(n)`, `MultipleOf(n)` (numbers), `MinLen(n)`, `MaxLen(n)` (strings, bytes, collections, mappings), `Pattern(regex)` (strings, `re.search`), `Strict()`, `Alias("wireName")`. Any metadata exposing the annotated-types attribute names (`ge`, `le`, `min_length`, `pattern`, …) works, e.g. `annotated_types.Len(1, 10)`. `@validation_config(extra="ignore" | "forbid", strict=None, rename=None | "camel")` attaches `__lauren_validation__` to a dataclass / TypedDict / NamedTuple and returns it unchanged; it is not inherited (re-decorate subclasses), stacking merges, and bare `@validation_config` raises `DecoratorUsageError`. `rename="camel"` maps `first_name` ↔ `firstName`; an `Alias` wins. Startup checks raise `UnresolvableParameterError` with `detail = {"class", "handler", "param", "type", "reason", "field"?}` and `reason` in `"unsupported_type"` | `"bad_constraint"` | `"bad_pattern"`: a constraint that does not apply (`Ge` on `str`), an invalid regex, `MinLen > MaxLen`, an unknown field type. Outside `stdlib_models="enforce"` an unknown field type is a startup warning and that parameter keeps the 1.x behaviour. ### The 422 payload `ExtractorError` / `ExtractorFieldError` carry `issues` (a tuple of `ValidationIssue`) and `detail["issues"]`: dicts `{"loc", "type", "msg", "ctx"?, "input"?}`. `loc` roots: `["path", name]`, `["query", name]`, `["header", name]`, `["cookie", name]`, `["body", ...]`, `["form", ...]`, `["frame", "data", ...]` (WebSocket), `["item", n, ...]` (`Stream[T]`). `input` is echoed only with `debug=True` or `ValidationPolicy(include_input=True)`. * Every failing parameter of a request is collected into one 422 (capped by `max_issues`, default 50). Several failures → `code="extractor_error"`, `message="N validation errors"`. A single failing parameter keeps its 1.x code (`extractor_field_error` for path / query / header / cookie), message and `detail` keys, with `issues` added. * Non-validation errors still short-circuit (a custom extractor's `UnauthorizedError` → 401, `RequestBodyTooLarge` → 413). A pipe failure is a `value_error` issue at the parameter's `loc`. * `detail.field` and `detail.errors` (`"loc: msg"` strings) are kept until 2.0 — read `detail.issues`. `error_format="rfc9457"` turns the issues into the problem `errors` array; `"rfc7807"` carries them as `errors.issues`. * Build the same shape yourself with `ExtractorError.from_issues(issues, *, field=None, message=None, include_input=False, detail=None)` or `WebSocketValidationError.from_issues(issues, *, event=None, errors=None, message="validation error", include_input=False)`. * msgspec messages (``Expected `int`, got `str` - at `$.age` ``) are parsed for the path and expected type (unknown ones → `value_error` with the original text). A `msgspec.Struct` body is decoded and validated in one `msgspec.json.decode` call. ### `ValidationPolicy` `ValidationPolicy(*, stdlib_models="warn", json_mode="lax", extra="ignore", include_input=None, max_issues=50)` — a frozen dataclass passed as `LaurenFactory.create(validation=...)` / `Lauren(validation=...)`; invalid values raise `StartupError(reason="invalid_validation_policy")`. | `stdlib_models` | Dataclass / TypedDict / NamedTuple bodies, query & form models, typed scalars | |---|---| | `"legacy"` | the shallow 1.x behaviour (escape hatch) | | `"warn"` (1.x default) | the native validator runs; a would-be rejection logs one warning per (route, issue type) — never the input — and the request continues with the 1.x value | | `"enforce"` (2.0 default) | native results (real nested instances, `UUID` path params, `?on=maybe` → 422, `?n=` → `None` for `int \| None`) and 422s | Pydantic and msgspec types, and any type using validation markers / `@validation_config`, are validated natively in every mode. Under `"warn"` a dataclass body's `__post_init__` may run twice per request. ### Programmatic use ```python run from dataclasses import dataclass from lauren.validation import TypeCompiler, ValidationFailed, validate @dataclass class Job: name: str retries: int = 3 job_type = TypeCompiler().compile(Job) # app.type_compiler inside an app assert job_type.validate({"name": "sync", "retries": "5"}) == Job("sync", 5) assert job_type.project(Job("sync", 5)) == {"name": "sync", "retries": 5} assert job_type.json_schema()["$defs"]["Job"]["required"] == ["name"] try: validate(Job, {"retries": "x"}, mode="lax") # compiles on every call except ValidationFailed as exc: assert [i.type for i in exc.issues] == ["missing", "int_parsing"] ``` * `TypeCompiler(policy=None)` — per-instance cache (no global state); `.compile(tp, *, mode=None) -> CompiledType` (`mode` "lax" / "strict", default the policy's `json_mode`). * `CompiledType.validate(value, *, loc=())` → value or `ValidationFailed`; `.project(value)` → JSON builtins with only the declared fields under their wire names (response filtering; `validate(project(x))` round-trips); `.json_schema(*, dialect="2020-12" | "openapi-3.1", components=None)` → JSON Schema 2020-12 (`$defs`) or an OpenAPI 3.1 schema whose definitions go into `components`; `.explicit` — the type uses validation metadata. * `validate(tp, value, *, mode="lax", policy=None)` — one-off convenience. * `ValidationFailed(ValueError)` — `.issues: tuple[ValidationIssue, ...]`, `.errors(*, include_input=False) -> list[dict]`. * `ValidationIssue(loc, type, msg, input=MISSING, ctx=None)` — frozen; `.to_dict(*, include_input=False)`, `.prefixed(*keys)`; `str(issue)` is `"loc.path: msg"`. --- ## Pipes Pipes transform or validate an extracted parameter value **before** it reaches the handler. ```python run from lauren import ExtractorFieldError, Path, Pipe, PipeContext, is_pipe class PositiveIntPipe(Pipe): async def transform(self, value: int, ctx: PipeContext) -> int: if value <= 0: # An HTTPError maps to a 4xx; a plain ValueError would be a 500. raise ExtractorFieldError("must be positive", detail={"field": ctx.name}) return value @get("/{id}") async def get_item(self, id: Path[int, PositiveIntPipe]) -> dict: ... ``` `PipeMeta` is the metadata marker stored on pipe callables; `PIPE_META` is the sentinel attribute name; `is_pipe(obj)` returns `True` if *obj* carries the pipe marker. `PathField` provides alias / validation overrides for path parameters, analogous to Pydantic's `Field`. `FieldDescriptor` is the richer variant that supports `ge`, `le`, `min_length`, etc. `PipeContext` carries the parameter name, annotation, and owning handler metadata so a pipe can make context-aware decisions. --- ## Custom Extractors (plugin API) Any subclass of `ExtractionMarker` with an async `extract` method can participate in handler signatures. The canonical form is an **instance method** that receives an `ExecutionContext` plus the `Extraction` metadata; the legacy classmethod form is still supported for backward compatibility. ```python run from lauren.extractors import Extraction, ExtractionMarker from lauren.exceptions import UnauthorizedError from lauren.types import ExecutionContext class CurrentUser(ExtractionMarker): source = "app.current_user" # any unique string async def extract( self, execution_context: ExecutionContext, extraction: Extraction, ) -> object: uid = execution_context.request.state.get("user_id") if uid is None: raise UnauthorizedError("missing auth") return {"user_id": uid} # Usage — declare as a handler parameter annotation: @get("/me") async def me(self, user: CurrentUser) -> dict: return user ``` The extractor receives: - `execution_context` — full request/handler context, including `execution_context.request`, route metadata, and the matched handler. - `extraction` — an `Extraction` with `name`, `default`, `has_default`, and `inner_type`. Raise any `HTTPError` subclass (`UnauthorizedError`, `ForbiddenError`, ...) to short-circuit with the matching status. If the extractor needs constructor dependencies, decorate it with `@injectable` and list it in a module's `providers=[...]`. Lauren then resolves the extractor through the DI container and still calls the same `extract(self, execution_context, extraction)` method. The legacy classmethod shape still works: ```python class LegacyUser(ExtractionMarker): source = "legacy.current_user" @classmethod async def extract(cls, request, extraction, *, container, request_cache): ... ``` --- ## Dependency Injection ### `DIContainer` - `container.register(cls)` — add a provider. - `container.compile()` — validate the graph (cycles, missing deps, scope violations). - `await container.resolve(token, *, request_cache=None, framework_values=None)` — resolve an instance. - `container.get_provider(token)` — get the `Provider` metadata. - `container.has_provider(token)` — membership test. - `container.set_singleton(cls, instance)` — manually install a singleton (useful for tests). ### Scopes and their constraints - `SINGLETON` can depend on `SINGLETON` only (depending on request-scoped raises `DIScopeViolationError` at compile). - `REQUEST` can depend on `SINGLETON` or `REQUEST`. One instance per HTTP request, WebSocket connection or `app.scope()` block. - `TRANSIENT` produces a new instance each resolution; can depend on anything. ### Protocols and multi-bindings ```python run from typing import Protocol, runtime_checkable @runtime_checkable class EmailSender(Protocol): def send(self, to: str, msg: str) -> None: ... @injectable(provides=[EmailSender]) class SmtpSender: def send(self, to, msg): ... # Consumers request the Protocol: @injectable() class Notifier: def __init__(self, sender: EmailSender) -> None: ... ``` Two providers for the same token without `multi=True` raise `ProtocolAmbiguityError` at compile. Mark both `multi=True` to receive a `list[EmailSender]` at resolution. #### Type-correct `list[T]` injection Multi-bindings can be requested with their natural type at *every* injection site, not just `container.resolve(T)`: ```python run @injectable(provides=[EmailSender], multi=True) class SmtpSender: ... @injectable(provides=[EmailSender], multi=True) class SmsSender: ... # 1. Constructor injection @injectable() class Dispatcher: def __init__(self, senders: list[EmailSender]) -> None: self._senders = senders # 2. Class-body field injection @injectable() class Aggregator: senders: list[EmailSender] # 3. Handler parameters @controller("/notify") class NotifyController: @get("/channels") async def channels(self, senders: list[EmailSender]) -> dict: return {"count": len(senders)} ``` The container recognises `list[T]` (and `typing.List[T]`) as a request for every visible provider of `T` registered with `multi=True`, in registration order. Visibility, request-scope caching, and scope narrowing all apply per element. Asking for `list[T]` when `T` is registered without `multi=True` raises `ProtocolAmbiguityError` at compile time — it forces the user to decide between scalar (`T`) and collection (`list[T]`) intent. ### Generator function providers (FastAPI-style lifecycle) When an `@injectable()`-decorated function is a generator or async generator, the container treats it as a lightweight context manager: - Code **before** `yield` = setup (runs when the scope is first resolved). - The **yielded value** = the resolved dependency (what callers receive). - Code **after** `yield` = teardown (runs when the scope ends). ```python run @injectable() # SINGLETON — setup at startup, teardown at shutdown def db_pool(): pool = create_pool(DATABASE_URL) yield pool pool.close() @injectable(scope=Scope.REQUEST) # REQUEST — per-request setup + teardown def db_session(pool: Depends[db_pool]): session = pool.acquire() try: yield session finally: session.rollback(); session.release() @injectable(scope=Scope.REQUEST) async def async_client(): # async generator — awaitable setup/teardown client = await httpx.AsyncClient().__aenter__() yield client await client.aclose() ``` **Teardown timing by scope:** | Scope | Teardown trigger | |---|---| | `SINGLETON` | `app.shutdown()` / `LifecycleScheduler.run_pre_destruct()` | | `REQUEST` | When the HTTP request, WebSocket connection or `app.scope()` block ends (automatic via `aclose()` protocol) | | `TRANSIENT` | ❌ Raises `StartupError` — transient instances are not tracked | Use `try/finally` for guaranteed cleanup even when handlers raise 500 errors. ### Optional parameters with defaults If an `__init__` parameter has a default value, the DI container treats it as optional — the default is used when no provider is registered. This is what lets dataclass-backed configs work naturally: ```python run @injectable(scope=Scope.SINGLETON) @dataclass class Settings: database_url: str = "sqlite:///:memory:" jwt_secret: str = "dev" ``` ### Operation scopes (`app.scope()`) `app.scope(name="operation", *, values=None) -> OperationScope` opens the request scope without a request, for scripts, scheduled work and other non-HTTP code. Use it as an `async with` block while the app is running (after `await app.startup()`, or inside an `ApplicationContext`, whose `ctx.scope()` is the same); `Lauren()` apps have the same method once compiled (before that it raises `LifecycleViolationError`). Commands run in a scope of their own. ```python run import asyncio from lauren import LaurenFactory, OperationScope, Scope, injectable, module, pre_destruct @injectable(scope=Scope.REQUEST) class DbSession: def __init__(self) -> None: self.closed = False @pre_destruct async def close(self) -> None: self.closed = True @injectable() class Reports: async def nightly(self, db: DbSession) -> str: return "ok" @module(providers=[DbSession, Reports]) class ReportsModule: pass async def main() -> None: app = LaurenFactory.create(ReportsModule) await app.startup() async with app.scope("nightly-report") as scope: assert isinstance(scope, OperationScope) db = await scope.resolve(DbSession) # Scope.REQUEST: one instance per scope assert db is await scope.resolve(DbSession) reports = await scope.resolve(Reports) # singletons: shared as usual assert await reports.nightly(db) == "ok" assert db.closed and scope.teardown_errors == () # torn down on exit await app.shutdown() asyncio.run(main()) ``` `OperationScope` members: - `await scope.resolve(token, *, owning_module=None)` — resolve within the scope. Visibility is global by default; pass a module class to see only what that module can see. Raises `LifecycleViolationError` outside the `async with` block. - `scope.name` — the label passed to `app.scope()`; `scope.values` — read-only view of the seeded values; `scope.active` — `True` inside the block. - `scope.teardown_errors` — `tuple[DestructError, ...]` collected on exit (empty until then); each has `__cause__` set and `detail` keys `class`, `stage` (`"pre_destruct"` / `"aclose"`), `origin`, `cause`. Rules: - `Scope.REQUEST` instances are cached per scope; singletons come from the container; transients are new on every resolution and are not tracked (never torn down). - `values={Type: obj}` seeds framework values: `scope.resolve(Type)` returns `obj`, and a provider constructor parameter of that type receives it. Startup validation only accepts such a parameter for registered providers and for context types the framework declares; give your own types a provider or a default. - On exit, every request-scoped instance is torn down newest first: `@pre_destruct`, then `aclose()` (generator providers' teardown). HTTP requests and WebSocket connections use the same finaliser. Teardown errors are logged on the `lauren` logger and collected, never raised; an exception from the block propagates unchanged after teardown. - Scopes are single-use (entering twice raises `LifecycleViolationError`), do not nest or inherit, and belong to the app's container, so two apps never share instances. - Several coroutines of one operation may share a scope; two of them resolving the same request-scoped token for the first time concurrently may each build an instance, exactly as within one HTTP request. - **Outside any request or scope**, resolving a `Scope.REQUEST` provider through the container (`await app.container.resolve(DbSession)`) is a leak: by default it falls back to transient behaviour — a new instance per call that is never torn down — and logs a WARN on the `lauren` logger once per provider: *"DbSession is request-scoped but was resolved outside a request or `app.scope()`; it will never be torn down. Use `app.scope()` or enable `strict_request_scope=True`."* With `LaurenFactory.create(..., strict_request_scope=True)` (also `Lauren(strict_request_scope=True)`) the same resolution raises `DIScopeViolationError` with `detail={"token": "DbSession", "reason": "no_active_scope"}`. Requests, WebSocket connections and `app.scope()` blocks are unaffected. Lauren 2.0 makes strict the default. **Rule:** resolve request-scoped providers only inside a request or `app.scope()`. | Mode | `Scope.REQUEST` provider resolved outside any scope | |---|---| | default (`strict_request_scope=False`) | new instance per call, never torn down; one WARN per provider and app | | `strict_request_scope=True` | `DIScopeViolationError`, `detail={"token": ..., "reason": "no_active_scope"}` | --- ## Custom Providers Four NestJS-style recipes for cases where ``@injectable`` is not enough. All return a ``CustomProvider`` record listed in a module's ``providers=[...]``. ### `use_value` — bind to a literal ```python run from lauren import Token, module, use_value DB_URL = Token("DB_URL") @module(providers=[ use_value(provide=DB_URL, value="postgres://localhost/app"), use_value(provide="FEATURE_FLAGS", value={"beta": True}), # bare strings work too ]) class ValueModule: ... ``` ### `use_class` — bind a token to a different class ```python run from lauren import Scope, module, use_class class ConfigService: ... class ProductionConfig(ConfigService): ... class DevelopmentConfig(ConfigService): ... PROD = False @module(providers=[ use_class( provide=ConfigService, use=ProductionConfig if PROD else DevelopmentConfig, scope=Scope.SINGLETON, ), ]) class ConfigModule: ... ``` ### `use_factory` — compute the value through a DI-resolved function ```python run from lauren import LaurenFactory, OptionalDep, Scope, module, use_factory, use_value from lauren.logging import Logger class Engine: def __init__(self, url: str, logger: Logger | None = None) -> None: self.url, self.logger = url, logger def make_engine(url: str, log: Logger | None) -> Engine: return Engine(url, logger=log) @module(providers=[ use_value(provide=DB_URL, value="sqlite://"), use_factory( provide=Engine, factory=make_engine, injects=[DB_URL, OptionalDep("LOGGER")], # "LOGGER" is not registered → None scope=Scope.SINGLETON, ), ]) class EngineModule: ... app = LaurenFactory.create(EngineModule) ``` `async def` factories are awaited transparently. ### `use_existing` — alias one token to another ```python run from lauren import LaurenFactory, injectable, module, use_existing @injectable() class LoggerService: ... @module(providers=[ LoggerService, use_existing(provide="AliasedLoggerService", existing=LoggerService), ]) class AliasModule: ... app = LaurenFactory.create(AliasModule) ``` ### `Inject` for non-class tokens ```python run from typing import Annotated from lauren import Inject, Token DB_URL = Token("DB_URL") @injectable() class Repo: def __init__( self, url: Annotated[str, Inject(DB_URL)], ) -> None: self.url = url ``` `Token("NAME")` is hashable, identity-distinct by default (``unique=True``), and renders nicely in errors. Bare strings work but sacrifice the diagnostic and IDE benefits. --- ## Modules ```python @module(providers=[Clock], exports=[Clock]) class SharedModule: ... @module( controllers=[HealthController], providers=[Db, UserRepo], imports=[SharedModule], ) class AppModule: ... ``` Resolution rules: - A provider is visible inside a module if it's declared there, or imported from an `exports`-listed module. - Imports propagate: `A imports B` + `B imports C` makes C's exports visible to A only if B re-exports them. --- ## Guards & Middleware ### Guards (`GuardProtocol`) Guards authorize a request by returning `bool` (or raising an `HTTPError`). A falsy return raises `ForbiddenError` (403, `detail={"guard": ""}`) unless the guard class declares `deny_error` — an `HTTPError` subclass, called as `deny_error("guard denied the request", detail={"guard": ""})`, or a zero-argument factory returning an `HTTPError` instance. Authentication guards should declare `deny_error = UnauthorizedError` so a denial is a 401. `LaurenFactory.create` validates it (`GuardConfigError`, `detail["reason"] == "invalid_deny_error"`); subclasses inherit it like any class attribute. On WebSocket gateways a connect-time denial closes with 1008 and the error's `code` as the close reason; a per-message denial answers with its error frame. ```python run from typing import ClassVar from lauren import ExecutionContext, HTTPError, UnauthorizedError class ApiKeyGuard: deny_error: ClassVar[type[HTTPError]] = UnauthorizedError # 401 instead of 403 async def can_activate(self, ctx: ExecutionContext) -> bool: return ctx.request.headers.get("x-api-key") == "secret" class BasicAuthGuard: @staticmethod def deny_error() -> HTTPError: # factory: adds a challenge return UnauthorizedError("login required", challenge='Basic realm="admin"') async def can_activate(self, ctx: ExecutionContext) -> bool: return ctx.request.headers.get("authorization") == "Basic YWRtaW46cHc=" ``` ```python run from lauren import ExecutionContext class AdminGuard: async def can_activate(self, ctx: ExecutionContext) -> bool: if ctx.request.state.get("user", {}).get("role") != "admin": return False return True ``` Attach with `@use_guards(...)`. Guards declared on a controller class run before guards declared on the individual method. `@set_metadata("key", value)` lets guards read per-route policy: ```python run from lauren import get, set_metadata, use_guards class RoleGuard: async def can_activate(self, ctx): required = ctx.get_metadata("required_role", "user") return ctx.request.state.get("user", {}).get("role") == required @get("/purge") @use_guards(RoleGuard) @set_metadata("required_role", "admin") async def purge(self) -> dict: ... ``` `PUBLIC_METADATA_KEY` (`"lauren.public"`) is the well-known key for public routes: `@set_metadata(PUBLIC_METADATA_KEY, True)` marks one, and a global authentication guard honours it with `if ctx.get_metadata(PUBLIC_METADATA_KEY, False): return True`. The framework never skips a guard by itself; the `HealthModule` probe routes carry the key (see [Health & Readiness](#health--readiness)). ### Middleware (`MiddlewareProtocol`) ```python run from lauren import middleware @middleware() class RequestId: async def dispatch(self, request, call_next): import uuid request.state.rid = uuid.uuid4().hex resp = await call_next(request) return resp.with_header("x-request-id", request.state.rid) ``` Registration paths (onion order, outermost first): 1. Global — `LaurenFactory.create(AppModule, global_middlewares=[RequestId, AuthMiddleware])` 2. Controller — `@use_middlewares(...)` on the controller class 3. Route — `@use_middlewares(...)` on a handler method **Errors raised by middleware.** A middleware may raise to reject a request. An `HTTPError` raised by middleware at *any* level — before or after `call_next` — is rendered with its own status and the standard envelope (`error_format` is honoured). Which exception handlers may claim it depends on where it was raised: | Raised in | Handlers consulted | |---|---| | Route/controller middleware, guards, extractors, interceptors, the handler | route → controller → global | | Global middleware (before or after `call_next`), the session engine | global only | | An exception handler's own `catch` | none (logged with its cause, then 500) | Any other unhandled exception becomes `500 internal_error`, logged with its traceback and an `origin` naming the component that raised it. The connection stays usable and `RequestComplete` fires for every request. ### Per-Route Middleware (`use_middlewares`) `use_middlewares` attaches middleware(s) to a specific controller class or route handler without registering them globally: ```python run from lauren import use_middlewares, middleware, injectable, Scope @middleware() @injectable(scope=Scope.SINGLETON) class AuditLog: async def dispatch(self, req, call_next): response = await call_next(req) print(f"AUDIT {req.method} {req.path} → {response.status_code}") return response @use_middlewares(AuditLog) @controller("/admin") class AdminController: ... ``` `CallNext` is the callable type for `call_next` inside a middleware `dispatch` method. --- ## Interceptors Interceptors run **after guards** and **before** the handler. They wrap handler execution: ideal for timing, caching, or response transforms. ```python run from lauren import interceptor, use_interceptors, CallHandler from lauren.types import ExecutionContext, Response @interceptor() class TimingInterceptor: async def intercept(self, ctx: ExecutionContext, next: CallHandler) -> Response: import time t0 = time.monotonic() response = await next.handle() ms = (time.monotonic() - t0) * 1000 return response.with_header("x-duration-ms", f"{ms:.1f}") # Apply to a controller or individual route: @use_interceptors(TimingInterceptor) @controller("/api") class MyController: ... ``` `InterceptorProtocol` is the structural protocol every interceptor satisfies. `InterceptorConfigError` is raised when an interceptor is misconfigured at startup (e.g. applied without parentheses). `use_middlewares` is the per-controller / per-route equivalent of `global_middlewares=` on the app factory. Both `use_interceptors` and `use_middlewares` take **classes** (resolved through DI — not instances) and can be stacked; stacking accumulates. --- ## Exception Handlers Catch domain or framework exceptions and turn them into structured HTTP responses. Two forms — class (DI-injected) and function (no DI). Three attachment scopes — global, per-controller, per-route. ### Class form ```python run from lauren import ConflictError, LaurenFactory, NotFoundError, controller, exception_handler, get, module from lauren.logging import Logger, NullLogger from lauren.testing import TestClient from lauren.types import Request, Response # NotFoundError / ConflictError are built in; the handler also claims subclasses. @exception_handler(NotFoundError, ConflictError) class DomainErrors: def __init__(self, log: Logger) -> None: self.log = log async def catch(self, exc: Exception, request: Request) -> Response: self.log.warn(f"domain error: {type(exc).__name__}: {exc}") return Response.json({"error": str(exc)}, status=400) @controller("/items") class ItemController: @get("/{item_id}") async def show(self, item_id: int) -> dict: raise NotFoundError(f"item {item_id} not found") @module(controllers=[ItemController]) class ItemsModule: ... app = LaurenFactory.create( ItemsModule, global_exception_handlers=[DomainErrors], global_providers=[NullLogger], # any Logger implementation ) assert TestClient(app).get("/items/7").json() == {"error": "item 7 not found"} ``` The class is auto-marked as `@injectable(scope=Scope.SINGLETON)` so it participates in DI like guards and middleware. It MUST define `catch(self, exc, request)` returning a ``Response``. ### Function form ```python run @exception_handler(ValueError) async def handle_value_error(exc: ValueError, request: Request) -> Response: return Response.json({"detail": str(exc)}, status=422) ``` Function-form handlers are invoked directly with ``(exc, request)`` and do **not** participate in DI. For dependencies, use the class form. ### Attachment ```python run from lauren import use_exception_handlers # Per-controller — handles every method on the class: @use_exception_handlers(DomainErrors) @controller("/users") class UserController: ... # Per-route — handles only this handler: @get("/x") @use_exception_handlers(handle_value_error) async def x(self): ... # Global — handles every request: app = LaurenFactory.create( AppModule, global_exception_handlers=[DomainErrors, handle_value_error], global_providers=[NullLogger], # DomainErrors injects a Logger ) ``` Resolution order, most specific wins: route → controller → global. Within each tier handlers are tried in registration order; the first whose declared types match (`isinstance(exc, declared)`) wins. ### Rules * `@exception_handler` MUST be invoked with at least one exception type. Bare `@exception_handler` and empty `@exception_handler()` are both rejected with `ExceptionHandlerConfigError`. * A handler can declare multiple types: `@exception_handler(A, B)`, or equivalently by **stacking** the decorator (stacking *accumulates* the types, it does not overwrite — duplicates are de-duplicated): ```python @exception_handler(UnauthorizedError) @exception_handler(ForbiddenError) def to_login(exc, request): ... # catches both ``` * Subclasses of `@exception_handler` classes are NOT auto-handlers — re-decorate to opt in (`MetadataInheritanceError`). * Handlers should *return* responses, never raise. A raise inside `catch` becomes a `500 internal_error`, logged together with the error it was handling (`origin="exception_handler"`, `handler`, `cause`). * Errors raised by a **global** middleware (or the session engine) are outside any route, so only **global** handlers can claim them. * A handler that returns its own `Response` owns it: the error's `headers` (`WWW-Authenticate`, `Retry-After`, …) are **not** merged — copy them from `exc.headers` if needed. --- ## `@propagate_metadata` — copy Lauren decorator metadata between objects Like `functools.wraps` but for Lauren's `@use_*` decorators. Copies guard, interceptor, middleware, exception-handler, encoder, and user-metadata annotations from a *source* to the decorated target. ```python from lauren import propagate_metadata, use_guards, controller, get # Copy all @use_* metadata from source to target class @propagate_metadata(SourceClass) @controller("/derived") class DerivedController: ... # Copy from a function (e.g. when building decorator wrappers) @propagate_metadata(original_fn) @get("/wrapped") async def wrapper(*a, **kw): return await original_fn(*a, **kw) # Selective — only propagate guards and user metadata @propagate_metadata(Source, interceptors=False, middlewares=False, encoder=False) @controller("/x") class X: ... ``` **Ordering for list-based metadata** (guards, interceptors, middlewares, exception handlers): source entries are **prepended** before the target's own entries, so propagated behaviour runs as the outermost layer. **Encoder**: source encoder is copied only when the target has no encoder of its own (target's explicit `@use_encoder` wins). **User metadata** (`@set_metadata` key/value pairs): source dict is merged in; target's existing keys take precedence on conflict. Parameters: `source` (required), `guards=True`, `interceptors=True`, `middlewares=True`, `exception_handlers=True`, `encoder=True`, `user_metadata=True`. --- ## Auto-Serialization Handlers can return any of the following — lauren builds the `Response`. Both `async def` and plain `def` handlers coerce their return values identically (see *Sync vs Async Handlers* below). ```python @get("/r1") async def r1(self) -> dict: return {"ok": True} # -> JSON, 200 @get("/r2") async def r2(self) -> str: return "hello" # -> text/plain, 200 @get("/r3") async def r3(self) -> None: return None # -> 204 No Content @post("/r4") async def r4(self): return {"id": 42}, 201 # -> JSON, 201 (tuple form; undeclared to OpenAPI) @post("/r5") async def r5(self): return {"queued": True}, 202, {"x-queue": "default"} # body, status, headers @get("/r6") async def r6(self) -> BaseModel: return UserOut(id=1, name="x") # -> JSON via model_dump(mode="json") @get("/r7") async def r7(self) -> list[BaseModel]: return [UserOut(...), UserOut(...)] # -> JSON array of dumped models @get("/r8") async def r8(self): return Response.html("

hi

") # raw Response is honored @post("/r9", status_code=201) async def r9(self) -> dict: return {"id": 42} # -> JSON, 201 (declared: documented in OpenAPI) @post("/r10") async def r10(self) -> Created[dict]: return Created({"id": 42}, location="/r/42") # -> JSON, 201 + Location (typed result) ``` A status set by `status_code=` or a typed result (`Ok`, `Created`, `Accepted`, `NoContent`, `Annotated[T, Status(206)]`) is **declared**: OpenAPI documents it. The tuple form is **undeclared** (chosen at runtime, invisible to OpenAPI). A dataclass or model is dumped by its *runtime* type unless the route enforces a response contract — see [Response Contracts](#response-contracts). ### JSON Serialisation The default encoder is `StdlibJSONEncoder` (the process-wide default, swappable with `lauren.serialization.set_active_encoder`). `auto_encoder()` returns the fastest available one (orjson > msgspec > stdlib) — Lauren does **not** apply it for you; pass it explicitly: `LaurenFactory.create(..., json_encoder=auto_encoder())`. The encoder is threaded through the entire application — it is used for handler response coercion, error responses (`_error_response`), SSE events (`EventStream`, `Response.sse`), WebSocket `send_json`, and every streaming frame. Pass it once at factory time and every output path honours it. | Class | Dependency | Best for | |---|---|---| | `StdlibJSONEncoder` | Built-in `json` (always available) | Default, zero deps | | `OrjsonEncoder` | `orjson` C extension | Fastest for dict/list payloads | | `MsgspecEncoder` | `msgspec` | Fastest when returning `msgspec.Struct` directly | | `PydanticEncoder` | `pydantic` (v2) | Pydantic-heavy APIs; honours all `@field_serializer` / `model_config` rules; skips the intermediate `model_dump()` dict | | `JSONEncoder` | — | Protocol — implement `encode(obj) -> bytes` and `encode_compact(obj) -> bytes` | The built-in encoders accept `fallback="dict" | "warn" | "error"` (e.g. `StdlibJSONEncoder(fallback="error")`) to pin the `json_fallback` policy for objects no rule covers; without it they follow the app's `LaurenFactory.create(json_fallback=...)` (default `"warn"`), and `"dict"` outside an app. ```python run from lauren import LaurenFactory, PydanticEncoder, auto_encoder # Wire at build time — every output path inherits this encoder: app = LaurenFactory.create(AppModule, json_encoder=PydanticEncoder()) # Or auto-select best available (orjson > msgspec > stdlib): enc = auto_encoder() enc.encode({"hello": "world"}) ``` `PydanticEncoder` calls `model.model_dump_json()` / `TypeAdapter.dump_json(items)` directly so pydantic-core serialises model → bytes in one step, skipping the intermediate Python dict. Non-Pydantic values (plain dicts, lists, primitives, `msgspec.Struct`, dataclasses) fall back to `StdlibJSONEncoder` transparently. #### Per-route / per-controller encoder override — `@use_encoder` Individual routes or whole controllers can override the app-level encoder: ```python from lauren import use_encoder, OrjsonEncoder, PydanticEncoder, controller, get # Every route on this controller uses OrjsonEncoder: @use_encoder(OrjsonEncoder()) @controller("/fast") class FastController: @get("/") async def h(self) -> dict: ... # Only this one route uses PydanticEncoder; others use the app encoder: @controller("/mixed") class MixedController: @get("/models") @use_encoder(PydanticEncoder()) async def models(self) -> list[MyModel]: ... @get("/plain") async def plain(self) -> dict: ... ``` Precedence: **method-level > controller-level > app-level**. The override applies to handler response coercion, EventStream framing, and error responses for that route. Route-not-found (404) and unhandled outer-layer exceptions always use the app-level encoder because no compiled handler is in scope at those points. `@use_encoder` must always be called with parentheses — `@use_encoder(enc)`, never bare `@use_encoder`. Passing a class instead of an instance (`@use_encoder(OrjsonEncoder)`) is rejected at decoration time with a clear error. --- ## Response Contracts A route declares its output like its input — by types. `status_code=` and typed results declare the statuses, the return annotation (or `response_model=`) declares the body, and `response_validation=` decides whether the wire honours it. Everything is read in `LaurenFactory.create`; the request path runs prebuilt closures. ```python run from dataclasses import dataclass from lauren import ( Created, LaurenFactory, NoContent, NotFoundError, Ok, controller, delete, get, module, post, put, raises, ) from lauren.testing import TestClient @dataclass class UserOut: id: int name: str @dataclass class UserInDB(UserOut): password_hash: str USERS = {1: UserInDB(1, "ada", "bcrypt$...")} @controller("/accounts") class AccountController: @get("/{id}") @raises(NotFoundError, description="No account with that id") async def show(self, id: int) -> UserOut: if id not in USERS: raise NotFoundError(f"no account {id}") return USERS[id] # internal record: filtered to UserOut @post("/", status_code=201) async def create(self) -> UserOut: return USERS[1] @put("/{id}") async def upsert(self, id: int) -> Ok[UserOut] | Created[UserOut]: if id in USERS: return Ok(USERS[id]) return Created(UserOut(id, "new"), location=f"/accounts/{id}") @delete("/{id}") async def remove(self, id: int) -> NoContent: return NoContent() @module(controllers=[AccountController]) class AccountsModule: pass app = LaurenFactory.create(AccountsModule, response_validation="filter") client = TestClient(app) assert client.get("/accounts/1").json() == {"id": 1, "name": "ada"} # no password_hash assert client.post("/accounts/").status_code == 201 assert client.put("/accounts/9").header("location") == "/accounts/9" assert client.delete("/accounts/1").status_code == 204 responses = app.openapi()["paths"]["/accounts/{id}"]["put"]["responses"] assert sorted(responses) == ["200", "201"] ``` ### Typed results — `Ok[T]`, `Created[T]`, `Accepted[T]`, `NoContent`, `Status` Frozen generic dataclasses (`from lauren import Ok, Created, Accepted, NoContent, Status`): | Class | Status | Fields | |---|---|---| | `Ok(value, headers=None)` | 200 | | | `Created(value, location=None, headers=None)` | 201 | `location` → `Location` header | | `Accepted(value=None, location=None, headers=None)` | 202 | `location` → `Location` header | | `NoContent(headers=None)` | 204 | empty body | | `Status(code, description=None)` | — | `Annotated` metadata: `-> Annotated[Chunk, Status(206)]` | The wrapper's class picks the status (`.status` class attribute); `headers` are added and `location` wins over a `Location` entry in them; a `None` value sends an empty body. The value is serialized with the route's encoder (`@use_encoder` → app `json_encoder`) — use `Created(user)` rather than `Response.created(user)`, which uses the process-wide default encoder. A union of results documents one response per member; at runtime the returned instance's class picks it, and a plain value maps to the route's `status_code=` (default 200). Typed results also work on unannotated routes (`return Created(x)` replaces `return x, 201`). Startup checks (`DecoratorUsageError`, `detail = {"decorator": "route", "target", "reason"}`): `status_code` 204/304 with a body type (`reason="body_not_allowed"`; allowed: `None`, `NoContent`, no annotation, `Response`), `status_code=` contradicting a result or `Status` (`"status_conflict"`), one status declared twice (`Ok[A] | Ok[B]` → `"duplicate_status"`; write `Ok[A | B]`), plain types with different `Status` codes (`"ambiguous_status"`), an invalid `Status` code (`"invalid_status_code"`). ### `response_validation="off" | "filter" | "strict"` Set app-wide (`LaurenFactory.create(response_validation=...)`, `Lauren(...)`) and per route (`@get(..., response_validation="strict")`). The declared body type is `response_model=` when set, else the return annotation (each typed result's `T`). | Mode | Instance (or subclass) of the declared type | Mapping | Other object (ORM row) | Wrong leaf types | |---|---|---|---|---| | `"off"` (1.x default) | dumped by its runtime type (subclass fields leak) | as is | `__dict__` fallback | not checked | | `"filter"` | declared fields only | undeclared keys dropped, recursively | declared attributes read | passed through | | `"strict"` | projected + validated | projected + validated | read + validated | 500 | - Models emit declared fields under their wire names (`Alias`, `rename="camel"`, pydantic aliases), as the OpenAPI schema documents them. - A missing required field, a scalar where a model or list is declared, or a value no union member produces is a violation in both modes → 500 `{"error": {"code": "response_validation_error", ...}}`. The body never contains the payload; the violation is logged at ERROR with `issues` (`{"loc": ["response", "name"], "type": "missing", "msg": ...}`); with `debug=` the issues are also in `detail.issues`. Exception handlers do not see violations; interceptors see the exception. - Pydantic models and `msgspec.Struct` types use their library (the declared type's serializer drops a subclass's fields); non-instances are converted with `from_attributes=True`, so an unconvertible value is a violation in `filter` too. - `StreamingResponse[T]` items are projected one by one: a bad first item → 500, a later one → a `response_validation_error` error frame, then the stream ends. - Untyped routes and returned `Response` objects are never enforced. An unsupported declared type under `filter` / `strict` fails startup with `UnresolvableParameterError` (`detail["reason"] == "unsupported_response_type"`, `detail["param"] == ""`). - `response_model=` with the contract `"off"` only documents: `create()` logs one warning listing those routes. lauren 2.0 makes `"filter"` the default. ### `@raises(*http_errors, description=None)` Declares the `HTTPError` subclasses a route raises (metadata only — runtime behaviour is unchanged). On a method it propagates like `@get`; on a controller it covers the class's own routes (not a subclass's). Stacking accumulates. Feeds OpenAPI (one response per status with the error envelope schema — `LaurenErrorEnvelope`, or `LaurenProblemDetails` under `error_format="rfc9457"` — and the codes in `x-lauren-error-codes`) and `ReflectedRoute.raises`. Each argument must be an `HTTPError` subclass with an integer `status_code`; bare `@raises` / `@raises()` are rejected (`DecoratorUsageError`). With `debug=` on, a route that declares `@raises` logs one warning per undeclared `HTTPError` class its handler raises. ### `json_fallback="dict" | "warn" | "error"` An object no encoder rule covers (not a dict, list, model, dataclass, struct, date, enum, …) used to ship its public attributes. `"dict"` keeps that, `"warn"` (the default) also logs one warning per type and app, `"error"` raises `TypeError` → 500 `internal_error`. `StdlibJSONEncoder(fallback=...)` (and the other built-in encoders) pin their own policy; outside an app the fallback stays `"dict"`. lauren 2.0 makes `"error"` the default. --- ## Pagination Validated paging parameters, page responses with one envelope per app, RFC 8288 `Link` headers and opaque signed cursors. Public names live in `lauren.pagination` and are exported from `lauren`: `OffsetPagination`, `PageNumberPagination`, `CursorPagination`, `PaginationConfig`, `Page`, `CursorPage`, `paginate_sequence`. ```python run from dataclasses import dataclass from lauren import ( CursorPage, CursorPagination, LaurenFactory, OffsetPagination, Page, PaginationConfig, controller, get, module, ) from lauren.testing import TestClient @dataclass class PgUserOut: id: int name: str @dataclass(frozen=True) class PgUserKey: # the keyset; validated when a cursor is decoded id: int PG_USERS = [PgUserOut(id=i, name=f"u{i}") for i in range(1, 36)] @controller("/pg") class PgController: @get("/users") async def users(self, paging: OffsetPagination) -> Page[PgUserOut]: rows = PG_USERS[paging.offset : paging.offset + paging.limit] return Page.of(rows, paging, total=len(PG_USERS)) # total optional @get("/feed") async def feed(self, paging: CursorPagination[PgUserKey]) -> CursorPage[PgUserOut]: after = paging.cursor.id if paging.cursor else 0 if paging.direction == "next": # rows after the keyset, ascending rows = [u for u in PG_USERS if u.id > after] else: # "prev": rows before it, descending rows = [u for u in reversed(PG_USERS) if u.id < after] return CursorPage.from_rows(rows[: paging.limit + 1], paging, key=lambda u: PgUserKey(id=u.id)) @module(controllers=[PgController]) class PgModule: ... pg_client = TestClient(LaurenFactory.create(PgModule, pagination=PaginationConfig(cursor_secret="pg-key"))) r = pg_client.get("/pg/users?limit=10&offset=10&q=x") assert r.json()["meta"] == {"limit": 10, "offset": 10, "total": 35, "has_next": True} assert '; rel="next"' in r.headers["link"] assert pg_client.get("/pg/users?limit=500").status_code == 422 # loc ["query", "limit"] feed = pg_client.get("/pg/feed?limit=5").json() nxt = pg_client.get("/pg/feed", params={"cursor": feed["meta"]["next_cursor"], "limit": 5}).json() assert [u["id"] for u in nxt["items"]] == [6, 7, 8, 9, 10] bad = pg_client.get("/pg/feed", params={"cursor": "forged.0000"}) assert (bad.status_code, bad.json()["error"]["code"]) == (400, "invalid_cursor") ``` ### Paging parameters A handler parameter annotated with a paging type is a built-in extraction source (no marker, no `Depends`), compiled at startup; it may be wrapped in `Annotated[..., PaginationConfig(...)]`. | Type | Query | Attributes | |---|---|---| | `OffsetPagination` | `?limit=&offset=` | `limit`, `offset` | | `PageNumberPagination` | `?page=&per_page=` (1-based) | `page`, `per_page`, `limit` (= `per_page`), `offset` (= `(page - 1) * per_page`) | | `CursorPagination[C]` | `?cursor=&limit=` | `cursor: C \| None` (`None` on the first page), `direction` (`"next"` / `"prev"`), `limit`, `encode_cursor(key, *, direction="next") -> str` | * Integers are validated by the native engine (`Ge` / `Le` markers): `limit` / `per_page` in `1..max_limit`, `offset` in `0..max_offset`, `page >= 1` (and `(page - 1) * per_page <= max_offset`). Failures are a 422 `ExtractorFieldError` with `issues[].loc == ["query", ]`, merged with the request's other parameter failures. Missing → default; repeated → first value. * `C` is any type the validation engine compiles (dataclass, `TypedDict`, `NamedTuple`, pydantic, msgspec, scalars); bare `CursorPagination` accepts any JSON keyset. Unsupported → `UnresolvableParameterError` (`reason="unsupported_cursor_type"`) at startup. A field descriptor on a paging parameter → `UnresolvableParameterError` (`reason="pagination_field_descriptor"`); pipes run as usual. * A tampered, truncated, malformed, expired or wrong-version cursor → 400 `BadRequestError` with `code="invalid_cursor"`, `detail={"param": "cursor", "reason": "signature" | "malformed" | "version" | "expired" | "payload"}`. An empty `?cursor=` means the first page. * Built outside a request (`OffsetPagination(limit=10, offset=0)`, `PageNumberPagination(page=1, per_page=20)`, `CursorPagination(cursor=None, *, limit=20, direction="next", secret=None)`) they are plain values for unit tests (invalid arguments → `ValueError`); such pages render without links. `CursorPagination` needs `secret=` there to sign. ### `PaginationConfig(*, default_limit=None, max_limit=None, max_offset=None, limit_param=None, offset_param=None, page_param=None, per_page_param=None, cursor_param=None, envelope=None, meta_keys=None, total_header=None, base_url=None, cursor_secret=None, cursor_max_age=None)` Frozen dataclass; every `None` field inherits. App-wide: `LaurenFactory.create(..., pagination=PaginationConfig(...))` / `Lauren(pagination=...)`; per parameter: `Annotated[OffsetPagination, PaginationConfig(...)]`. Precedence: parameter → app → built-in default. | Field | Built-in | Notes | |---|---|---| | `default_limit` / `max_limit` | `20` / `100` | ints >= 1; `default_limit > max_limit` is an error unless the maximum comes from a more specific level — then the inherited default follows it down | | `max_offset` | `None` (unbounded) | int >= 0; links beyond it are omitted | | `limit_param` / `offset_param` / `page_param` / `per_page_param` / `cursor_param` | `limit` / `offset` / `page` / `per_page` / `cursor` | non-empty, distinct within a style | | `envelope` | `"items"` | `"items"` \| `"data"` \| `"bare"` — app level only | | `meta_keys` | `"snake"` | `"camel"` → `hasNext`, `perPage`, `totalPages`, `nextCursor`, `prevCursor` — app level only | | `total_header` | on for `"bare"`, else off | `X-Total-Count` when `total` is known | | `base_url` | `None` (relative links) | trusted `http(s)://` origin → absolute `Link` URLs | | `cursor_secret` | `None` | str / bytes / `Secret` or a sequence (first signs, all verify); required by any `CursorPagination` route | | `cursor_max_age` | `None` (never expire) | seconds; older cursors → `reason="expired"` | Invalid values → `StartupError` (`detail["reason"] == "invalid_pagination_config"`, `detail["field"]`, plus `route` / `handler` / `param` for a parameter layer); a non-`PaginationConfig` `pagination=` → the same reason with `detail["got"]`. A `CursorPagination` route without a secret → `StartupError` (`detail["reason"] == "pagination_secret_missing"`, `detail["route"]`). The secret never appears in `repr` or error messages. ### `Page[T]` / `CursorPage[T]` and helpers * `Page[T]`: frozen keyword-only dataclass `items: list[T]`, `limit: int`, `offset: int = 0`, `total: int | None = None`, `has_next: bool`. * `CursorPage[T]`: `items`, `limit`, `has_next`, `next_cursor: str | None = None`, `prev_cursor: str | None = None`. * `Page.of(items, paging, *, total=None, has_next=None)` — `has_next` is `offset + len(items) < total`; without `total` pass `has_next=` (never guessed: `ValueError`); more than `paging.limit` items → `ValueError`. * `Page.from_rows(rows, paging, *, total=None)` — give `limit + 1` rows; the probe row is trimmed and sets `has_next`. * `CursorPage.from_rows(rows, paging, *, key)` — `limit + 1` rows in the direction of travel (`"next"`: after the keyset ascending; `"prev"`: before it descending, returned ascending); signs `key(row)` of the boundary rows. * `paginate_sequence(seq, paging)` — any iterable; offset / page-number → `Page` with `total=len`; cursor → `CursorPage` with positional cursors (use bare `CursorPagination` or `CursorPagination[int]`). Returning a page renders (one `isinstance` check in response coercion): | `envelope` | Body | Headers | |---|---|---| | `"items"` | `{"items": [...], "meta": {...}}` | `Link` (+ `X-Total-Count` if `total_header`) | | `"data"` | `{"data": [...], "meta": {...}, "links": {"self", "first", "prev", "next", "last"}}` (`null` when unavailable) | `Link` | | `"bare"` | `[...]` | `Link`, `X-Total-Count` | `meta`: offset → `limit, offset, total, has_next`; page number → `page, per_page, total, total_pages, has_next`; cursor → `limit, has_next, next_cursor, prev_cursor` (`total` / `total_pages` are `null` when unknown). `Link` relations: `first` always, `prev` unless on the first page, `next` only with `has_next`, `last` only with a known `total` (never for cursors). URLs come from a copy of the request path and query: only the pagination parameters change, others keep their order and encoding; they are path-absolute and include the root path (`root_path=` or the ASGI `root_path`), absolute with `base_url`. A page built directly (not via the helpers) renders the app envelope without links. Items of any backend serialise through the app encoder; `(page, 206)` tuples work. Under `response_validation="filter"` / `"strict"` a page stays a page: only its `items` are projected onto `T` (issues at `["response", "items", n]`), and a route's `status_code=` applies to it. OpenAPI: pagination parameters with `minimum` / `maximum` / `default`; bodies as components `Page`, `NumberedPage`, `CursorPage` in the app's envelope (a plain array for `"bare"`); `Link` (and `X-Total-Count`) response headers. **Rules:** * Never expose raw database ids or offsets as cursors — use `CursorPagination` (signed, opaque; signed, not encrypted). * Use a dedicated `cursor_secret`, never the session secret (cursors are signed in their own namespace with the shared signer, `lauren._signing`). * `total` is optional — for large tables fetch `limit + 1` rows (`Page.from_rows`) or use cursors instead of counting. --- ## Sync vs Async Handlers Handler methods may be `async def` **or** plain `def`: ```python @controller("/items") class ItemController: @get("/async/{id}") async def get_async(self, id: Path[int]) -> dict: return await self._repo.find(id) # awaits async I/O @get("/sync/{id}") def get_sync(self, id: Path[int]) -> dict: return self._repo.find_sync(id) # blocking call — offloaded to thread pool ``` Sync handlers are **automatically offloaded** to `anyio.to_thread.run_sync` at dispatch time (the `is_coroutine` flag is set once at startup via `inspect.iscoroutinefunction`). The event loop stays free to serve other requests while the sync function executes. **Thread-safety rules for sync handlers:** - `Scope.SINGLETON` services are shared across threads — protect mutable state with `threading.Lock`. - `Scope.REQUEST` and `Scope.TRANSIENT` instances are per-request — safe to mutate freely inside one handler. - Do **not** call asyncio primitives (e.g. `asyncio.Queue.put_nowait`) directly from a sync handler. Use `asyncio.get_running_loop().call_soon_threadsafe(fn)` instead. All binding styles work with sync handlers: ```python @controller("/demo") class DemoController: @get("/instance") def instance_handler(self) -> dict: ... # sync instance method @staticmethod @get("/static") def static_handler() -> dict: ... # sync static method @classmethod @get("/cls") def class_handler(cls) -> dict: ... # sync classmethod ``` The default JSON encoder handles: Pydantic v2 models, enums (`Enum.value`), `datetime` / `date` / `time` (`isoformat()`), `timedelta` (total seconds), `UUID`, `pathlib.PurePath`, `Decimal`, `set` / `frozenset` (as list), `bytes` (UTF-8 decoded), dataclass instances, and `msgspec.Struct` values. --- ## Background Tasks `BackgroundTasks` lets you fire work **after** the response has been sent. Declare it as a handler parameter and the framework injects it: ```python run from dataclasses import dataclass from lauren import BackgroundTasks, Json, LaurenFactory, Scope, TaskHandle, controller, injectable, module, post from lauren.testing import TestClient @dataclass class SignUpBody: email: str @injectable(scope=Scope.SINGLETON) class EmailService: def __init__(self) -> None: self.sent: list[str] = [] async def send(self, addr: str) -> None: self.sent.append(addr) @controller("/accounts") class AccountController: def __init__(self, email: EmailService) -> None: self._email = email @post("/sign-up") async def sign_up(self, body: Json[SignUpBody], tasks: BackgroundTasks) -> dict: handle: TaskHandle = tasks.add_task(self._email.send, body.email) return {"status": "queued", "task_id": handle.task_id} # runs after the response @module(controllers=[AccountController], providers=[EmailService]) class AccountsModule: ... app = LaurenFactory.create(AccountsModule) assert TestClient(app).post("/accounts/sign-up", json={"email": "a@b.c"}).json()["status"] == "queued" ``` `TaskHandle` carries `task_id` and `status` (`"pending"` → `"running"` → `"done"` | `"failed"` | `"cancelled"`), plus: * `done` — `True` once the status is terminal. * `cancel()` — skips a task that has not started; returns `False` once it is running or finished (tasks share the request's `asyncio.Task`). * `await result()` — the task's return value; re-raises its exception, or `concurrent.futures.CancelledError` (an ordinary `Exception`) if it was cancelled. Tasks run only after the response is sent, so the request that queued the task cannot await it — that raises `RuntimeError`; await it from another coroutine (a WebSocket handler, a test). Both `async def` and regular `def` callables are accepted; sync callables are offloaded via `anyio.to_thread.run_sync`. If the request task is cancelled (shutdown, client disconnect) the interrupted and remaining tasks are marked `"cancelled"`. ### Background-task signals Three `LifecycleEvent` subclasses are emitted on the shared `SignalBus`: | Event | When | |---|---| | `BackgroundTaskStarted` | Just before the callable is invoked | | `BackgroundTaskComplete` | After it returns successfully | | `BackgroundTaskFailed` | After it raises an exception | --- ## Hosted Services & Scheduling Work that is not a request lives in `lauren.scheduling` (key names are also exported from `lauren`). **Hosted services** are classes whose `run(ctx)` the framework starts after startup, supervises and stops during shutdown; **scheduled methods** (`@interval`, `@cron`, `@run_once`) run on singleton injectables. Everything is discovered and validated in `LaurenFactory.create` (Phase 6a); nothing reflects at run time. Time flows through a `Clock`, so tests use `ManualClock` and never wait on wall-clock time. ```python run import asyncio from lauren import LaurenFactory, Scope, injectable, module from lauren.scheduling import ( JobContext, ManualClock, ScheduleModule, ServiceContext, cron, hosted_service, interval, run_once, ) @injectable(scope=Scope.REQUEST) class JobDbSession: # one instance per job run, torn down after it async def aclose(self) -> None: ... @injectable() class JobStats: def __init__(self) -> None: self.events: list[str] = [] @hosted_service(restart="on-failure", stop_timeout=10.0) class HeartbeatService: def __init__(self, stats: JobStats) -> None: self._stats = stats async def run(self, ctx: ServiceContext) -> None: while await ctx.sleep(30): # False as soon as stop is requested self._stats.events.append("beat") @injectable() class MaintenanceJobs: def __init__(self, stats: JobStats) -> None: self._stats = stats @interval(seconds=60, initial_delay=5, jitter=5) async def warm_cache(self) -> None: self._stats.events.append("warm") @cron("0 3 * * *", tz="UTC", name="purge-sessions", timeout=300, exclusive=True) async def purge(self, ctx: JobContext, db: JobDbSession) -> None: self._stats.events.append(f"purge {ctx.scheduled_at:%H:%M}") @run_once(delay=10) async def prime(self) -> None: self._stats.events.append("prime") jobs_clock = ManualClock() # production: omit clock= for the real clock @module(imports=[ScheduleModule.for_root(clock=jobs_clock)], providers=[JobStats, HeartbeatService, MaintenanceJobs, JobDbSession]) class JobsAppModule: ... async def run_jobs_demo() -> list[str]: app = LaurenFactory.create(JobsAppModule) await app.startup() await jobs_clock.advance(hours=3, seconds=10) await app.shutdown() return app.container.singletons()[JobStats].events job_events = asyncio.run(run_jobs_demo()) assert "purge 03:00" in job_events and job_events.count("prime") == 1 and "beat" in job_events ``` ### `@hosted_service(*, name=None, restart="on-failure", max_restarts=5, restart_window=300.0, backoff=(1.0, 30.0), on_exhausted="fail_readiness", wait_ready=False, start_timeout=30.0, stop_timeout=10.0, enabled=True)` - Must be called with parentheses. The class must define `run(self, ctx)` — `async def` (recommended) or plain `def` (runs on its own daemon thread and must poll `ctx.stop_requested`; a thread past `stop_timeout` is abandoned). A missing or malformed `run` raises `LifecycleConfigError` at decoration. - Stores `HostedServiceMeta` on the class's own `__dict__` and marks the class `@injectable(scope=SINGLETON)` unless it has its own `@injectable`. List it in a module's `providers`. A registered subclass that is not re-decorated raises `MetadataInheritanceError`. - `name` defaults to the class name (unique app-wide, else `DuplicateBindingError`). `restart`: `"never"` / `"on-failure"` / `"always"` (also after a normal return); at most `max_restarts` restarts within `restart_window` seconds; the n-th restart waits `uniform(0, min(cap, base * 2**n))` (`backoff=(base, cap)`). When restarts run out: `on_exhausted="fail_readiness"` sets `HostedServiceInfo.healthy=False`, `"log"` only logs. - `wait_ready=True`: `startup()` waits up to `start_timeout` real seconds for `ctx.mark_ready()`; a timeout (or an early exit) stops the services already started and raises `LifecycleError`. - Services do **not** require `ScheduleModule`; with it they share its clock. `ServiceContext`: `stop_requested` (thread-safe), `await sleep(seconds) -> bool` (`False` when interrupted by stop), `await wait_stopped(timeout=None) -> bool`, `mark_ready()` / `ready` (reset on restart), `name`, `restart_count`, `log` (bound `context="Service:"`), and `scope(name=None)` — a new `OperationScope` (seeded with the context) for one iteration's request-scoped dependencies: `async with ctx.scope() as scope: db = await scope.resolve(DbSession)`. ### `@interval(seconds, *, initial_delay=0, mode="fixed_rate", ...)`, `@cron(expression, *, tz=None, ...)`, `@run_once(delay=0, ...)` - `@interval`: `"fixed_rate"` fires on a grid anchored at `READY + initial_delay` (the first run is `initial_delay` seconds after startup); `"fixed_delay"` waits `seconds` after each run completes. `@cron`: next fire computed in `tz` (default: the `ScheduleModule` timezone). `@run_once`: once, `delay` seconds after `READY`. - Common options and defaults: `name=None` (→ `Class.method`; stacked schedules on one method → `Class.method#1`, `#2`), `overlap="skip"` (`"skip"` | `"queue"` — one run right after the current one | `"allow"`), `max_instances=1` (3 with `"allow"`), `timeout=None` (cancel → `JobFailed(error=TimeoutError)`), `jitter=0` (random `0..jitter` s per fire, no drift), `misfire="coalesce"` (woke later than `misfire_grace`: run once now for the latest missed fire; `"skip"` → `JobSkipped(reason="misfire")`), `misfire_grace=None` (→ `min(60, period)`), `exclusive=False` (take the `SchedulerLock` per fire), `enabled=True`. Seconds options accept `timedelta`. - Bad arguments (non-positive `seconds`, malformed cron, unknown `tz`, bare `@interval`) raise `DecoratorUsageError` at decoration. The marker `__lauren_schedule__` is a list: stacking accumulates. - Method rules (startup): the provider must be `Scope.SINGLETON` (`LifecycleConfigError`); parameters are `self`, an optional `JobContext` (matched by annotation) and dependencies visible from the provider's module (`UnresolvableParameterError` otherwise), resolved per run in a fresh `OperationScope` — `Scope.REQUEST` providers get one instance per run, torn down after it, and may take a `JobContext` constructor parameter. Sync methods run in a worker thread. Inherited methods count; an undecorated override removes the schedule. - Scheduled methods without `ScheduleModule.for_root()` → `LifecycleConfigError` (`detail["reason"] == "scheduling_not_enabled"`); importing it twice → `LifecycleConfigError` (`"duplicate_schedule_module"`). `JobContext`: `name`, `scheduled_at` (nominal fire time, aware, job time zone), `fired_at`, `attempt` (always 1), `log` (bound `context="Job:"`), `cancelled` (set on timeout / shutdown; sync jobs poll it), `scope` (the run's `OperationScope` when the method takes dependencies, else `None`). ### `ScheduleModule.for_root(*, timezone="UTC", lock=None, enabled=True, clock=None, job_drain_timeout=None)` Returns a module for `@module(imports=[...])`. `lock` backs `exclusive=True` (default: per-process `InMemorySchedulerLock`); `enabled=False` keeps discovery, validation and `trigger()` but fires nothing; `clock` (pass a fresh `ManualClock` per app in tests); `job_drain_timeout` bounds how long shutdown waits for in-flight runs (default: what is left of `drain_timeout`). Importing it registers the app's `SchedulerRegistry` as a global provider. ### `SchedulerRegistry` Inject it anywhere (one per app). `jobs() -> tuple[JobInfo, ...]`, `job(name)`, `services() -> tuple[HostedServiceInfo, ...]`, `await trigger(name) -> JobRun` (runs now, respects `overlap` and `exclusive`; works with firing disabled; `LifecycleViolationError` before `startup()` or during shutdown), `pause(name)`, `resume(name)`, `remove(name)`, `add_interval(name, fn, *, seconds, ...)`, `add_cron(name, fn, expression, *, tz=None, ...)`. Dynamic jobs call `fn()` or `fn(ctx)` — no DI — and a name collision raises `DuplicateBindingError`. Unknown names raise `KeyError`. - `JobInfo` (frozen; `as_dict()` is JSON-friendly): `name`, `kind` (`"interval"` | `"cron"` | `"once"`), `schedule`, `next_run_at`, `last_run_at`, `last_status` (`"completed"` | `"failed"` | `"cancelled"`), `last_error`, `last_duration_s`, `running`, `paused`, `enabled`, `dynamic`, `runs`, `failures`, `skips`. Bounded: no history. - `JobRun`: `name`, `status` (`"completed"` | `"failed"` | `"skipped"`), `scheduled_at`, `duration_s`, `error`, `reason`. - `HostedServiceInfo`: `name`, `state` (`"pending"` | `"disabled"` | `"running"` | `"restarting"` | `"stopping"` | `"stopped"` | `"failed"`), `ready`, `restart_count`, `last_error`, `healthy`; `as_dict()`. ### `CronExpression(expression, *, tz=None)` Five fields `minute hour day-of-month month day-of-week` or six with a **leading seconds** field; `*`, `a,b`, `a-b`, `*/n`, `a-b/n`, `a/n`, `JAN`-`DEC`, `SUN`-`SAT` (0 and 7 = Sunday), macros `@yearly` / `@annually`, `@monthly`, `@weekly`, `@daily` / `@midnight`, `@hourly`. Both day fields restricted → either matches (POSIX). `L`, `W`, `#`, `?`, `@reboot` and never-firing expressions are rejected with `DecoratorUsageError` (`detail`: `field`, `value`, `allowed`, `reason`). Methods: `next_after(dt)`, `iter_from(dt)` (strictly after `dt`, increasing), `matches(dt)`, `with_tz(tz)`; properties `expression`, `tz`, `has_seconds`. DST: a local time in a spring-forward gap fires once at the first valid instant after it; an ambiguous fall-back time fires once, at its first occurrence. Interval jobs use the monotonic clock. ```python run from datetime import datetime, timezone from itertools import islice from lauren import CronExpression nightly = CronExpression("0 3 * * MON-FRI", tz="UTC") friday_noon = datetime(2026, 10, 23, 12, 0, tzinfo=timezone.utc) assert [d.day for d in islice(nightly.iter_from(friday_noon), 3)] == [26, 27, 28] ``` ### `SchedulerLock` (Protocol), `InMemorySchedulerLock(*, clock=None)` `async acquire(key, *, ttl) -> bool` / `async release(key)`. For each fire of an `exclusive=True` job the scheduler acquires `"lauren:job:{name}:{fire_time_iso}"` with `ttl` = the job's `timeout`, else `min(period, 3600)`; a lost race emits `JobSkipped(reason="locked")`. The claim is held until the TTL expires (not released after the run), so a late worker cannot re-run the fire. The in-memory default is per process (one INFO log at startup says so); share a Redis / database lock across workers, or run scheduled work in one process. ### Clocks: `Clock` (Protocol), `SystemClock`, `ManualClock(start=None)` `Clock`: `monotonic()`, `now()` (aware UTC), `async sleep(seconds)`. `ManualClock` starts at `2000-01-01T00:00:00Z` (or `start`): `await advance(seconds=0, *, minutes=0, hours=0, days=0)` wakes every sleeper due in the window in order and lets the loop settle (sync jobs on threads included); `await jump(...)` moves time without waking anything on the way (simulates a suspended process — misfire tests); `sleepers` counts pending sleeps. Fire times, jitter, misfire, per-run timeouts, restart backoff and `ServiceContext.sleep` follow the clock; `start_timeout`, `stop_timeout` and the job drain stay in real time. ### Lifecycle order Startup: `StartupBegin` → `@post_construct` (topological) → hosted services start (dependency order; `wait_ready` awaited) → scheduler armed → `StartupComplete` → scheduler fires (`run_once` / `initial_delay` count from here). Shutdown: `ShutdownBegin` + scheduler stops firing → requests drain → in-flight job runs awaited, then cancelled → hosted services stop (reverse order) → `on_shutdown` → `@pre_destruct`. ### Testing `ScheduleModule.for_root(clock=ManualClock())` + `await clock.advance(...)`; `ScheduleModule.for_root(enabled=False)` or `LaurenFactory.create(..., scheduler=False)` + `await registry.trigger("name")`; `LaurenFactory.create(..., hosted_services=False)`. `with TestClient(app):` runs startup, requests and shutdown on one persistent event loop, so hosted services and the scheduler keep running between requests and stop when the block exits. **Rules:** - Never `asyncio.create_task(...)` from `@post_construct` for a background loop; use `@hosted_service`. - Scheduled jobs require `ScheduleModule.for_root()` (or `ScheduleModule.for_root(enabled=False)` to disable them explicitly). - With several workers every job runs in each one: use `exclusive=True` with a shared `SchedulerLock`, or a dedicated scheduler process. - Durable, retried jobs are out of scope: runs missed while the process was down are not replayed (beyond `misfire_grace`). --- ## Standalone Context & Commands Run the application's DI graph and lifecycle without serving HTTP — for scripts, one-off admin tasks, Kubernetes `CronJob`s and queue workers. The API lives in `lauren.context` and `lauren.commands`; `ApplicationContext`, `command`, `Opt`, `CommandContext` and `CommandResult` are also exported from `lauren`. ### `LaurenFactory.create_context(root_module, *, hosted_services=False, scheduler=False, drain_timeout=10.0, **create_kwargs)` Builds the app with `LaurenFactory.create(root_module, **create_kwargs)` — every phase, routes included, so if a script boots, the server boots — and returns an `ApplicationContext`. Nothing is served. Two other ways to get one: - `app.context(*, hosted_services=False, scheduler=False, drain_timeout=10.0)` wraps a built, **not yet started** `LaurenApp` (for tooling that only knows `main:app`). An app that was already started raises `LifecycleViolationError` (`reason="app_already_started"`): one lifecycle has one owner. - `ApplicationContext.from_lauren(lauren_app, *, hosted_services=False, scheduler=False, drain_timeout=10.0)` compiles an imperative `Lauren` app (without starting it) and wraps it. ```python run import asyncio from lauren import LaurenFactory, Scope, injectable, module, post_construct, pre_destruct from lauren.commands import CommandContext, command @injectable() class Database: def __init__(self) -> None: self.rows: list[str] = [] self.connected = False @post_construct async def connect(self) -> None: self.connected = True @pre_destruct async def disconnect(self) -> None: self.connected = False @injectable(scope=Scope.REQUEST) class UnitOfWork: def __init__(self, db: Database) -> None: self.db = db self.closed = False async def aclose(self) -> None: # runs when the scope ends self.closed = True @injectable() class SeedCommands: @command("db:seed", help="Insert fixture rows.") async def seed(self, ctx: CommandContext, uow: UnitOfWork, *, count: int = 3) -> int: uow.db.rows.extend(f"row-{i}" for i in range(count)) ctx.echo(f"seeded {count} rows") return 0 @module(providers=[Database, UnitOfWork, SeedCommands]) class ToolsModule: pass async def main() -> None: async with LaurenFactory.create_context(ToolsModule) as ctx: # startup: @post_construct db = ctx.get(Database) # an already constructed singleton assert db.connected async with ctx.scope("backfill") as scope: # the request scope without a request uow = await scope.resolve(UnitOfWork) assert uow.closed result = await ctx.run_command("db:seed", "--count", "2") assert (result.exit_code, result.stdout) == (0, "seeded 2 rows\n") assert db.rows == ["row-0", "row-1"] assert not db.connected # shutdown: @pre_destruct asyncio.run(main()) ``` `ApplicationContext` members: | Member | Semantics | |---|---| | `async with ctx:` | `await app.startup()` on enter — `@post_construct`, then `Lauren.on_startup` hooks; hosted services and the scheduler only when enabled on the context **and** the app. `await app.shutdown(drain_timeout=...)` on exit — `on_shutdown`, then `@pre_destruct`. An exception from the block propagates after shutdown; a startup failure shuts down whatever had started, then propagates. Single-use | | `ctx.app`, `ctx.container` | Escape hatches: the `LaurenApp` and its `DIContainer` | | `ctx.active` | `True` inside the block | | `ctx.get(token)` | The already constructed singleton, synchronously (a list for a `list[T]` or multi-bound token). `MissingProviderError` for an unknown token, `DIScopeViolationError` (`reason="not_singleton"`) for a request-scoped or transient one | | `await ctx.resolve(token, *, owning_module=None)` | General resolution (singletons, new transients). A `Scope.REQUEST` token raises `DIScopeViolationError` (`reason="no_active_scope"`): use a scope | | `ctx.scope(name="context", *, values=None)` | A new `OperationScope`, as `app.scope()` | | `await ctx.run_command(name, *argv, stdin=None, interactive=False, assume_yes=False, verbose=False)` | Runs a discovered command like `python -m lauren exec` and returns a `CommandResult`; failures become exit codes, never exceptions | | `ctx.commands(*, include_hidden=False)` | `tuple[CommandInfo, ...]` sorted by name; works before entering | | `await ctx.wait_for_shutdown_signal(signals=(SIGINT, SIGTERM))` | Blocks until one of the signals arrives and returns its number. The handlers exist for this call only, so a second signal gets the default behaviour | | `ctx.lifecycle_state` | The wrapped app's `AppLifecycleState` (`CREATED` before enter, `READY` inside the block, `STOPPED` after exit) | `get`, `resolve`, `scope`, `run_command` and `wait_for_shutdown_signal` raise `LifecycleViolationError` (`reason="context_not_active"`) outside the block. A worker wants hosted services but no HTTP: ```python fragment async def worker_main() -> None: async with LaurenFactory.create_context(WorkerModule, hosted_services=True) as ctx: await ctx.wait_for_shutdown_signal() # SIGINT / SIGTERM → leave the block → graceful shutdown ``` ### `@command(name, *, help=None, aliases=(), hidden=False)` Marks a method of any provider as a command. It must be called with parentheses, and `name` must match `^[a-z0-9][a-z0-9._:-]*$` (`DecoratorUsageError` otherwise). It appends a `CommandMeta` to `__lauren_command__` and returns the function unchanged; stacking registers several names. `help` defaults to the docstring's first line; `aliases` are extra names; `hidden=True` keeps a command runnable but out of listings. Commands are discovered app-wide by the Phase 6a discovery pass of `LaurenFactory.create` (method lookup follows the MRO like routes) and validated there: a duplicate name or alias raises `DuplicateBindingError` (`reason="duplicate_command_name"`, `targets` names both methods), and a parameter that is neither injectable nor a CLI type raises `UnresolvableParameterError` (`reason="unsupported_command_parameter"`, with `command` and `param`). The provider may have any scope: every invocation runs in its own `OperationScope`, seeded with the `CommandContext`, so a `Scope.REQUEST` command class or dependency is built per run and torn down afterwards. Parameters are mapped once, at startup, where the `argparse` parser is built too: | Parameter | Becomes | |---|---| | annotated `CommandContext` | injected | | `Annotated[T, Inject(token)]`, or a type the provider's module can resolve | injected from the command's scope (route-handler precedence; a provider another module does not export is **not** visible) | | `bool`, wherever declared | `--name / --no-name` flag (`bool \| None` defaults to `None`) | | keyword-only (after `*`) | `--kebab-case` option; required when it has no default | | any other parameter without a default | required positional argument | | any other parameter with a default | optional positional argument | | `list[T]` / `tuple[T, ...]` | repeatable option (`--tag a --tag b`); as a positional, one or more values (zero or more with a default) | | `T \| None` | optional, default `None` | | `Enum`, `Literal[...]` | choices (enum values or member names) | | anything else | `UnresolvableParameterError` at startup | CLI types are `str`, `int`, `float`, `Decimal`, `bool`, `Path`, `UUID`, `datetime` / `date` / `time` (ISO 8601), `timedelta` (seconds or an ISO 8601 duration), `Enum` and `Literal`, converted with the lax table typed configuration uses. An unannotated parameter is a `str` (or its default's type). `Annotated[T, Opt(short="-n", env="SEED_COUNT", help="rows")]` adds a short flag, an environment-variable fallback (read at run time; the command line wins) and help text; `short` and `env` apply to options only (`DecoratorUsageError`, `reason="opt_on_positional"`). Help is generated from `help=` or the docstring, `Opt(help=...)`, defaults, choices and env names; `-h` / `--help` prints it. ```python fragment @injectable() class SeedCommands: def __init__(self, db: Database) -> None: self._db = db @command("db:seed", help="Insert fixture data.", aliases=("seed",)) async def seed( self, ctx: CommandContext, fixture: Fixture = Fixture.DEMO, # optional positional, choices *, count: Annotated[int, Opt(short="-n", help="rows per table")] = 50, # -n / --count reset: bool = False, # --reset / --no-reset ) -> int: if reset and not ctx.confirm("Truncate every table?"): return 1 ctx.echo(f"seeded {await self._db.seed(fixture, count)} rows") return 0 @command("db:export") async def export(self, target: Path, *, tables: list[str] | None = None) -> dict: ... # the returned dict is printed as JSON ``` ### Results, output and exit codes | Outcome | Exit code | Output | |---|---|---| | returns `None` | 0 | — | | returns an `int` | that int | — | | returns a `bool` | 0 if true, else 1 | — | | returns another value | 0 | JSON-encoded with the app's `json_encoder` to stdout (exit 1 if it cannot be encoded) | | `ctx.fail("message", exit_code=2)` | 2 | message to stderr; raises `SystemExit`, so `finally` blocks and scope teardown run | | unknown command or argument error | 2 | message or argparse usage to stderr | | uncaught exception | 1 | one-line summary to stderr; the full traceback with `--verbose` | | the app cannot be built or started (runner) | 3 | the rendered error and its `detail` | | first SIGINT / SIGTERM (runner) | 130 / 143 | the command task is cancelled (`finally` runs), the scope is torn down, the app shuts down; a second signal exits immediately | `CommandContext` — annotate a command parameter, or a request-scoped provider's constructor parameter, with it: `name` (the canonical name), `argv`, `echo(*parts, sep=" ", end="\n")` → stdout, `error(*parts)` → stderr, the `stdout` / `stderr` streams, `is_interactive` (the runner's stdin is a terminal), `assume_yes` (`--yes`), `confirm(prompt, *, default=False)` (`--yes` answers yes; when nobody can answer it **refuses** — a note to stderr, returns `False` — instead of hanging), `fail(message, exit_code=1)` and `log` (the app logger bound to `context="Command:"`). Write output with `ctx.echo`, not `print()`, so it can be captured. `CommandResult(exit_code, value=None, stdout="", stderr="", exception=None)` is frozen: `value` is the return value and `exception` the uncaught exception, if any. For tooling, `lauren.commands` also has `CommandInfo(name, aliases, help, hidden, parameters, module, target)` and `CommandParameter(name, kind, flags, type, required, default, repeatable, choices, env, help)` (`kind` is `"positional"`, `"option"` or `"flag"`). ### `python -m lauren` ```text python -m lauren exec [-t TARGET] [-y] [-v] [TARGET] COMMAND [ARGS...] python -m lauren commands [-t TARGET] [--all] [TARGET] python -m lauren console [-t TARGET] [-v] [TARGET] ``` `TARGET` is `pkg.module:attr` — or `--target`, or `$LAUREN_TARGET` — naming a `@module` class (→ `create_context`), a `LaurenApp` (→ `app.context()`), a `Lauren` app (→ `from_lauren`), an `ApplicationContext`, or a zero-argument (sync or async) factory returning one of them (`main:create_app`). - `exec` looks the command up and parses its arguments **before** starting the app, so an unknown command, `--help` or a usage error never runs `@post_construct`; then it starts the app (hosted services and the scheduler off), runs the command in a fresh scope and shuts down. `--yes` / `--verbose` are also accepted after `COMMAND` unless the command declares those options. - `commands` lists the commands without starting the app (`--all` adds hidden ones). - `console` starts the app and opens a Python REPL with `ctx`, `app`, `container`, `get`, `resolve`, `scope` and `asyncio` preloaded; top-level `await` works and `exit()` / Ctrl-D shuts the app down. Exit codes follow the table above; a malformed TARGET is a usage error (2), and an import failure or a TARGET of the wrong kind is 3. The runner is stdlib-only and keeps no state between runs; `lauren-cli`'s `lauren exec`, `lauren commands` and `lauren console` run it in-process. ### Testing commands `Test.create_testing_module(...)` overrides apply to contexts exactly as to apps; `ctx.run_command` captures the output: ```python run import asyncio from lauren import injectable, module from lauren.commands import CommandContext, command from lauren.testing import Test @injectable() class Mailer: def send(self, to: str) -> str: raise RuntimeError("would send real mail") @injectable() class MailCommands: def __init__(self, mailer: Mailer) -> None: self._mailer = mailer @command("mail:test") def send_test(self, ctx: CommandContext, to: str) -> None: ctx.echo(self._mailer.send(to)) @module(providers=[Mailer, MailCommands]) class MailModule: pass class FakeMailer: def send(self, to: str) -> str: return f"queued for {to}" async def main() -> None: app = Test.create_testing_module(MailModule).override_provider(Mailer).use_value(FakeMailer()).compile() async with app.context() as ctx: result = await ctx.run_command("mail:test", "ada@example.com") assert (result.exit_code, result.stdout) == (0, "queued for ada@example.com\n") asyncio.run(main()) ``` **Rules:** - Never expose admin or maintenance tasks (seeding, migrations, purges) as HTTP endpoints: write a `@command` and run it with `python -m lauren exec` (a Kubernetes `CronJob` for periodic work). - Resolve request-scoped providers only inside a request, `app.scope()` / `ctx.scope()` or a command; anywhere else they leak (see [Operation scopes](#operation-scopes-appscope) and `strict_request_scope=True`). - Keep commands thin adapters over services, like controllers. - A context owns its app's lifecycle: never `await app.startup()` on an app you wrap. --- ## Error Catalog All exceptions live in `lauren.exceptions` and inherit from `LaurenError`. Every class, generated from `lauren/exceptions.py` (startup errors are raised by `LaurenFactory.create` or at decoration time and are never HTTP-mapped; `HTTPError` subclasses render as responses; lifecycle errors are logged): | Class | Base | HTTP status | `code` | Meaning | |---|---|---|---|---| | `LaurenError` | `Exception` | — | `lauren_error` | Base class for all lauren framework errors. | | `StartupError` | `LaurenError` | — | `startup_error` | Base class for configuration errors raised by `LaurenFactory.create` (never HTTP-mapped). | | `RouterConflictError` | `StartupError` | — | `router_conflict` | Two routes share the same (method, path) signature. | | `CircularDependencyError` | `StartupError` | — | `circular_dependency` | A cycle was detected in the DI graph. | | `CircularModuleError` | `StartupError` | — | `circular_module` | A cycle was detected in the module import graph. | | `MissingProviderError` | `StartupError` | — | `missing_provider` | No provider is registered for a requested token. | | `ProtocolAmbiguityError` | `StartupError` | — | `protocol_ambiguity` | A protocol has multiple providers but `multi=False` was requested. | | `ModuleExportViolation` | `StartupError` | — | `module_export_violation` | A module exports a provider it neither declares nor imports. | | `LifecycleConfigError` | `StartupError` | — | `lifecycle_config` | A lifecycle-bound declaration is misused (a hook on a non-method, a hosted service or job on a non-singleton, …). | | `MetadataInheritanceError` | `StartupError` | — | `metadata_inheritance` | Injectable metadata was inherited from a base class (forbidden). | | `DuplicateBindingError` | `StartupError` | — | `duplicate_binding` | The same class or token is registered twice in one container. | | `UnresolvableParameterError` | `StartupError` | — | `unresolvable_parameter` | A handler or provider parameter cannot be resolved to an extractor or dep. | | `DIScopeViolationError` | `StartupError` | — | `di_scope_violation` | A singleton depends on a request-scoped provider (or similar). | | `MiddlewareConfigError` | `StartupError` | — | `middleware_config` | A middleware lacks `dispatch(request, call_next)`, or `@use_middlewares` cannot run where placed. | | `GuardConfigError` | `StartupError` | — | `guard_config` | A guard lacks `can_activate(context)`, or `@use_guards` cannot run where placed. | | `InterceptorConfigError` | `StartupError` | — | `interceptor_config` | An interceptor lacks `intercept(ctx, call_handler)`, or `@use_interceptors` cannot run where placed. | | `ExceptionHandlerConfigError` | `StartupError` | — | `exception_handler_config` | An `@exception_handler` / `@use_exception_handlers` was misused. | | `SessionConfigError` | `StartupError` | — | `session_config` | A `SessionConfig` was invalid, or a `Session` was injected into a handler in an app that did not enable sessions. | | `ConfigValidationError` | `StartupError` | — | `config_validation` | One or more settings classes failed to load or validate. | | `OpenAPISchemaError` | `StartupError` | — | `openapi_schema` | Reserved for strict OpenAPI generation — not raised yet. | | `DecoratorUsageError` | `StartupError` | — | `decorator_usage` | A decorator or provider helper was used incorrectly — most often without parentheses. | | `HTTPError` | `LaurenError` | 500 | `http_error` | Base class for errors mapped to HTTP responses. | | `ExtractorError` | `HTTPError` | 422 | `extractor_error` | A request value could not be extracted or validated. | | `ExtractorFieldError` | `ExtractorError` | 422 | `extractor_field_error` | One parameter is missing or violates a field constraint. | | `BadRequestError` | `HTTPError` | 400 | `bad_request` | The request is malformed in a way that is not schema validation. | | `NotFoundError` | `HTTPError` | 404 | `not_found` | The requested resource does not exist — the domain 404. | | `RouteNotFoundError` | `NotFoundError` | 404 | `route_not_found` | No route matches the request path. | | `MethodNotAllowedError` | `HTTPError` | 405 | `method_not_allowed` | The path matches, but not for this HTTP method; `allow=` sets the `Allow` header. | | `NotAcceptableError` | `HTTPError` | 406 | `not_acceptable` | No available representation matches the request's `Accept` headers. | | `ConflictError` | `HTTPError` | 409 | `conflict` | The request conflicts with the current state of the resource. | | `GoneError` | `HTTPError` | 410 | `gone` | The resource existed but has been removed permanently. | | `PreconditionFailedError` | `HTTPError` | 412 | `precondition_failed` | A conditional request header (`If-Match`, `If-Unmodified-Since`) evaluated to false. | | `RequestBodyTooLarge` | `HTTPError` | 413 | `request_body_too_large` | The request body exceeds `max_body_size`. | | `UnsupportedMediaTypeError` | `HTTPError` | 415 | `unsupported_media_type` | The request body's `Content-Type` is not one the route accepts. | | `TooManyRequestsError` | `HTTPError` | 429 | `too_many_requests` | The client sent too many requests; `retry_after=` sets `Retry-After`. | | `ServiceUnavailableError` | `HTTPError` | 503 | `service_unavailable` | The server cannot handle the request right now; `retry_after=` sets `Retry-After`. | | `RequestTimeoutError` | `HTTPError` | 504 | `request_timeout` | A server-side deadline was exceeded while handling the request (504). | | `UnauthorizedError` | `HTTPError` | 401 | `unauthorized` | Authentication is missing or invalid; `challenge=` sets `WWW-Authenticate`. | | `ForbiddenError` | `HTTPError` | 403 | `forbidden` | The caller is not allowed — also what a guard returning `False` raises by default. | | `MissingStateError` | `HTTPError` | 500 | `missing_state` | `request.state.require(key, T)` found no value for `key`. | | `StateTypeError` | `HTTPError` | 500 | `state_type_error` | A `request.state` value has the wrong type for `get_typed` / `require`. | | `LifecycleError` | `LaurenError` | — | `lifecycle_error` | Base class for errors around startup, shutdown and lifecycle hooks. | | `LifecycleViolationError` | `LifecycleError` | — | `lifecycle_violation` | A lifecycle operation was used in the wrong state (`startup()` twice, a mutator after compile). | | `DestructError` | `LifecycleError` | — | `destruct_error` | A `@pre_destruct` hook raised during shutdown (collected; shutdown continues). | | `DestructTimeoutError` | `LifecycleError` | — | `destruct_timeout` | A `@pre_destruct` hook exceeded its timeout (collected; shutdown continues). | | `DrainTimeoutError` | `LifecycleError` | — | `drain_timeout` | Reserved: not raised — a drain timeout at shutdown is logged as a warning. | All HTTP errors serialize as: ```json {"error": {"code": "...", "message": "...", "detail": { ... }}} ``` ### Built-in HTTP errors Raise the everyday statuses directly — never re-declare them: `BadRequestError` (400), `UnauthorizedError` (401), `ForbiddenError` (403), `NotFoundError` (404; routing misses raise its subclass `RouteNotFoundError`), `MethodNotAllowedError` (405), `NotAcceptableError` (406), `ConflictError` (409), `GoneError` (410), `PreconditionFailedError` (412), `RequestBodyTooLarge` (413), `UnsupportedMediaTypeError` (415), `ExtractorError` (422), `TooManyRequestsError` (429), `ServiceUnavailableError` (503), `RequestTimeoutError` (504 — a *server-side* deadline, code `request_timeout`; not HTTP 408). For a domain code, subclass the matching built-in (it keeps the status, and `@exception_handler(NotFoundError)` claims it too): ```python run from datetime import datetime, timezone from lauren import ( ConflictError, HTTPError, LaurenFactory, MethodNotAllowedError, NotFoundError, ServiceUnavailableError, TooManyRequestsError, UnauthorizedError, controller, get, module, ) from lauren.testing import TestClient class UserNotFound(NotFoundError): code = "user_not_found" class EmailTaken(ConflictError): code = "email_taken" # headers= on every error (mapping, or (name, value) pairs to repeat a name); # typed sugar for the headers HTTP requires: UnauthorizedError("bad token", challenge='Bearer realm="api"') # WWW-Authenticate TooManyRequestsError("slow down", retry_after=30) # Retry-After: 30 ServiceUnavailableError("draining", retry_after=datetime(2026, 10, 23, 18, 0, tzinfo=timezone.utc)) # Retry-After: Fri, 23 Oct 2026 18:00:00 GMT MethodNotAllowedError("use PUT", allow=["PUT"]) # Allow: PUT (wherever raised) NotFoundError("gone", headers={"cache-control": "no-store"}) # Instance overrides (escape hatch; subclass for stable codes): HTTPError("I'm a teapot", status_code=418, code="teapot") @controller("/users") class UserController: @get("/{user_id}") async def show(self, user_id: int) -> dict: raise UserNotFound("user does not exist", detail={"id": user_id}) @module(controllers=[UserController]) class UsersModule: ... r = TestClient(LaurenFactory.create(UsersModule)).get("/users/7") assert r.status_code == 404 assert r.json() == {"error": {"code": "user_not_found", "message": "user does not exist", "detail": {"id": 7}}} ``` Rules: header names are lower-cased (`err.headers` is a tuple of pairs); CR / LF / NUL, non-latin-1 values and `content-type` / `content-length` raise `ValueError` at construction. `challenge=` (str or list of str) is `UnauthorizedError`-only; `retry_after=` (seconds, `timedelta`, or an aware `datetime` — naive raises `ValueError`) is accepted by `TooManyRequestsError`, `ServiceUnavailableError` and `RequestTimeoutError`. Instance `status_code=` must be 400–599 and `code=` non-empty. The headers are sent in every `error_format`, from handlers, guards, extractors and global middleware alike. ### Problem details (`error_format="rfc9457"`) ```python run app = LaurenFactory.create( UsersModule, error_format="rfc9457", # "default" | "rfc9457" | "rfc7807" problem_type_base="https://errors.example.com/", # optional ) r = TestClient(app).get("/users/7") assert r.header("content-type") == "application/problem+json" assert r.json() == { "type": "https://errors.example.com/user_not_found", "title": "Not Found", "status": 404, "detail": "user does not exist", "instance": "/users/7", "code": "user_not_found", "context": {"id": 7}, } ``` * `type`: the class's `problem_type`, else `problem_type_base + code`, else `"about:blank"`. `title`: the class's `problem_title`, else the status phrase. * `detail`: the message (the title when empty). `instance`: the request path. `code`: Lauren's stable code. `context`: the error's `detail` dict (omitted when empty). * `errors`: an **array**, for validation failures only — each entry has `detail`, plus `pointer` (RFC 6901 JSON Pointer into the body, e.g. `"#/address/city"`) or `in` + `parameter`, and `code` when known. Built from an `issues` list of `{"loc", "type", "msg"}` in the error's `detail`, or one entry per message of an `ExtractorError` that names a `field`. * Class-level `problem_type` / `problem_title` attributes on any `HTTPError` subclass set `type` / `title`. * `error_format="rfc7807"` is the **legacy** shape, byte-identical to earlier releases (`urn:ietf:rfc:7231:` default `type`, no `instance`, detail dict in an object-valued `errors`). Unknown values fall back to `"default"` with a warning (a `StartupError` in 2.0). ### Error catalog as data `lauren.reflect.error_catalog(*, include_user=True) -> tuple[ReflectedError, ...]` walks `LaurenError` and its subclasses at call time (depth-first); `include_user=False` keeps lauren's own public classes. `ReflectedError` (frozen) fields: `cls`, `code`, `status_code` (`None` outside `HTTPError`), `category` (`"startup" | "http" | "lifecycle" | "websocket" | "other"`), `problem_type`, `problem_title`, `summary` (first docstring paragraph). Startup / tooling only — never on the request path. ```python run from lauren import error_catalog entry = next(e for e in error_catalog() if e.cls is UserNotFound) assert (entry.code, entry.status_code, entry.category) == ("user_not_found", 404, "http") assert all(e.cls.__module__.startswith("lauren") for e in error_catalog(include_user=False)) ``` **Deprecation notice:** `detail.type` on `internal_error` 500s and `detail.guard` on guard denials expose class names; they stay in 1.x and will be emitted only with debug pages enabled in 2.0. Match on `code`, not on them. ### Errors: Decorator & Interceptor `DecoratorUsageError` is raised when a configurable decorator such as `@controller`, `@injectable`, or `@middleware` is used **bare** (without parentheses): ```python # Wrong — raises DecoratorUsageError as soon as the class is defined: @controller # missing () class Broken: ... # Correct: @controller("/api") class Fine: ... ``` `InterceptorConfigError` is raised when an interceptor class is misconfigured at startup (e.g. no `intercept` method, wrong signature, or applied without parentheses). --- ## Debug Error Pages `LaurenFactory.create(..., debug=True)` (or `debug=DebugConfig(...)`; also `Lauren(debug=...)`) replaces the `500 internal_error` of an **unhandled** exception with a read-only report for allowed clients: exception type, message and `__cause__` / `__context__` chain; traceback frames with source context; the matched route (template, handler, controller, owning module), the effective middleware → guard → interceptor chain, exception handlers and parameter sources; a redacted request summary; Lauren / Python versions and `error_format`. Clients whose `Accept` prefers `text/html` get a self-contained HTML page (inline CSS, no JS, all strings escaped); others get the normal envelope plus a top-level `debug` member. Both send `Cache-Control: no-store`. ```python run from lauren import DebugConfig, LaurenFactory, controller, get, module from lauren.testing import TestClient @controller("/orders") class OrderController: @get("/{order_id}") async def show(self, order_id: int) -> dict: return {"total": {}[order_id]} # KeyError — a bug @module(controllers=[OrderController]) class OrdersModule: ... client = TestClient(LaurenFactory.create(OrdersModule, debug=True)) # TestClient is 127.0.0.1 report = client.get("/orders/7", headers={"accept": "application/json"}).json() assert report["error"]["code"] == "internal_error" assert report["debug"]["exception"]["type"] == "KeyError" assert report["debug"]["route"]["path_template"] == "/orders/{order_id}" assert "KeyError" in client.get("/orders/7", headers={"accept": "text/html"}).text assert DebugConfig().allow_remote is False ``` `DebugConfig(include_4xx=False, allow_remote=False, redact_headers=("authorization", "cookie", "set-cookie", "proxy-authorization", "x-api-key"), redact_query_keys=("token", "password", "secret", "api_key"), source_context=5, max_frames=40, include_locals=False)`: | Field | Effect | |---|---| | `include_4xx` | Rendered `HTTPError` responses (any status) also get `debug.raised_at` + `debug.traceback` | | `allow_remote` | Serve every client; by default only loopback (`127.0.0.0/8`, `::1`) — **not** a boundary behind a same-host proxy | | `redact_headers` / `redact_query_keys` | Values replaced by `[redacted]` when the name *contains* an entry (case-insensitive); query keys also redact frame locals | | `source_context` / `max_frames` | Source lines around each frame / innermost frames kept | | `include_locals` | Show frame locals (`repr`, truncated); off because locals hold secrets | Safety: never inferred from the environment; `create()` logs a WARN ("Debug error pages enabled — never use in production"); with the typed configuration module, a production `Profile` makes `debug=` a `StartupError(detail={"reason": "debug_in_production", ...})`; invalid values raise `StartupError(detail={"reason": "invalid_debug_config", ...})`; a failing renderer falls back to the plain 500 and logs `origin="debug_renderer"`. When debug is off the renderer is never imported. --- ## Testing ```python run from lauren.testing import TestClient app = LaurenFactory.create(AppModule) with TestClient(app) as c: # startup() on enter, shutdown() on exit r = c.get("/users/42", headers={"Authorization": "Bearer ..."}) assert r.status_code == 200 assert r.json()["id"] == 42 assert r.headers.get("content-type") == "application/json" ``` `lauren.testing` (all names import from there): `TestClient`, `AsyncTestClient`, `TestResponse`, `StreamingTestResponse`, `ResponseHeaders`, `HTTPStatusAssertionError`, `CookieJar`, `StoredCookie`, `Test`, `TestingModuleBuilder`, `lifespan`, `WsTestClient`, `WebSocketTestSession`, `WebSocketSession`; opt-in fixtures in `lauren.testing.pytest_plugin`. ### `TestClient(app, *, base_url="http://testserver", headers=None, cookies=None, follow_redirects=False, timeout=30.0, raise_server_exceptions=False, lifespan="auto", backend="asyncio", backend_options=None, root_path="", strict_cookies=False, persist_cookies=None)` - **One persistent event loop per client**: startup, requests, background tasks, WebSockets and shutdown share it, so `asyncio.Event`/`Queue`/`Lock`, pools and tasks created in `@post_construct` keep working. A call from sync test code runs that loop in the test's thread (thread-local state and context variables are visible, as in 1.x); a daemon thread runs it between calls. Calling the sync client inside a running loop works but warns once — use `AsyncTestClient`. - **Lifespan**: `with TestClient(app)` runs `startup()` on enter and the full shutdown (drain → `on_shutdown` → `@pre_destruct` → generator teardown) on exit; failures raise the original exception. A bare `TestClient(app)` keeps 1.x behaviour: a `LaurenApp` starts at construction, a `Lauren` app on its first request; `close()` shuts down; garbage collection stops the loop without shutdown hooks. `lifespan="off"` never starts/stops; `"on"` also drives a foreign ASGI app's lifespan outside `with` and fails if unsupported. - **One driver per app**: a second `TestClient(app)` attaches to the live client's loop; entering it with `with` raises — use `client.new_session()` (same app and loop, empty jar). Restarting a shut-down app raises. - **Requests**: `request(method, url, *, params, headers, cookies, json, data, files, content, follow_redirects, timeout, disconnect_after)` and `get/post/put/patch/delete/head/options(url, **kw)`; `await arequest(...)`. `data=` → urlencoded form, `files=` → multipart (`{"f": ("a.png", b"...", "image/png")}`, file objects, lists), `content=` bytes/str or an (async) iterable streamed per chunk. A `host` header comes from `base_url`; an explicit `content-type` wins. - **Timeouts**: `timeout` (30 s; `None` for a bare client) bounds each request; on expiry the app task is cancelled and `TimeoutError` is raised. - **Cookies**: `client.cookies` is a browser-like `CookieJar` (domain/path matching, `Max-Age`/`Expires`, `Max-Age=0` deletes, `__Host-`/`__Secure-` rules; `Secure` cookies work on `testserver`/`localhost`/`127.0.0.1`/`::1` unless `strict_cookies=True`). `Set-Cookie` is stored for `with`-managed and `new_session()` clients (`persist_cookies=None`); a bare client stores none unless `persist_cookies=True`. Per-request `cookies=` merge without persisting; an explicit `Cookie` header bypasses the jar. `client.set_session({...})` issues a valid signed session cookie. - **Streaming**: `with client.stream("GET", "/events") as r:` → `StreamingTestResponse` with `iter_bytes()/iter_text()/iter_lines()/iter_sse()` (`ServerSentEvent` items); leaving the block (or `r.close()`) delivers `http.disconnect`; `timeout` bounds each chunk. - **Disconnects**: `receive()` blocks after the body until the response is complete, then reports `http.disconnect`. `disconnect_after=0.2` simulates a client abort: a blocked `receive()` gets `http.disconnect`, a streamed body is truncated, later sends are dropped, and the call returns after the app's coroutine finished with `r.aborted is True` (`status_code` is `None` if the response never started). - **Diagnostics**: `raise_server_exceptions=True` re-raises errors that became a 500 (not `HTTPError`s) via a `lauren.testing` scope extension (`request.extensions`); `with client.capture(RequestComplete) as events:` collects the app's lifecycle events; `client.resolve(Token)` resolves DI on the client's loop. - **WebSockets**: `with client.websocket_connect("/chat", timeout=5.0) as ws:` — sync `send_text/send_bytes/send_json/send_event(event, data)` and `receive/receive_text/receive_bytes/receive_json/receive_event()`, carrying the jar's cookies. `WsTestClient(app).connect(path, server_timeout=2.0)` is the async variant. - Passes the URL path to the app as written: it does **not** percent-decode it the way ASGI servers do, so `/files/a%2Fb` reaches a handler as `a%2Fb` in tests but `a/b` under uvicorn. `TestResponse`: `status_code`, `headers` (`ResponseHeaders` — still a `list[tuple[str, str]]`, plus case-insensitive `get`, `getall`, `["name"]`, `keys()`, `in`), `body`/`content`, `text`, `json()`, `header(name)`, `headers_all(name)`, `cookies`, `is_success`/`is_redirect`/`is_client_error`/ `is_server_error`, `raise_for_status()` (raises `HTTPStatusAssertionError(AssertionError)`), `aborted`, `url`, `request`, `elapsed`, `history`. `AsyncTestClient` has the same keywords and methods, awaited, on the caller's loop (`async with AsyncTestClient(app) as client`, `await client.get(...)`, `async with client.stream(...)` with `aiter_*`). An app driven by a client on another loop raises `RuntimeError`. `async with lauren.testing.lifespan(app):` runs startup/shutdown around a third-party client such as `httpx.AsyncClient(transport=ASGITransport(app))`. ### Provider overrides — `Test.create_testing_module(root, *, overrides=(), **factory_kwargs)` ```python run from lauren import controller, get, injectable, module from lauren.testing import Test, TestClient @injectable() class Clock: def now(self) -> str: return "real time" @controller("/time") class TimeController: def __init__(self, clock: Clock) -> None: self.clock = clock @get("/") async def now(self) -> dict: return {"now": self.clock.now()} @module(providers=[Clock], exports=[Clock]) class ClockModule: pass @module(imports=[ClockModule], controllers=[TimeController]) class TimeModule: pass class FrozenClock: def now(self) -> str: return "2026-01-01T00:00:00Z" app = Test.create_testing_module(TimeModule).override_provider(Clock).use_class(FrozenClock).compile() with TestClient(app) as client: assert client.get("/time/").json() == {"now": "2026-01-01T00:00:00Z"} ``` `TestingModuleBuilder` steps: `override_provider(token)` → `use_value(v)` / `use_class(cls, scope=None)` / `use_factory(fn, injects=[...], scope=None)` / `use_existing(token)` / `use_values([...])` (whole multi-binding set), with `.optional()` to tolerate a missing target; `override_guard(G)` → `allow()` / `deny()` / `use_value` / `use_class`; `override_interceptor(I)` and `override_middleware(M)` → `passthrough()` / `use_value` / `use_class`; `override_exception_handler(H)` → `use_value(obj_or_fn)` / `use_class` (class form); `override_module(Old).with_module(New)`. `compile()` runs the full `LaurenFactory.create` (same kwargs) with the overrides applied: the replacement keeps the target's token, owning module, `provides` and `multi`, so visibility and Protocol / `list[T]` bindings are unchanged; the replaced provider is never instantiated; `use_class`/`use_factory` inherit the target's scope. Errors: `MissingProviderError` with `detail["reason"] == "override_target_missing"` (target nothing registers), `StartupError` (same reason, module not imported), `DIScopeViolationError` (scope narrowing), `DecoratorUsageError` (same token overridden twice). Declarative form: `overrides=[use_value(provide=Repo, value=fake)]`. No user class is mutated and nothing is consulted per request. ### pytest plugin (opt-in) `pytest_plugins = ["lauren.testing.pytest_plugin"]` in `conftest.py`. Ini: `lauren_app = "pkg.main:AppModule"` (a `@module` class, a `TestingModuleBuilder`, or — with `lauren_client_scope = session` — an app object) and `lauren_client_scope` (default `function`). Fixtures: `lauren_app` (compiles a `lauren_testing_module` fixture when you define one), `lauren_client` (lifespan-managed), `lauren_aclient` (pytest-asyncio), `lauren_ws`, `lauren_capture`. ### Test tiers | Directory | Purpose | |---|---| | `tests/unit/` | Import a module directly; no ASGI app, no event loop unless required | | `tests/integration/` | Build a `@module`, create an app via `LaurenFactory`, drive via `TestClient` | | `tests/e2e/` | Full-stack multi-backend tests exercising all validator paths in one app | | `tests/property/` | Hypothesis property tests for validation invariants; `pytest.importorskip("hypothesis")` skips when absent | ### Blocking optional deps in tests When a test module needs to verify behaviour with pydantic absent, it must block pydantic in a **module-scoped** fixture and ensure Lauren's core modules are pre-imported first: ```python # tests/conftest.py — session-scoped, autouse=True @pytest.fixture(scope="session", autouse=True) def _preload_lauren(): import lauren # noqa: F401 import lauren.streaming # noqa: F401 import lauren.extractors # noqa: F401 # tests/integration/test_no_pydantic.py @pytest.fixture(autouse=True, scope="module") def disable_pydantic(): original = {k: v for k, v in sys.modules.items() if "pydantic" in k} for k in list(original.keys()): del sys.modules[k] sys.modules["pydantic"] = None # type: ignore[assignment] sys.modules["pydantic_core"] = None # type: ignore[assignment] yield for k in list(sys.modules.keys()): if "pydantic" in k: del sys.modules[k] sys.modules.update(original) ``` The `_preload_lauren` session fixture is critical: if any Lauren module is first imported while pydantic is blocked, the module-level `_PYDANTIC_AVAILABLE` flag is set to `False` permanently for that session — even after pydantic is restored. Pre-importing with all deps available prevents this contamination. --- ## Strict Decorator Inheritance **Rule:** subclassing does not inherit `@controller`, `@module`, `@injectable`, or `@middleware` status. Every class must be decorated explicitly. ```python run @injectable() class Base: ... # Starting the app with this class registered raises MetadataInheritanceError: class Child(Base): pass # Correct: @injectable() class Child(Base): pass ``` Same rule for `@controller` and `@module`. This avoids surprising behavior when the user subclasses for purely implementation reasons. Method-level decorators (`@get`, `@post`, etc.) DO propagate through inheritance — that's just Python method resolution, nothing lauren-specific. `@use_guards` and `@use_middlewares` attach to the exact target (class or method) only. A subclass that wants parent guards must re-declare them. --- ## OpenAPI 3.1 Generation `app.openapi()` returns a schema dict. Route metadata is derived from: - Controller `@controller(prefix=..., tags=..., summary=..., description=..., deprecated=..., security=...)` - Handler `@get(..., summary=..., description=..., response_model=..., responses=..., deprecated=..., operation_id=..., include_in_schema=..., tags=...)` The response schema comes from `response_model=` or, when omitted, from the handler's return annotation (`-> UserOut`, `-> list[UserOut]`, `-> tuple[UserOut, int]`). The status is the declared one — `status_code=`, each member of a typed-result union (`Ok[A] | Created[A]` → `"200"` and `"201"`, with a `Location` header for `Created` / `Accepted`), `Annotated[T, Status(206)]` — and `-> None` / `-> NoContent` are documented as `"204"` without content. `@raises(...)` adds one response per error status; `response_headers=` documents headers on the success response. Pydantic models, msgspec structs, dataclasses, `TypedDict`s and `Discriminated[...]` unions are all supported; a type that cannot be described degrades to a generic object schema. ```python run class UserOut(BaseModel): id: int name: str @controller("/users") class UserReadController: @get("/{id}", response_model=UserOut, operation_id="getUser") async def show(self, id: Path[int]) -> UserOut: ... @module(controllers=[UserReadController]) class OpenApiModule: ... spec = LaurenFactory.create(OpenApiModule).openapi() assert spec["paths"]["/users/{id}"]["get"]["operationId"] == "getUser" ``` Schemas are emitted under `components.schemas` and referenced from the path operation's `responses..content["application/json"].schema`. ### OpenAPI Security `openapi_security` attaches OpenAPI 3.1 security requirement objects to a guard class so the generated schema documents authentication schemes: ```python run from lauren import openapi_security @openapi_security({"BearerAuth": []}) # one dict per requirement (OR-ed) class JwtGuard: async def can_activate(self, ctx) -> bool: ... ``` `OpenAPISecurityMeta` is the metadata dataclass stored on the guard by this decorator. --- ## Logging lauren ships with a NestJS-style logger. Pass any object implementing the ``lauren.logging.Logger`` protocol to ``LaurenFactory.create(..., logger=...)``. Four implementations are built in: * ``ConsoleLogger`` — coloured, human-readable output (default for TTYs). * ``JsonLogger`` — one JSON object per line for production aggregators. * ``NullLogger`` — silent; also the factory default if no logger is provided. * ``InMemoryLogger`` — test helper that captures records in a list. ```python run from lauren import LaurenFactory from lauren.logging import default_logger, ConsoleLogger, JsonLogger, LogLevel # Auto-choose based on TTY detection + LAUREN_LOG_FORMAT / LAUREN_LOG_LEVEL app = LaurenFactory.create(AppModule, logger=default_logger()) # Or pick explicitly: app = LaurenFactory.create( AppModule, logger=ConsoleLogger(level="DEBUG", name="MyApp"), ) app = LaurenFactory.create( AppModule, logger=JsonLogger(level=LogLevel.INFO), ) ``` Events emitted by the framework: | Phase | Context | Level | |---|---|---| | Factory entry | ``LaurenFactory`` | INFO | | Phase 1 module graph | ``ModuleGraph`` | VERBOSE | | Phase 2-4 DI | ``DIContainer`` | VERBOSE | | Each route registration | ``RouterExplorer`` | INFO | | Phase 6 lifecycle | ``Lifecycle`` | VERBOSE / INFO | | App ready | ``LaurenApp`` | INFO | | Each request (DEBUG level) | ``Request`` | DEBUG / WARN / ERROR | | Shutdown steps | ``Shutdown`` | INFO / WARN | Log levels (ascending): ``DEBUG``, ``VERBOSE``, ``INFO``, ``WARN``, ``ERROR``. Per-request traces fire at ``DEBUG`` for 2xx/3xx, ``WARN`` for 4xx, ``ERROR`` for 5xx, so production runs at ``INFO`` stay quiet unless something is wrong. Custom loggers: implement the minimal protocol:: class MyLogger: level: LogLevel = LogLevel.INFO def log_record(self, record: LogRecord) -> None: ... or subclass ``lauren.logging._BaseLogger`` to inherit the ``log/debug/warn/ error/verbose`` sugar methods. --- ## Graceful Shutdown & Signals ``LaurenApp.shutdown()`` is the canonical teardown path. It: 1. Stops admitting new work and moves ``READY → DRAINING`` (a no-op when ``app.begin_draining()`` already did); the scheduler stops firing (hosted services keep running). 2. Drains in-flight requests (up to ``drain_timeout`` seconds). 3. Awaits in-flight scheduled job runs (``job_drain_timeout``, default what is left of ``drain_timeout``), then cancels them. 4. Stops hosted services in reverse start order (each gets its ``stop_timeout``, then is cancelled). 5. Awaits running ``@on_event`` handler tasks with what is left of the budget and cancels the rest (see [Application Events](#application-events)). 6. Runs any ``on_shutdown`` callbacks the user registered (LIFO). 7. Runs ``@pre_destruct`` hooks in reverse topological order. All steps are logged through the installed logger, and the app ends ``STOPPED`` (a ``FAILED`` app stays ``FAILED``). Calls are idempotent: concurrent or repeated ``shutdown()`` invocations return once the first shutdown has finished. ```python run app = LaurenFactory.create(AppModule, logger=default_logger()) # Register arbitrary cleanup (decorator form supported): @app.on_shutdown async def flush_metrics() -> None: await metrics_client.flush() # Signal integration (POSIX): from lauren.signals import install_signal_handlers, wait_for_shutdown async def main(): # SIGTERM / SIGINT: app.begin_draining(), keep serving 10 s, then app.shutdown(). event = install_signal_handlers(app, drain_timeout=30, shutdown_delay=10) # ... run the app (e.g. via uvicorn.run) ... await wait_for_shutdown(event) ``` `install_signal_handlers(app, *, signals=(SIGINT, SIGTERM), drain_timeout=10.0, shutdown_delay=0.0, loop=None) -> asyncio.Event` — on the first handled signal it calls `app.begin_draining(reason="signal SIGTERM")`, waits `shutdown_delay` seconds (readiness answers 503 while connections are still accepted), then schedules `app.shutdown(drain_timeout=...)`. Later signals are logged and ignored; a negative or non-numeric `shutdown_delay` raises `ValueError`. When running under a well-behaved ASGI server (uvicorn, hypercorn), the server already delivers SIGTERM to the lifespan protocol, so you typically won't need ``install_signal_handlers`` — it's for standalone scripts and customised runners. Note that uvicorn stops listening and drains connections *before* it sends ``lifespan.shutdown``: a readiness flip inside ``shutdown()`` is invisible to load balancers, so announce termination earlier with ``app.begin_draining()`` (a ``preStop`` window, a signal handler, a custom runner). ### Lifecycle state `app.lifecycle_state` is an `AppLifecycleState` (exported from `lauren` and `lauren.signals`); every transition emits `LifecycleStateChanged(app, previous, current, reason)` on the app's `SignalBus`. | Transition | Where | |---|---| | `CREATED → STARTING` | top of `startup()`, before `StartupBegin` | | `STARTING → READY` | after every `@post_construct` hook, before `StartupComplete` | | `STARTING → FAILED` | `startup()` raised (the lifespan answers `lifespan.startup.failed`) | | `FAILED → STARTING` | `startup()` called again to retry (e.g. an async configuration source recovered) | | `READY → DRAINING` | `app.begin_draining(reason=...)`, or the first step of `shutdown()` | | `DRAINING → STOPPED` | end of `shutdown()` (`CREATED → STOPPED` for an app never started) | `STOPPED` is terminal; `FAILED` lasts until `startup()` is retried (a `FAILED` app that shuts down stays `FAILED`, and `startup()` then raises). During `DRAINING` requests are served normally; only readiness fails. ```python run from lauren import AppLifecycleState, LifecycleStateChanged, SignalBus, module @module() class LsAppModule: ... ls_signals = SignalBus() ls_seen: list[str] = [] ls_signals.on(LifecycleStateChanged)(lambda event: ls_seen.append(event.current.value)) ls_app = LaurenFactory.create(LsAppModule, signals=ls_signals) assert ls_app.lifecycle_state is AppLifecycleState.CREATED await ls_app.startup() assert ls_app.begin_draining(reason="preStop") is True # readiness fails; requests still served assert ls_app.begin_draining() is False # idempotent await ls_app.shutdown() assert ls_seen == ["starting", "ready", "draining", "stopped"] ``` ### Signals & Lifecycle Events `SignalBus` is an in-process pub/sub bus for `LifecycleEvent` subclasses. Subscribe with the `@bus.on(EventType)` decorator or `bus.subscribe()`. The default app bus is accessible via `lauren.signals.bus`. ```python run from lauren import SignalBus from lauren.signals import ( StartupBegin, StartupComplete, ShutdownBegin, RequestReceived, RequestComplete, LifecycleEvent, ) bus = SignalBus() @bus.on(StartupComplete) async def on_ready(event: StartupComplete) -> None: print("App ready") ``` Full event hierarchy: | Event | When | |---|---| | `StartupBegin` | `lifespan.startup` arrives; `@post_construct` not yet run | | `StartupComplete` | All `@post_construct` hooks finished | | `ShutdownBegin` | `lifespan.shutdown` or OS signal received | | `RequestReceived` | ASGI scope parsed into a `Request` | | `RequestComplete` | Final response produced (also on errors); emitted just before it is sent | | `BackgroundTaskStarted` | Background task begins | | `BackgroundTaskComplete` | Background task finishes successfully | | `BackgroundTaskFailed` | Background task raises | | `EventHandlerFailed` | An `@on_event` handler raised or was cancelled at shutdown (`event_type`, `handler`, `error`, `mode`, `event`) | | `HostedServiceStarted` / `HostedServiceStopped` / `HostedServiceFailed` | A hosted service starts (each restart too), ends for good, raises (see [Hosted Services & Scheduling](#hosted-services--scheduling)) | | `JobStarted` / `JobCompleted` / `JobFailed` / `JobSkipped` | A scheduled job run starts, completes, fails (timeouts, cancellation) or is skipped | | `LifecycleStateChanged` | `app.lifecycle_state` changed (`app`, `previous`, `current`, `reason`) | | `HealthChanged` | A health check's status, or the report's aggregate (`name=None`), changed (`app`, `name`, `previous`, `current`, `details`) — see [Health & Readiness](#health--readiness) | `LifecycleEvent` is the common base class for all events above. Domain events do not belong here: publish them on the application `EventBus` ([Application Events](#application-events)). --- ## Health & Readiness Liveness, readiness and startup probes plus a full report, driven by the application lifecycle state and by `@health_check` methods on singleton providers. Public names live in `lauren.health` (`HealthModule`, `health_check`, `HealthResult`, `HealthStatus`); `HealthModule`, `HealthChanged` and `PUBLIC_METADATA_KEY` are also exported from `lauren`. ```python run from lauren import LaurenFactory, Scope, injectable, module from lauren.health import HealthModule, HealthResult, HealthStatus, health_check from lauren.testing import TestClient @injectable(scope=Scope.SINGLETON) class HcDatabase: def __init__(self) -> None: self.reachable = True @health_check("database", timeout=2.0, cache_ttl=5.0) # tags=("ready",), critical=True async def ping(self) -> bool: return self.reachable # e.g. await pool.execute("SELECT 1") @injectable(scope=Scope.SINGLETON) class HcSearch: @health_check("search", critical=False, timeout=1.0) # down/degraded => report degraded def ping(self) -> HealthResult: # sync: runs in a worker thread return HealthResult.degraded(latency_ms=612.0, reason="slow") @module(imports=[HealthModule.for_root(details="always", version="1.4.2")], providers=[HcDatabase, HcSearch]) class HcAppModule: ... hc_app = LaurenFactory.create(HcAppModule) hc_client = TestClient(hc_app) # runs startup(): READY assert hc_client.get("/health/live").json() == {"status": "up"} assert hc_client.get("/health/ready").json() == {"status": "degraded"} # 200 (degraded_status) report = hc_client.get("/health").json() assert (report["status"], report["lifecycle"], report["version"]) == ("degraded", "ready", "1.4.2") assert report["checks"]["search"]["reason"] == "slow" hc_app.begin_draining(reason="preStop") assert hc_client.get("/health/ready").status_code == 503 # out of rotation... assert hc_client.get("/health/live").status_code == 200 # ...but never restarted for it assert HealthResult.down(error="x").status is HealthStatus.DOWN ``` ### `HealthModule.for_root(*, path="/health", details="never", details_guard=None, format="lauren", event_loop_lag_ms=None, disk_paths=None, max_rss_bytes=None, version=None, report_status_code=True, degraded_status=200)` Returns a generated module; import it into the root module. Routes (GET, `include_in_schema=False`, metadata `PUBLIC_METADATA_KEY=True`): | Endpoint | 200 when | 503 when | |---|---|---| | `{path}/live` | state not `FAILED`/`STOPPED` and no `"live"` check down | otherwise | | `{path}/startup` | state `READY`/`DRAINING` and no `"startup"` check down | `CREATED`, `STARTING`, `FAILED`, `STOPPED` | | `{path}/ready` | state `READY` and no **critical** `"ready"` check down | any other state or a critical check down | | `{path}` | aggregate up or degraded | aggregate down (unless `report_status_code=False`) | * Probes answer `{"status": "up" | "degraded" | "down"}` only (never details); a degraded answer uses `degraded_status`. All responses send `Cache-Control: no-store`. * Aggregate: any critical check down → `down`; else any down or degraded → `degraded`; else `up`. In the report the lifecycle state is a critical input that is up only in `READY`; checks run only in `READY` / `DRAINING` (otherwise `"checks": {}`). * Report: `{"status", "lifecycle", "checks": {name: {...}}, "version"?}`. Names and statuses always; `critical`, `latency_ms`, the result's details, `reason` (`"timeout"`), `error` (`"Type: message"` for a raising check) and `cached: true` only when details are allowed: `details="never"` (default), `"always"`, or `"when_authorized"` with `details_guard` — a guard class (`can_activate(ctx)`) resolved through DI with the request's `ExecutionContext`; a denial (falsy, or an `HTTPError` raised) hides the details and never turns into a 403. * `format="ietf"`: `application/health+json`, statuses `pass`/`warn`/`fail`, each check a one-element list, `error` → `output`. * Built-in indicators (off by default; names reserved): `event_loop_lag_ms` → `event_loop` (`live` + `ready`; p99 lag over 10 s sampled every 0.5 s while `READY`; degraded above half), `disk_paths={path: min_free_bytes}` → `disk:` (`ready`), `max_rss_bytes` → `memory` (`ready`; current RSS on Linux, peak RSS elsewhere on POSIX; `DecoratorUsageError` on Windows). * One `HealthModule` per app: different options → `StartupError` (`reason="conflicting_health_options"`); two at the same path → `RouterConflictError`. Invalid options → `DecoratorUsageError` (`detail["helper"] == "HealthModule.for_root"`); details-guard mistakes → `GuardConfigError` (`reason` `missing_details_guard` / `invalid_details_guard` / `unused_details_guard`). ### `@health_check(name, *, critical=True, timeout=2.0, cache_ttl=0.0, tags=("ready",))` * On a method of a `Scope.SINGLETON` provider (else `LifecycleConfigError`, `detail = {"check", "provider", "method", "scope", "reason"}`); discovered app-wide at startup, resolved through the provider's own module — no export needed. Must be called with a name; stacking registers several checks. Static/class methods work. Validated even without `HealthModule`. * Returns `None`/`True` (up), `False` (down) or a `HealthResult` (`HealthResult.up(**details)` / `.degraded(...)` / `.down(...)`; frozen, `status: HealthStatus`, `details: dict`); raising → down with `error`. * Parameters: only dependencies the provider's module can resolve (request-scoped ones get a fresh `app.scope("health:")` per run); anything else → `UnresolvableParameterError` at startup. * `tags` ⊆ `{"live", "ready", "startup"}` (`()` = report only); names unique per app (`DuplicateBindingError`, `detail["providers"]` lists both; also for the reserved `lifecycle`, `event_loop`, `memory`, `disk:*`). * Execution: concurrent, each run bounded by `timeout` (down, `reason="timeout"`), single-flight across concurrent probes, result reused for `cache_ttl` seconds (`cached: true`). Sync checks run in a worker thread the timeout abandons; while that thread is still busy later runs fail fast (`error="the previous run is still running"`). * Status changes emit `HealthChanged(app, name, previous, current, details)` on the app's `SignalBus` (`previous=None` on a check's first run; `name=None` for the report's aggregate). ### Draining before the socket closes Uvicorn stops listening before it sends `lifespan.shutdown`, so readiness must fail earlier: a Kubernetes `preStop` sleep (Kubernetes removes the pod from its endpoints itself), `install_signal_handlers(app, shutdown_delay=10)` (drains on SIGTERM, keeps serving, then shuts down), or a custom runner calling `app.begin_draining()` before triggering the server's graceful shutdown. **Rules:** * Liveness must not check dependencies: a database outage takes pods out of rotation (readiness) but must not restart them. Tag a check `"live"` only when its failure means this process is broken. * Never hand-roll a `{"status": "ok"}` endpoint: it answers "ok" before startup, during shutdown and while dependencies are down. Use `HealthModule.for_root()`, or at least answer from `app.lifecycle_state`. * A global auth guard should let `ctx.get_metadata(PUBLIC_METADATA_KEY, False)` routes through; Lauren never skips a guard on its own. --- ## Application Events Typed, in-process domain events: one module announces that something happened (`UserRegistered`) and other modules react with `@on_event` handlers, without the publisher importing them or widening its module's `imports`. Public names live in `lauren.events` (`EventBus`, `EventPublisher`, `on_event`, `EventContext`, `EmitResult` and `EventHandlerFailed` are also exported from `lauren`). Handlers are discovered on every provider and validated inside `LaurenFactory.create`; an emission is a lookup in a compiled per-app table. ```python run from dataclasses import dataclass from lauren import LaurenFactory, Scope, controller, injectable, module, post from lauren.events import EventBus, EventContext, EventPublisher, on_event from lauren.testing import TestClient @dataclass(frozen=True) class EvDomainEvent: # optional common base: its handlers see every subclass pass @dataclass(frozen=True) class EvUserRegistered(EvDomainEvent): user_id: str email: str @injectable(scope=Scope.REQUEST) class EvDbSession: # one per request (or per operation scope) def __init__(self) -> None: self.rows: list[str] = [] @injectable() class EvAuditTrail: # lives in its own module in a real app @on_event(priority=100) # class inferred from the annotation; mode="inline" async def record(self, event: EvDomainEvent, db: EvDbSession, ctx: EventContext) -> None: db.rows.append(f"audit {type(event).__name__} {ctx.event_id}") @injectable() class EvWelcomeMailer: def __init__(self) -> None: self.sent: list[str] = [] @on_event(EvUserRegistered, mode="background") # after the HTTP response async def send_welcome(self, event: EvUserRegistered) -> None: self.sent.append(event.email) @controller("/ev-users") class EvUserController: @post("/") async def register(self, events: EventPublisher, db: EvDbSession) -> dict: db.rows.append("insert u1") await events.emit(EvUserRegistered("u1", "ada@example.com")) # inline handlers share `db` return {"rows": len(db.rows)} @module(controllers=[EvUserController], providers=[EvDbSession, EvAuditTrail, EvWelcomeMailer]) class EvAppModule: ... ev_app = LaurenFactory.create(EvAppModule) await ev_app.startup() response = await TestClient(ev_app).arequest("POST", "/ev-users/") assert response.json() == {"rows": 2} # the audit row joined the request's session assert (await ev_app.container.resolve(EvWelcomeMailer)).sent == ["ada@example.com"] bus = await ev_app.container.resolve(EventBus) # the per-app singleton bus with bus.recording() as rec: result = await bus.emit(EvUserRegistered("u2", "bob@example.com"), wait=True) assert (result.handled, result.scheduled, result.ok) == (2, 1, True) assert rec.handler_calls("EvWelcomeMailer.send_welcome") == 1 assert bus.handlers_for(EvUserRegistered) == ("EvAuditTrail.record", "EvWelcomeMailer.send_welcome") await ev_app.shutdown() ``` ### `@on_event(event_type=None, *, mode="inline", priority=0, errors=None, enabled=True)` * Decorates a method of any provider (`@injectable`, controller, any scope); must be called with parentheses. Stacking subscribes one method to several classes. Returns the method unchanged (metadata in `__lauren_on_event__`). * `event_type=None` infers the class from the annotation of the first parameter after `self` (a union subscribes to each member, `None` ignored). With an explicit class, that annotation must be the class or a base of it (or absent / `object` / `Any`). * A handler on a base class receives every subclass. A method subscribed along an event's MRO runs once, under its most specific subscription. * Other parameters: `EventContext` (any name) and dependencies resolvable from the provider's own module. `Request` is never injectable into a handler. * `priority`: higher runs first; ties keep discovery order (provider registration order, then method order). * `errors`: `"propagate"` (default for inline) re-raises from `emit()`; `"isolate"` (default and only option for deferred modes) logs the error, signals `EventHandlerFailed` and lists it in `EmitResult.errors`. * `enabled=False`: validated at startup, never called. * Sync (`def`) handlers run in a worker thread; method-level MRO propagation applies (an undecorated override removes the handler). | `mode` | When it runs | Request-scoped deps through `EventPublisher` | through `EventBus` | |---|---|---|---| | `"inline"` | inside `emit()`, sequentially | the request's (or connection's) DI scope | one `OperationScope` per `emit()`, shared by its inline handlers | | `"concurrent"` | tracked task, started after every inline handler succeeded | a fresh `OperationScope` per call | same | | `"background"` | in an HTTP request: after the response, on its `BackgroundTasks` queue; elsewhere (WebSocket, `EventBus`): like `"concurrent"` | a fresh `OperationScope` per call | same | ### Publishing | Injectable | Scope | Use in | |---|---|---| | `EventBus` | singleton, one per app (always registered) | services, jobs, commands, `@post_construct` | | `EventPublisher` | request (framework factory) | route handlers, WebSocket hooks, request-scoped providers, `@on_event` handlers | `await events.emit(event, *, wait=False) -> EmitResult`: 1. Handlers for `type(event)` and its bases, ordered by `(-priority, discovery index)`. 2. Inline handlers run one by one. A `"propagate"` failure stops the rest, schedules **no** deferred handler and re-raises. 3. Then deferred handlers are scheduled. `wait=True` also awaits the tasks this call started (not request-queued background handlers). 4. `EmitResult(event, handled, scheduled, errors)`; `.ok` is `not errors`. Also on `EventPublisher`: * `emit(event, after=TransactionPhase.COMMIT)` defers the whole emission until the request's `UnitOfWork` commits (dropped on rollback), then delivers it like `EventBus.emit`. `UnitOfWork` is a runtime-checkable protocol (`on_commit(cb)`, `on_rollback(cb)`, callbacks are async) that a database integration provides; without one the call raises `LifecycleViolationError` (`reason="no_unit_of_work"`). * `emit_threadsafe(event) -> concurrent.futures.Future[EmitResult]` for sync handlers running in a worker thread; call `.result()` before returning. * `in_request` — `True` inside an HTTP request or WebSocket connection. Resolving `EventPublisher` outside a request, a connection or an `@on_event` handler (a job, `app.scope()`) raises `LifecycleViolationError` (`reason="no_request_scope"`); a singleton depending on it fails startup with `DIScopeViolationError`. ### `EventContext` Frozen; fields: `event_id` (hex, unique per `emit()`), `emitted_at` (UTC `datetime`), `depth` (0 top-level, +1 per re-emission), `mode`, `in_request`, `log` (app logger with `context="Event:"`). It never exposes the `Request`: requests are pooled and deferred handlers outlive them. ### Startup validation and lifecycle * Wiring errors fail `LaurenFactory.create` with `UnresolvableParameterError`, `detail = {"handler": "Class.method", "param": ..., "reason": ...}`, reasons `"event_type_mismatch"` (annotation narrower than the subscribed class), `"missing_event_parameter"` (no event parameter, or nothing to infer from), `"invalid_event_type"` (annotation is not a class) and `"unresolvable_dependency"`. Bad decorator arguments (including `errors="propagate"` with a deferred mode) raise `DecoratorUsageError`. * During `startup()` inline handlers only reach singletons whose `@post_construct` already ran (otherwise `LifecycleViolationError`, `reason="app_not_ready"`); deferred handlers wait until the app is ready. * `shutdown()` awaits running handler tasks after the request drain, with what is left of `drain_timeout`, then cancels the rest (each reported as `EventHandlerFailed` with a `CancelledError`). After shutdown `emit()` raises `LifecycleViolationError` (`reason="app_stopped"`). * Handlers re-emitting deeper than `max_depth` (default 16) raise `RecursionError` naming the chain of event classes. * `EventsModule.for_root(max_depth=16, drain_timeout=None)` is optional tuning; `drain_timeout=None` means "the rest of the shutdown budget". ### Testing * `with bus.recording() as rec:` captures `rec.events`, `rec.calls` (`"Class.method"` per finished call), `rec.failures`; `rec.of(Type)`, `rec.handler_calls(Class.method | "Class.method")`. * `await bus.drain(timeout=None)` waits for running deferred handlers (`TimeoutError` past the timeout). * `with bus.disabled(Mailer.send_welcome):` mutes handlers. * `bus.handlers_for(Type)` lists handler names in dispatch order. * A bare `EventBus()` has no handlers: a recording stand-in for unit tests. * `TestClient` keeps one event loop for the client's lifetime, so `"concurrent"` handlers a request starts keep running after the response: call `await bus.drain()` (or emit with `wait=True`) before asserting on their side effects; async tests can use `AsyncTestClient` on their own loop. ### `SignalBus` vs `EventBus` | | `SignalBus` | `EventBus` | |---|---|---| | Purpose | framework lifecycle telemetry | application domain events | | Event types | `LifecycleEvent` subclasses | any class | | Listeners | plain callables, imperative or a process-global default bus | DI-discovered methods, startup-validated | | Errors | always logged and swallowed | `propagate` (inline default) or `isolate` | | Modes | sequential inline | inline / concurrent / background | | Scope | default bus copied into every app | per app only | **Rules:** * Inline handlers are part of the publisher's unit of work; deferred handlers run only if every inline handler succeeded. * Never put the `Request` (or a request-scoped instance) inside an event; copy the values a handler needs. * Use `SignalBus` for telemetry about the framework, `EventBus` for domain events; never subclass `LifecycleEvent` for domain events. * Events are in-process and non-durable; deferred work is lost on a crash. --- ## WebSockets First-class WebSocket controllers with typed message dispatch. `@use_guards` and `@use_interceptors` work natively on gateways, at two levels: | Declared on | Guards run | Interceptors wrap | |---|---|---| | `global_ws_guards` / `global_ws_interceptors`, the `@ws_controller` class | once, before the connection is accepted (denial → close 1008) | `@on_connect` | | the `@on_connect` method | once, after global + class guards | `@on_connect`, inside the class-level ones | | an `@on_message` method | **for every frame** routed to it, after payload validation | only that handler | Connection-level guards receive a `WsConnectionContext` that duck-types with `ExecutionContext`, so the same guard class serves both HTTP and WebSocket. Message-level guards receive a `WsMessageContext` (a `WsConnectionContext` subclass adding `event` and the validated `payload`, with `handler_func` set and class + method `@set_metadata` merged, method wins). A message guard returning `False` answers with the error frame `{"error": {"code": "forbidden", "message": "guard X denied the message", "detail": {"guard": "X", "event": "..."}}}`, the handler does not run, and the connection stays open; a guard raising an `HTTPError` answers with that error's envelope instead. WS interceptors' `call_handler.handle()` returns the handler's raw return value (no `Response`). Guard/interceptor classes are registered with DI automatically. Rejected at startup (never silently ignored): `@use_middlewares` on any WS hook method (`MiddlewareConfigError`); `@use_guards` / `@use_interceptors` on `@on_disconnect` / `@on_error` (`GuardConfigError` / `InterceptorConfigError`); `@use_exception_handlers` / `@use_encoder` on any WS hook method (`ExceptionHandlerConfigError` / `DecoratorUsageError`); `@use_guards` / `@use_interceptors` / `@use_middlewares` on a Socket.IO `@on_socketio_event` handler (raised by `@socketio_controller`). Class-level `@use_middlewares` on a gateway is introspection-only, never runs, and logs a startup warning. ```python class ModeratorOnly: async def can_activate(self, ctx: WsMessageContext) -> bool: return ctx.connection.state.get("role") == "moderator" @ws_controller("/room/{room_id}") class RoomGateway: @on_message("room.kick") @use_guards(ModeratorOnly) # checked for every room.kick frame async def kick(self, ws: WebSocket, body: Json[Kick]) -> None: ... ``` See the **Reflect** section below for `WsConnectionContext`, `WsMessageContext`, `WsUpgradeRequest`, and the `apply_guards` / `apply_interceptors` helpers. ### Gateway anatomy ```python run from pydantic import BaseModel from lauren import ( BroadcastGroup, Json, WebSocket, injectable, module, ws_controller, on_connect, on_disconnect, on_error, on_message, ) class ChatMessage(BaseModel): text: str @injectable(provides=[BroadcastGroup]) # one shared room registry class Rooms(BroadcastGroup): ... @ws_controller("/chat/{room_id}") class ChatGateway: def __init__(self, rooms: BroadcastGroup) -> None: self._rooms = rooms @on_connect async def joined(self, ws: WebSocket) -> None: await ws.accept() # accept handshake room_id = ws.path_params["room_id"] await self._rooms.subscribe(room_id, ws) @on_message("chat.send") async def send(self, ws: WebSocket, body: Json[ChatMessage]) -> None: room_id = ws.path_params["room_id"] await self._rooms.broadcast( room_id, {"event": "chat.recv", "text": body.text}, exclude=ws, ) @on_disconnect async def left(self, ws: WebSocket) -> None: # Nothing unsubscribes ``ws`` for you — do it here (``broadcast`` # also prunes sockets whose send fails). await self._rooms.unsubscribe_all(ws) @module(controllers=[ChatGateway], providers=[Rooms]) class ChatModule: ... ``` `BroadcastGroup` is **not** registered by default and is not `@injectable` itself: subclass it with `@injectable(provides=[BroadcastGroup])` (as above) or bind an instance with `use_value(provide=BroadcastGroup, value=BroadcastGroup())`, and list that in `providers=` (or `global_providers=`). Injecting it otherwise fails at startup with `MissingProviderError`. Register the gateway in a module's ``controllers=[…]`` list — same contract as HTTP controllers. ``@ws_controller`` auto-marks the class as ``@injectable(scope=Scope.REQUEST)`` so each connection gets its own instance. ### `WebSocket` API | Method | Purpose | |------------------------------|------------------------------------------| | `await ws.accept(*, subprotocol=None, headers=None)` | Complete the handshake. Auto-called when `@on_connect` returns normally. | | `await ws.receive_text()` | Pull next text frame. | | `await ws.receive_bytes()` | Pull next binary frame. | | `await ws.receive_json()` | Pull next text frame and decode JSON. | | `await ws.send_text(s)` | Send a text frame. | | `await ws.send_bytes(b)` | Send a binary frame. | | `await ws.send_json(obj)` | JSON-encode and send. Handles Pydantic models, dataclasses, and standard JSON types. | | `await ws.close(code=1000, reason="")` | Initiate server-side close. Idempotent. | Properties: `path`, `path_template`, `path_params`, `headers`, `query_string`, `state`, `app_state`, `client_subprotocols`, `subprotocol`, `connected`, `connection_state`, `close_code`, `close_reason`. ### Typed dispatch and discriminated unions Use `Discriminated[A | B, "key"]` for pydantic-free discriminated unions: ```python run from dataclasses import dataclass from typing import Literal from lauren import Discriminated, Json, WebSocket, on_message, ws_controller @dataclass class ImageEvent: kind: Literal["image"] = "image" url: str = "" @dataclass class TextEvent: kind: Literal["text"] = "text" content: str = "" Event = Discriminated[ImageEvent | TextEvent, "kind"] @ws_controller("/feed") class FeedGateway: @on_message("post") async def post(self, ws: WebSocket, body: Json[Event]) -> None: if isinstance(body, ImageEvent): await ws.send_json({"type": "image", "url": body.url}) else: await ws.send_json({"type": "text", "content": body.content}) ``` Works with `@dataclass`, `TypedDict`, `msgspec.Struct`, and `pydantic.BaseModel`. For pydantic, `Annotated[Union[A, B], Field(discriminator="kind")]` is also accepted. The wildcard event ``"*"`` matches any event without a specific handler; ``"__binary__"`` captures binary frames as ``bytes`` rather than JSON-decoded text. ### `BroadcastGroup` Room-scoped fan-out provider, resolvable via DI like any other injectable. | Method | Purpose | |--------------------------------------------|-------------------------------------------------| | `await group.subscribe(name, ws)` | Add ``ws`` to ``name``. Idempotent. | | `await group.unsubscribe(name, ws)` | Remove. Safe to call if not a member. | | `await group.unsubscribe_all(ws)` | Remove from every group. Call it from `@on_disconnect` — the runtime does not. | | `await group.broadcast(name, msg, *, as_bytes=False, exclude=None)` | Send to every subscriber; returns number sent. Detects and prunes dead sockets. | | `group.groups()` / `group.members(name)` / `group.member_count(name)` | Introspection. | The default implementation is single-process. Multi-worker production should subclass and back the membership store with Redis Pub/Sub — the same controller code works unchanged. ### Authorisation ```python run @ws_controller("/private") class PrivateGateway: @on_connect async def auth(self, ws: WebSocket) -> None: if ws.headers.get("authorization") != "Bearer xxx": await ws.close(code=4401, reason="unauthorised") return # short-circuit await ws.accept() ``` Returning from `@on_connect` without calling `accept()` rejects the handshake. Three safe rejection patterns exist (all produce identical client-visible close codes): ```python fragment # 1. close() then return (preferred) await ws.close(code=4401, reason="unauthorised"); return # 2. raise WebSocketDisconnect (runtime closes) raise WebSocketDisconnect("unauthorised", close_code=4401) # 3. close() then raise (also safe — no duplicate close frame) await ws.close(code=4401); raise WebSocketDisconnect("unauthorised", close_code=4401) ``` ### Errors and `@on_error` ```python run @on_error async def caught(self, ws: WebSocket, exc: Exception) -> None: # Run for any exception inside @on_message handlers other than # WebSocketDisconnect. Returning normally resumes the loop. await ws.send_json({"error": str(exc)}) ``` Errors related to WebSockets: * `WebSocketError` — base class. * `WebSocketDisconnect(close_code=...)` — the peer closed. * `WebSocketValidationError` — a frame failed validation (the same compiled validator as an HTTP body of that type); `detail = {"event", "issues", "errors"}` with issue locations rooted at `["frame", "data"]`. * `WebSocketRouteNotFoundError` — no gateway matched the path. * `SocketIOError` (code `socketio_error`) — a Socket.IO-layer misuse, e.g. emitting on a closed connection (raised in-process, never sent). ### Testing ```python run from lauren import LaurenFactory from lauren.testing import WsTestClient app = LaurenFactory.create(ChatModule) client = WsTestClient(app) async with client.connect("/chat/42") as alice, client.connect("/chat/42") as bob: await alice.send_json({"event": "chat.send", "data": {"text": "hi"}}) reply = await bob.receive_json() # the sender is excluded from the broadcast assert reply == {"event": "chat.recv", "text": "hi"} ``` The session context-manager guarantees the server task is awaited so any unhandled server-side exception propagates into the test harness. ### Inheritance Subclasses of `@ws_controller` classes are NOT auto-mounted. The runtime raises `MetadataInheritanceError` at startup for any subclass in a module's `controllers=[...]` list without its own `@ws_controller` decoration. Method-level markers (`@on_connect` / `@on_message`) attach to the function itself; an override that doesn't re-decorate loses the marker — symmetric with HTTP route decorators. --- ## Server-Sent Events (`EventStream`) `EventStream` is a one-way streaming response primitive that frames events according to the Server-Sent Events spec (`text/event-stream`). The browser's `EventSource` reads from it natively — no JS-side parser code required. ```python run from lauren import EventStream, ServerSentEvent @get("/feed") async def feed(self, q: Depends[Queue]) -> EventStream: async def producer(): async for ev in q.subscribe(): yield ServerSentEvent(event=ev.kind, data=ev.payload, id=ev.id) return EventStream(producer(), keep_alive=15.0) ``` Producers can yield strings (treated as `data`), dicts (auto-promoted via `ServerSentEvent.from_dict`), or `ServerSentEvent` instances. `keep_alive=N` emits a comment frame (`: keep-alive\n\n`) every `N` seconds when the producer is idle, preventing intermediaries from killing long-lived connections. `last_event_id(request.headers)` reads the browser's `Last-Event-ID` header for resumable streams. ### Server-Sent Events Helpers `format_sse_event` formats a single SSE frame as its wire string without creating a full `ServerSentEvent` object — useful for low-level adapters: ```python run from lauren import format_sse_event line = format_sse_event(data="hello", event="ping", id="1") assert line == "event: ping\nid: 1\ndata: hello\n\n" ``` --- ## Typed Streaming For homogeneous streams (single Pydantic schema), prefer `StreamingResponse[T]` over raw `EventStream`. The framework content-negotiates between SSE, NDJSON, and JSON Lines based on the client's `Accept` header. ```python run from typing import AsyncIterator from pydantic import BaseModel from lauren import StreamingResponse class TickEvent(BaseModel): seq: int value: float @get("/ticks") async def ticks(self) -> StreamingResponse[TickEvent]: async def gen() -> AsyncIterator[TickEvent]: for i in range(100): yield TickEvent(seq=i, value=i * 0.5) await asyncio.sleep(0.05) return StreamingResponse(gen()) ``` Negotiation rules: | `Accept` (or default) | Format chosen | |--------------------------------------|-------------------------| | `text/event-stream` | SSE | | `application/x-ndjson`, `application/ndjson` | NDJSON | | `application/json+stream`, `application/jsonl`, `application/x-jsonlines` | JSON Lines (sent as `application/json+stream`) | | missing, `*/*`, or anything else | JSON Lines (default) | The `Stream` extractor and `StreamReader[T]` consumer give the same typed-iteration story for *receiving* a stream from the client. Each inbound item is validated by the app's compiled validator for `T`; a bad item raises `ExtractorError` whose issues are rooted at `["item", ]`. --- ## Socket.IO Compatibility Lauren ships an Engine.IO v4 / Socket.IO v5 adapter so the official `socket.io-client` can connect to a lauren backend without any wire-level glue: ```python run from lauren.socketio import ( SocketIOConnection, on_socketio_event, socketio_controller, ) @socketio_controller("/socket.io/") class ChatGateway: @on_socketio_event("connect") async def hello(self, conn: SocketIOConnection) -> None: await conn.emit("welcome", {"sid": conn.sid}) @on_socketio_event("chat:message") async def on_message( self, conn: SocketIOConnection, payload: dict ) -> dict: return {"echo": payload} # auto-acks the client ``` Two reserved event names map to lifecycle hooks: `"connect"` and `"disconnect"`. Every other name routes inbound `EVENT` packets to the matching method; the return value, if non-`None`, becomes an `ACK` payload when the client supplied a callback. The adapter implements the **WebSocket transport** subset (no long-polling, no binary attachments, root namespace only). It layers on `@ws_controller` internally so DI, lifecycle, and middleware all work identically to a hand-written WS gateway. JS client:: import { io } from "socket.io-client"; const sock = io("ws://...", { transports: ["websocket"] }); sock.emit("chat:message", {text: "hi"}, (ack) => console.log(ack)); --- ## Sessions First-class, signed-cookie session management. Enable it with one factory kwarg; misconfiguration fails at startup, never at runtime. ### `SessionConfig(*, secret=None, store=None, cookie_name="lauren_session", max_age=1209600, idle_timeout=None, rolling=False, path="/", domain=None, secure=True, http_only=True, same_site="lax", serializer=None, autoload=True, revocation_store=None, user_id_key="user_id")` The single declaration point — passed as `LaurenFactory.create(..., sessions=SessionConfig(...))` (also a `Lauren(sessions=...)` constructor kwarg). Secure by default: the cookie is HMAC-signed (`secret` is required), `HttpOnly` and `Secure` are on, and `SameSite=Lax`. ```python run from lauren import LaurenFactory, SessionConfig, InMemorySessionStore app = LaurenFactory.create( AppModule, sessions=SessionConfig( secret="a-long-random-secret", # str | bytes | list[...] (rotation) store=InMemorySessionStore(), # default if omitted max_age=1_209_600, # 14 days same_site="lax", # "lax" | "strict" | "none" rolling=False, # slide the cookie window on each access ), ) ``` - `secret` accepts a list for **rotation**: the first signs, all verify. Each entry may be a `lauren.config.Secret` (unwrapped internally), so `SessionConfig(secret=settings.session_secret)` works with typed configuration. - `same_site="none"` requires `secure=True`; a `__Host-`/`__Secure-` `cookie_name` is validated against `secure`/`path`/`domain`; `max_age` / `idle_timeout` must be positive — each rejected with `SessionConfigError`. **Rule:** the signing secret is mandatory (the cookie is always signed). Keep it out of source control and rotate by prepending a new value. ### `Session` (a mutable, dict-like, per-request handle) Injected into any handler that declares a `session: Session` parameter, at zero per-request reflection cost (the same mechanism as `ExecutionContext`). `request.state.session` is the non-injected equivalent. ```python run from lauren import Session, controller, get, post @controller("/account") class AccountController: @get("/visits") async def visits(self, session: Session) -> dict: session["visits"] = session.get("visits", 0) + 1 # marks modified return {"visits": session["visits"]} @post("/login") async def login(self, session: Session) -> dict: session.regenerate_id() # session-fixation defence at login session["user_id"] = "u-42" return {"ok": True} @post("/logout") async def logout(self, session: Session) -> dict: session.invalidate() # drop server row + expire cookie return {"ok": True} ``` - `MutableMapping[str, Any]` — `session[k]`, `get`, `setdefault`, `pop`, `update`, `clear`, `in`, `len`, plus `as_dict()`. - Identity flags: `id`, `is_new`, `is_modified`, `is_invalidated`. - **Dirty-tracked persistence:** the session is written back and the cookie re-sent only when new-with-content, modified, regenerated, invalidated, or (under `rolling`) refreshed — a pure read touches neither store nor cookie. - `regenerate_id()` rotates the id (deletes the old server row, re-issues the cookie); `invalidate()` clears the session and expires the cookie. **Rule:** a handler that injects `Session` in an app without `sessions=SessionConfig(...)` raises `SessionConfigError` at startup. ### `SessionStore` (Protocol) + built-in backends ```python class SessionStore(Protocol): requires_secret: ClassVar[bool] client_side: ClassVar[bool] async def load(self, session_id: str) -> dict | None: ... async def save(self, session_id: str, data: dict, *, max_age: int | None) -> None: ... async def delete(self, session_id: str) -> None: ... def new_id(self) -> str: ... ``` - `InMemorySessionStore(*, max_entries=100_000, sweep_interval=60.0, clock=None)` — process-local and bounded: sessions expire after `max_age` (checked on load and swept proactively, so write-once sessions do not linger) and the least recently used are evicted beyond `max_entries`. Values are kept as Python objects. Dev / single-worker only. - `SignedCookieSessionStore(*, max_bytes=4096)` — stateless: the whole (signed) payload rides in the cookie, no server row. Signed ≠ encrypted — never put confidential data here; an over-size payload raises `ValueError`. - Production multi-worker: pass any `Store` (e.g. a Redis store) as `SessionConfig(store=...)` — it is adapted with `StoreSessionStore` (see [Stores & Caching](#stores--caching)) — or implement the Protocol yourself. The store is injectable as `store: SessionStore` and is registered as a lifecycle-managed singleton: its `@post_construct` / `@pre_destruct` (or `startup()` / `aclose()`) hooks run at `app.startup()` / `app.shutdown()`, so a store may open its connection pool in `@post_construct`. ### `SessionSerializer` (Protocol) `dumps(dict) -> bytes` / `loads(bytes) -> dict`; the default is compact JSON (`JSONSessionSerializer`). Override via `SessionConfig(serializer=...)` for the cookie store when a denser format is needed. ### `RevocationStore` (Protocol) + `InMemoryRevocationStore` — making cookie sessions revocable A stateless `SignedCookieSessionStore` cookie can be replayed until it expires (same as a JWT). Pass an optional `RevocationStore` to make it revocable; it stays off by default so the cookie store remains truly stateless unless you opt in. ```python run from lauren import SessionConfig, SignedCookieSessionStore, InMemoryRevocationStore app = LaurenFactory.create( AppModule, sessions=SessionConfig( secret="…", store=SignedCookieSessionStore(), revocation_store=InMemoryRevocationStore(), # opt in user_id_key="user_id", # session key holding the user id max_age=3600, # finite lifetime required ), ) ``` ```python class RevocationStore(Protocol): async def revoke_token(self, token_id: str, *, ttl: int | None = None) -> None: ... async def is_token_revoked(self, token_id: str) -> bool: ... async def revoke_user(self, user_id: str, *, cutoff: float | None = None, ttl: int | None = None) -> None: ... async def user_cutoff(self, user_id: str) -> float | None: ... ``` Two mechanisms, both automatic once enabled: - **Per-session deny-list** — `session.invalidate()` (logout) and `session.regenerate_id()` (login) deny-list the prior cookie's token id, so a replayed cookie is rejected. The cookie store's equivalent of deleting a server-side row. - **Per-user cutoff** — inject `revocation: RevocationStore` and call `await revocation.revoke_user(user_id)` for "log out everywhere" / force-logout-on-password-change. Any session minted before the cutoff is rejected (works for the cookie store *and* server-side stores). `InMemoryRevocationStore(*, max_entries=100_000, sweep_interval=60.0, clock=None)` is the dev default: TTL-pruned and bounded (an entry evicted beyond `max_entries` is no longer enforced; a warning is logged the first time). Any `Store` passed as `revocation_store=` is adapted with `StoreRevocationStore`. The store is registered as a global provider (with its lifecycle hooks), so handlers can inject it directly. **Rule:** revocation requires a finite `max_age` (or `idle_timeout`) so deny-list entries can self-prune — `SessionConfigError` at startup otherwise. ### `SessionConfigError` `StartupError` subclass, raised by `LaurenFactory.create` for any invalid session policy — joins the `*ConfigError` family. > **Relationship to `lauren-guards`:** core `lauren.Session` is the > general-purpose session-state mechanism. `lauren-guards.session_cookie` > is an authentication *guard* that verifies a session and populates > `request.state.user`; the two compose. Don't confuse `lauren.Session` > (mutable request handle) with `lauren_guards.Session` (frozen auth row). ### CSRF and JSON routes Cookie sessions make every state-changing route a CSRF target, and a `Json[T]` body is parsed whatever its `Content-Type` unless the app opts in: a cross-site HTML form or `fetch(..., {mode: "no-cors"})` can POST `text/plain` without a CORS preflight and the browser attaches the session cookie. Session-enabled apps therefore default to `strict_content_type="warn"` (mismatches accepted and logged once per route × media type) and `LaurenFactory.create` logs one startup WARN listing the JSON POST routes (the method cross-site simple requests can use) until a policy is chosen. Fix: `content=ContentConfig(strict_content_type=True)` (text/plain JSON → 415), and `lauren-guards.csrf` for form-encoded routes. See [Content-type policy](#content-type-policy-contentconfig). --- ## Stores & Caching One async key-value contract — `Store` — sits under every stateful feature: sessions, session revocation, the `Cache` service, response caching, idempotency keys and locks. Core ships the protocol, a bounded `MemoryStore`, `Lock`, the session adapters, `StoresModule` (`lauren.stores`) and `CacheModule`, `Cache`, `@cache_response`, `@idempotent` (`lauren.cache`). Networked backends (Redis, …) live in companion packages; nothing here adds a dependency. `Store`, `MemoryStore`, `StoresModule`, `StoreRegistry`, `Lock`, `Cache`, `CacheModule`, `cache_response` and `idempotent` are also exported from `lauren`. ```python run from lauren import LaurenFactory, controller, get, injectable, module, post from lauren.cache import Cache, CacheModule, cache_response, idempotent from lauren.stores import MemoryStore, StoresModule from lauren.testing import TestClient shop_store = MemoryStore(max_entries=10_000) # bounded; one backend for everything @injectable() class ShopPrices: def __init__(self, cache: Cache) -> None: self._cache = cache self.lookups = 0 async def price(self, sku: str) -> int: async def load() -> int: # the expensive call self.lookups += 1 return 42 return await self._cache.get_or_set(f"price:{sku}", load, ttl=60, tags=("prices",), type=int) @controller("/shop") class ShopController: def __init__(self, prices: ShopPrices, cache: Cache) -> None: self.prices, self.cache = prices, cache self.orders = 0 @get("/price/{sku}") @cache_response(ttl=30, tags=("prices",)) # GET/HEAD only; guards run first async def price(self, sku: str) -> dict: return {"sku": sku, "price": await self.prices.price(sku)} @post("/reprice") async def reprice(self) -> dict: await self.cache.invalidate_tags("prices") # O(1): one incr per tag return {"ok": True} @post("/orders") @idempotent(ttl=3600) # Idempotency-Key header required async def order(self) -> dict: self.orders += 1 return {"order": self.orders} @module( imports=[StoresModule.for_root(default=shop_store), CacheModule.for_root(default_ttl=300)], providers=[ShopPrices], controllers=[ShopController], ) class ShopModule: pass shop_app = LaurenFactory.create(ShopModule) with TestClient(shop_app) as client: assert client.get("/shop/price/a").json() == {"sku": "a", "price": 42} assert "age" in client.get("/shop/price/a").headers # served from the cache client.post("/shop/reprice") assert client.get("/shop/price/a").json()["price"] == 42 # recomputed after invalidation assert client.resolve(ShopPrices).lookups == 2 first = client.post("/shop/orders", headers={"Idempotency-Key": "k1"}) retry = client.post("/shop/orders", headers={"Idempotency-Key": "k1"}) assert first.json() == retry.json() == {"order": 1} assert retry.headers["idempotent-replayed"] == "true" ``` ### `Store` (Protocol) ```python @runtime_checkable class Store(Protocol): async def get(self, key: str) -> bytes | None: ... async def set(self, key: str, value: bytes, *, ttl: float | None = None) -> None: ... async def delete(self, *keys: str) -> int: ... # how many existed async def exists(self, key: str) -> bool: ... async def expire(self, key: str, ttl: float) -> bool: ... # False when absent async def ttl(self, key: str) -> float | None: ... # None: absent or no expiry async def incr(self, key: str, amount: int = 1, *, ttl: float | None = None) -> int: ... async def set_if_absent(self, key: str, value: bytes, *, ttl: float | None = None) -> bool: ... async def delete_if_equal(self, key: str, expected: bytes) -> bool: ... # compare-and-delete def namespace(self, prefix: str) -> Store: ... # keys become "prefix:key" ``` - Values are `bytes`, keys `str`; `ttl` is seconds (`float`), `None` = no expiry, `<= 0` raises `ValueError`. - `incr` is atomic, creates the key at `0` and applies `ttl` **only when it creates the key** (the quota idiom); a non-integer value raises `ValueError`. - `set_if_absent` is atomic `SET NX` (locks, idempotency, single-flight); `delete_if_equal` is atomic compare-and-delete (safe lock release). - `namespace("cache")` is a cheap view writing `"cache:"`; views nest and share the parent's capacity, clock and connection. `PrefixedStore(store, prefix)` (in `lauren.stores`) is the reusable implementation. - No `keys()` / `scan()` / `clear()`: bulk invalidation uses generation counters (`incr`), which every backend supports. - A backend may define `async def startup(self)` / `async def aclose(self)`; `StoresModule`, `CacheModule(store=...)` and `SessionConfig(store=...)` run them with the app (`@post_construct` / `@pre_destruct` win when declared). Pre-built instances start before any provider the container constructs (so a service's `@post_construct` may already use the cache) and stop after them. ### `MemoryStore(*, max_entries, sweep_interval=60.0, clock=None)` Bounded, process-local `Store`. `max_entries` is **required** (`ValueError` unless a positive int): least recently used entries are evicted beyond it. Expired entries are dropped on read and swept proactively at most every `sweep_interval` seconds, so write-once keys never accumulate. Every operation is a short synchronous critical section under a `threading.Lock` (never awaits while holding it), so one store works across event loops and threads. `clock` is a zero-argument callable returning seconds (`time.monotonic`) — inject a fake clock to test TTLs without sleeping. `len(store)` counts held entries; `store.sweep()` sweeps now. **Rule:** stores must be bounded — framework-created stores (`StoresModule` without `default=`, `CacheModule` without a store, the in-memory session stores) use `max_entries=100_000`. The bound is a count of entries, and namespaces of one store share it: when a bounded store also holds public response-cache entries, give revocation, sessions and idempotency records a store of their own (or a backend that does not evict them) so cache traffic cannot push them out. ### `StoresModule.for_root(*, default=None, named=None)` Returns a generated `@module` (put it in `imports=`). Registers, globally: the default store under `Store`, each named store under `StoreToken(name)` and a `StoreRegistry`. Values are `Store` instances or zero-argument factories called **once per app** (several apps built from one module never share a store); `default=None` creates a per-app `MemoryStore(max_entries=100_000)`. Each store is a lifecycle-managed singleton: its `startup()` / `aclose()` hooks run at `app.startup()` / `app.shutdown()` — unlike `use_value`, which never runs hooks. ```python from typing import Annotated from lauren import Inject, injectable from lauren.stores import Store, StoreRegistry, StoreToken @injectable() class RateCounter: def __init__( self, store: Store, # the default store local: Annotated[Store, Inject(StoreToken("local"))], # a named store registry: StoreRegistry, # registry.get("local") ) -> None: self.store, self.local = store, local ``` Errors (`StartupError`, `detail["reason"]`): `invalid_store` (a factory returned a non-store; a bad `for_root` argument raises `DecoratorUsageError` with the same reason), `duplicate_store_name` (two `default=`, a name registered twice, or a `Store` provider already registered). `StoreRegistry.get(name="default")` raises `LookupError` for unknown names. ### `Lock(store, name, *, ttl, wait=None, poll_interval=0.05)` Async context manager over the key `"lock:"`: acquired with `set_if_absent(token, ttl=ttl)` (random token), released with `delete_if_equal`, so a holder whose lease expired can never release a newer holder's lock. `await lock.acquire(wait=...)` returns a bool (`wait=None` / `0` tries once, otherwise retries with capped backoff); `async with` raises `TimeoutError` when it cannot acquire; `locked`, `key`, `release()`. The TTL is the lease — choose one longer than the critical section (no automatic extension). A distributed lock for another feature (for example a scheduler's single-runner lock with `acquire(key, *, ttl) -> bool` / `release(key)`) is these two primitives: ```python fragment import secrets from lauren.stores import Store class StoreBackedLock: def __init__(self, store: Store) -> None: self._store = store.namespace("locks") self._tokens: dict[str, bytes] = {} async def acquire(self, key: str, *, ttl: float) -> bool: token = secrets.token_hex(16).encode() if await self._store.set_if_absent(key, token, ttl=ttl): self._tokens[key] = token return True return False async def release(self, key: str) -> None: token = self._tokens.pop(key, None) if token is not None: await self._store.delete_if_equal(key, token) ``` ### Sessions and revocation on a `Store` `SessionConfig(store=..., revocation_store=...)` accept any `Store` and adapt it automatically: `StoreSessionStore(store, *, namespace="session", serializer=None)` (payloads JSON-serialised, `ttl=max_age`) and `StoreRevocationStore(store, *, namespace="revocation")` (revoked tokens and user cutoffs are keys with TTLs). One `MemoryStore` or Redis store can serve sessions, revocation and the cache side by side (size a bounded store so cache traffic cannot evict revocation entries — see the `MemoryStore` rule): ```python fragment store = MemoryStore(max_entries=200_000) app = LaurenFactory.create( AppModule, sessions=SessionConfig(secret="…", store=store, revocation_store=store, max_age=3600), ) ``` **Rule:** a store handed to `SessionConfig(store=...)` / `revocation_store=` (or the backend an adapter wraps) gets its `@post_construct` / `@pre_destruct` — or `startup()` / `aclose()` — hooks run with the app, exactly once per object even when the same backend is also a `StoresModule` store. ### `CacheModule.for_root(*, store=None, namespace="cache", default_ttl=300.0, codec=None, lock_ttl=30.0, lock_wait=5.0, clock=None)` Registers the app's `Cache` globally. `store=None` uses the default `Store` provider when one exists, else a private per-app `MemoryStore(max_entries=100_000)`; a string names a `StoresModule` store; a `Store` instance or zero-argument factory is used directly (its lifecycle runs with the app). Entries live under `":"`. `codec=None` encodes with the app's JSON encoder (`JsonCacheCodec`). `clock` (wall-clock seconds) drives stale-while-revalidate and `Age` — inject it in tests. A `Cache` the application registers itself wins. Errors: two different `for_root` modules → `StartupError(reason="conflicting_cache_options")`; an unknown store name or a factory returning a non-store → `reason="invalid_store"`; bad arguments → `DecoratorUsageError`. ### `Cache(store, *, default_ttl=300.0, codec=None, lock_ttl=30.0, lock_wait=5.0, clock=None)` | Method | Semantics | |---|---| | `await get(key, *, type=None)` | Decoded value or `None` (miss). `type=` validates into a model / dataclass / TypedDict / msgspec struct, `list[T]`, `tuple[T, ...]`, `dict[str, T]`, `T \| None` or a scalar; data that no longer validates is a miss. | | `await set(key, value, *, ttl=, tags=())` | Encode and store; `ttl=None` = no expiry. | | `await delete(*keys) -> int` | Remove entries. | | `await get_or_set(key, factory, *, ttl=, tags=(), type=None, lock=False, stale_ttl=None)` | Cache-aside. `factory()` may return an awaitable. Concurrent misses in one process share **one** call; an exception reaches every waiter and nothing is cached. `lock=True` adds a store lease (others poll up to `lock_wait`, then compute themselves — never deadlock). `stale_ttl` serves the expired value for that long while one caller refreshes in the background (errors logged). | | `await invalidate_tags(*tags)` | One `incr` per tag; every entry written under the tag misses. | | `typed(T) -> TypedCache` | A view whose reads decode into `T`. | - Tags use generation counters (no `KEYS` / `SCAN`): an entry records the generations of its tags; a read compares them. Generations are read before the factory runs, so an invalidation that lands mid-computation leaves the new entry stale. Counters are seeded randomly, so an evicted counter never resurrects old entries. - Without `type=` a hit returns JSON data (`dict` / `list` / scalars) while a miss returns the factory's own value — pass `type=` to get models both ways. - A `stale_ttl` factory may run after the request that triggered it ended: never close over request-scoped objects there. - `CacheCodec` (`encode(value) -> bytes`, `decode(bytes) -> Any`) is the codec protocol. There is no pickle codec: values cross process boundaries. ### `@cache_response(*, ttl, vary=(), key=None, tags=(), methods=("GET", "HEAD"), statuses=DEFAULT_CACHEABLE_STATUSES, debug_header=False, respect_client_no_cache=False, query=None, allow_authenticated=False)` Server-side response caching for a route handler. Metadata only: validated at decoration (`DecoratorUsageError` for bare usage, a missing / non-positive `ttl`, an unsafe method, a `key` that does not take one argument, bad statuses, stacking, or combining with `@idempotent`), compiled at startup into a framework interceptor that is the route's **innermost** interceptor — after guards (authorization always runs before a lookup) and inside user interceptors (they observe hits). Routes without a policy pay nothing. - **Key:** method (`HEAD` reads the `GET` entry, never writes it) + route template + path params + query (order-insensitive across names, repeated values keep their order; `query=("page", …)` keeps only those parameters, so `?_=` cannot bust the cache) + the request headers named in `vary` + `key(ctx)`, hashed into `resp::`. `key=` is **mandatory for per-user data**; `key(ctx)` returning `None` bypasses the cache for that request. `tags` may be names or a callable of the `ExecutionContext`. - **Credentials:** a request carrying `Authorization` bypasses the cache (no lookup, no store) unless `key=` or `vary` on `authorization` / `cookie` makes entries per caller, or `allow_authenticated=True` declares the response identical for every caller. - **Stored only when** the status is in `statuses` (`DEFAULT_CACHEABLE_STATUSES` = 200, 203, 204, 300, 301, 404, 405, 410 — returned, not raised), the body is not streamed, there is no `Set-Cookie`, `Cache-Control` has no `private` / `no-store` / `no-cache`, and `Vary` names only headers listed in `vary` (never `*`). Anything else is a pass-through miss, never an error. - **Hit:** status, headers (minus hop-by-hop) and body are replayed with `Age`; `debug_header=True` adds `X-Cache: HIT|MISS`. Session cookies are applied outside the cache by the session engine, so a hit never replays another user's cookie. - Concurrent misses for one key share one handler run when its response is storable; otherwise each request runs its own. - It never emits `Cache-Control`; handler-set `Cache-Control` / `ETag` / `Last-Modified` are stored and replayed. `respect_client_no_cache=True` lets a request's `Cache-Control: no-cache` force a refresh (off by default — a trivial cache-busting lever). - OpenAPI marks the operation with `x-cache-ttl`. - Startup: no `Cache` provider → `StartupError(reason="cache_module_missing", handler=..., method=..., path=...)`; a cached route that injects `Session` without `key=` (or `vary` on `cookie` / `authorization`) logs a warning. ### `@idempotent(*, ttl=86400.0, header="Idempotency-Key", required=True, scope=None, lock_ttl=60.0)` Retries of an unsafe request with the same key run the handler once (Stripe / IETF `Idempotency-Key` semantics), stored under the cache store's `idem:` namespace: | Situation | Response | |---|---| | No header, `required=True` | `400` `BadRequestError` (`code="idempotency_key_missing"`); a key over 255 characters → `code="idempotency_key_invalid"` | | No header, `required=False` | handler runs normally | | First request with key `K` | handler runs; its response (status < 500, not streamed, `Set-Cookie` dropped) is stored for `ttl` | | Same `K`, same payload, completed | stored response replayed with `Idempotent-Replayed: true` | | Same `K` while the first still runs | `409` `ConflictError` (`code="idempotency_key_in_use"`, `Retry-After: 1`) | | Same `K`, different payload | `422` (`code="idempotency_key_mismatch"`) | | Handler raised | nothing stored — the retry runs again | The payload fingerprint is SHA-256 over method, route template, path, query and body. `scope(ctx)` partitions keys per user / tenant; `lock_ttl` bounds how long an in-flight request holds its key. Needs a `Cache` (same startup check as `@cache_response`). ### Testing stores `lauren.testing.store_contract.run_store_contract(make_store, *, advance=None, checks=None, concurrency=1000)` runs the normative contract (round-trip, TTL semantics and validation, `incr` atomicity under concurrency, `set_if_absent` exclusivity, `delete_if_equal`, namespaces, expiry) against fresh stores and raises `AssertionError` on the first violation. `advance(seconds)` moves the store's clock (sync or async; a networked backend may pass `asyncio.sleep` with short TTLs); without it the expiry check is skipped. `STORE_CONTRACT_CHECKS` lists the check names for `pytest.mark.parametrize`. ```python run import asyncio from lauren.stores import MemoryStore from lauren.testing.store_contract import run_store_contract class ContractClock: def __init__(self) -> None: self.now = 0.0 def __call__(self) -> float: return self.now def advance(self, seconds: float) -> None: self.now += seconds contract_clock = ContractClock() asyncio.run(run_store_contract(lambda: MemoryStore(max_entries=10_000, clock=contract_clock), advance=contract_clock.advance, concurrency=100)) ``` **Rule:** never cache `Set-Cookie` or per-user responses without `key=`, and keep values JSON-encodable — there is no pickle codec. --- ## Content-type policy (`ContentConfig`) ```python run from lauren import ContentConfig, LaurenFactory app = LaurenFactory.create(AppModule, content=ContentConfig(strict_content_type=True)) # also: Lauren(content=ContentConfig(...)) ``` `ContentConfig(strict_content_type=None)` is a frozen dataclass (importable from `lauren` and `lauren.types`). `strict_content_type` decides what a JSON body parameter — `Json[T]`, an implicit model body, `Discriminated[...]` — does with the request's `Content-Type`. The check runs right **after guards** (401/403 still win), **before interceptors** and **before any body byte is read**. | Value | Behaviour | |---|---| | `True` | Accept only `application/json` and `application/*+json` (case-insensitive; `charset` absent or UTF-8). Anything else, and a body announced by `Content-Length` > 0 or `Transfer-Encoding` without a `Content-Type`, raises `UnsupportedMediaTypeError` → 415 `{"error": {"code": "unsupported_media_type", "detail": {"content_type": , "supported": ["application/json", "application/*+json"]}}}`. | | `"warn"` | Same check, but mismatches are accepted; the first occurrence of each media type per route is logged at WARN (app logger, context `"ContentPolicy"`, and the stdlib `"lauren"` logger; at most 16 media types remembered per route). | | `False` | No check. | | `None` (default) | `"warn"` when `sessions=` is configured, else `False`. Any explicit value silences the session startup warning. | An empty body without `Content-Type` still reaches the extractor (422 `missing JSON body`, or the parameter default). `Bytes`, `ByteStream`, `Form` and `UploadFile` parameters are not gated. `Request.json()` called inside a handler is not gated. The 415 is an ordinary `HTTPError`: exception handlers can claim it. An invalid value (or a non-`ContentConfig` `content=`) raises `StartupError` with `detail["reason"] == "invalid_content_config"`. --- ## Configuration Typed settings, validated inside `LaurenFactory.create` (factory Phase 2b, before the DI graph compiles). Settings classes are DI providers; every missing or invalid value in every settings class is raised together as one `ConfigValidationError`; `Secret[T]` is redacted everywhere; `os.environ` is read, never written. Public names live in `lauren.config` (`ConfigModule` and `Secret` are also exported from `lauren`; `ConfigValidationError` from `lauren` and `lauren.exceptions`). ```python run from dataclasses import dataclass, field from typing import Annotated from lauren import LaurenFactory, controller, get, injectable, module from lauren.config import ConfigModule, Env, EnvSource, Profile, Secret from lauren.testing import TestClient @dataclass(frozen=True) class CfgDatabaseSettings: url: str # required pool_size: int = 10 @dataclass(frozen=True) class CfgAppSettings: session_secret: Secret[str] # required, redacted database: CfgDatabaseSettings # nested section: APP_DATABASE__URL debug: bool = False allowed_origins: list[str] = field(default_factory=list) # "a, b" or '["a","b"]' llm_api_key: Annotated[Secret[str], Env("LLM_API_KEY")] = Secret("") # explicit name, no prefix @injectable() class CfgMailer: def __init__(self, settings: CfgAppSettings, profile: Profile) -> None: # typed injection self.debug = settings.debug self.sandbox = not profile.is_production @controller("/cfg") class CfgController: def __init__(self, mailer: CfgMailer, settings: CfgAppSettings) -> None: self.mailer, self.settings = mailer, settings @get("/") async def show(self) -> dict: return {"debug": self.mailer.debug, "pool": self.settings.database.pool_size, "secret": self.settings.session_secret} # JSON-encodes as "**********" @module( imports=[ConfigModule.for_root( CfgAppSettings, env_prefix="APP_", sources=[EnvSource()], # production: omit -> .env, .env.{profile}, environment environ={"APP_SESSION_SECRET": "s3cr3t", "APP_DATABASE__URL": "sqlite://", "APP_DATABASE__POOL_SIZE": "5"}, # tests: replaces os.environ; production: omit )], controllers=[CfgController], providers=[CfgMailer], ) class CfgAppModule: pass cfg_app = LaurenFactory.create(CfgAppModule) assert TestClient(cfg_app).get("/cfg/").json() == {"debug": False, "pool": 5, "secret": "**********"} ``` ### `ConfigModule.for_root(settings_cls, *, env_prefix="", sources=None, section=None, profile=None, profiles=("development", "test", "production"), environ=None, overrides=None, is_global=True, expose_sections=False)` Returns a new generated `@module` class per call (put it in `imports=`). Global by default: every module can inject `settings_cls` without importing anything. `profile=`, `profiles=` and `environ=` are app-wide (several `for_root` modules must agree on them, else `StartupError` `reason="conflicting_config_roots"`). `is_global=False` makes it module-scoped like `for_feature`. ### `ConfigModule.for_feature(settings_cls, *, env_prefix="", sources=None, section=None, overrides=None, expose_sections=False)` Module-scoped: the importing module, and importers of a module that re-exports the class, can inject it; anyone else gets `MissingProviderError`. `sources=None` inherits the root's sources (or the default stack). `section="database"` reads the `[database]` table of TOML / YAML files. Declaring the same class in two feature modules is a `ModuleExportViolation`. `expose_sections=True` (both factories) also provides every nested section class as its own token; a section type used twice is a `DuplicateBindingError`. ### Sources and precedence Lowest → highest: class defaults < `sources` in list order < `EnvSource()` (must be **last**; a `.env` file never overrides the real environment) < `overrides={"dotted.path": value}`. `sources=None` = `[DotenvSource(".env", required=False), DotenvSource(".env.{profile}", required=False), EnvSource()]`. | Source | Notes | |---|---| | `EnvSource()` | `os.environ`, or the `environ=` mapping | | `DotenvSource(path, *, required=True, encoding="utf-8")` | stdlib parser: `KEY=VALUE`, `export`, `#` comments, `'literal'`, `"escaped\n"` multi-line; no `${VAR}` interpolation; never writes `os.environ` | | `TomlSource(path, *, required=True)` | `tomllib`; tables → nested sections; values checked strictly | | `YamlSource(path, *, required=True)` | needs PyYAML (`lauren[yaml]`); like TOML | | `SecretsDirSource(path, *, required=True)` | one file per key (Docker/Kubernetes `/run/secrets`); name = env name or its lower case | | `AsyncSource` (Protocol) | `async def load(self, keys) -> Mapping[str, str]`; optional `name` attribute | `{profile}` in paths is substituted. Env names: `APP_` + field path joined with `__`, upper case (`database.url` → `APP_DATABASE__URL`); `Annotated[T, Env("A", "B")]` reads the first present of `A`, `B` with no prefix. String values (env, `.env`, secret files, async) use the lax table: `int`/`float`/`Decimal`, `bool` (`1/0 true/false yes/no on/off` — anything else is **invalid**), `Enum` (value then name), `Literal`, `Path`, `UUID`, ISO `datetime`/`date`/`time`, `timedelta` (seconds or `PT5M`), `list`/`tuple`/`set` (JSON array or comma-separated), `dict` (JSON object), `X | None` (`""` → `None`), `Secret[T]`. TOML/YAML values are already typed and checked strictly. Settings shapes: frozen `@dataclass` (recommended, stdlib-only), pydantic `BaseModel` (coerced, then `model_validate`; constraint errors mapped back), `msgspec.Struct` (coerced, then `msgspec.convert(strict=False)`), and `pydantic_settings.BaseSettings` (delegated entirely; `env_prefix=`, `sources=`, `section=` rejected). TypedDict is rejected. A non-frozen dataclass logs a startup warning. ### Profiles — `Profile(name)` Active profile: `for_root(profile=...)`, else `LAUREN_PROFILE` (from `environ=` when given), else `"development"`. Not in `profiles=` → `ConfigValidationError` (`reason="unknown_profile"`), never a fallback. `Profile` (frozen: `name`, `is_development`, `is_test`, `is_production`) is registered globally whenever the app has a `ConfigModule`. ### `Secret[T]` `Secret(value)`; `get_secret_value()` reads it. `repr`/`str`/f-strings print `Secret('**********')`; Lauren's JSON encoders (and pydantic JSON mode for a `Secret[T]` model field) print `"**********"`, as they do for pydantic `SecretStr`/`SecretBytes`. `==`/`hash` compare the value; `bool()` follows it. `SessionConfig(secret=Secret(...))` (or a list of them) is accepted. ### `load_settings(settings_cls, *, env_prefix="", sources=None, section=None, profile=None, profiles=..., environ=None, overrides=None)` Same loader and aggregated error, outside the module graph — for values needed *before* the factory (`sessions=`, `docs_url=`, `logger=`). Then pass `global_providers=[use_value(provide=AppSettings, value=settings)]`. `await aload_settings(...)` also awaits async sources (`load_settings` rejects them with `DecoratorUsageError`). ### `ConfigValidationError` `StartupError` subclass (`code="config_validation"`). Message: one line per problem, e.g. `AppSettings.database.pool_size [APP_DATABASE__POOL_SIZE] invalid int 'ten' (from environment)`. `detail = {"profile": str, "errors": [entry, ...]}`; each entry has `reason`: `missing` (`settings`, `field`, `key`, `searched`), `invalid` (`settings`, `field`, `key`, `expected`, `source`, `value` — omitted for secrets —, `message`), `unknown_field` (an `overrides` key), `unknown_profile` (`profile`, `allowed`, `source`), `missing_file` / `parse_error` (`line` for `.env`) / `source_error` (`source`, `message`). Feature-module entries add `module`. Misuse of the API itself (TypedDict, `EnvSource()` not last, bad argument types) raises `DecoratorUsageError` when the module is declared. ### Async sources Settings that list an `AsyncSource` finish loading at the start of `LaurenApp.startup()` (lifespan / `TestClient`), before `@post_construct` — the one exception to "validated in `create()`". `create()` still raises for problems an async source cannot fix (e.g. an invalid value in the higher-precedence `EnvSource()`). Resolving such settings before startup raises `StartupError` (`reason="settings_not_loaded"`). `create()` logs one INFO line, context `Config`: `profile=production settings=AppSettings(8 keys) MailSettings(2 keys) sources=.env.production, environment` (sources actually read; never values). **Rule:** never read `os.environ` in `__init__` or at import time, and never `load_dotenv(override=True)` — declare a settings class, import a `ConfigModule`, inject the class. Tests pass `environ={...}` instead of mutating the process environment. --- ## Static Files `StaticFilesModule` serves a directory of static assets as a Lauren feature module: ```python run from lauren.static_files import StaticFilesModule @module(imports=[StaticFilesModule.for_root("/assets", directory="./public")]) class AssetsModule: ... ``` `StaticFilesModule.for_root(path, *, directory, max_age=3600, dotfiles="allow_well_known")`. Path traversal → 403; missing file → empty 404; ETag + `If-None-Match` → 304. **Dotfiles are not served by default:** any path segment starting with `.` (`/.env`, `/.git/config`, `/a/.hidden/b.txt`) gets the same empty 404 as a missing file — judged on the request path *and* on the resolved path, so `/static/../public/.env` or a symlink into a dot-directory is hidden too. `dotfiles="allow_well_known"` (default) still serves `/.well-known/**` (but not dotfiles inside it); `"deny"` hides `/.well-known/` too; `"allow"` serves every dotfile (the previous default). Unknown values raise `ValueError` from `for_root`. --- ## Request Arena `RequestArena` is the pool of reusable per-request containers. `RequestAllocation` is the handle to a leased set of containers for a single request. Both are internal infrastructure exposed for advanced ASGI adapter customisation; typical application code never touches them. The `Request`-instance pool reuses objects via `Request.reset`, which clears the instance's entire `__dict__` before re-populating the canonical fields — so no attribute from a previous request (canonical or user-attached) can leak onto the next. This is a correctness invariant, not hygiene. --- ## Reflect — Metadata Introspection API `lauren.reflect` provides typed readers for every piece of decorator-attached metadata in a Lauren application: controller and module declarations, HTTP routes, WS gateway hooks, guard/interceptor/middleware chains, user metadata, and the compiled runtime dispatch table. All readers return `None` / empty tuple for undecorated objects and use `cls.__dict__` only (own-class rule — no inheritance). ### WebSocket context types #### `WsUpgradeRequest` Read-only view of the HTTP upgrade request that opened a WebSocket connection. Guards receive this via `ctx.request`, duck-typing with the HTTP `Request` object: ```python class ApiKeyGuard: async def can_activate(self, ctx) -> bool: return ctx.request.headers.get("x-api-key") == "secret" ``` Fields: `headers`, `path`, `path_params`, `query_string`, `client`, `method` (always `"GET"`). #### `WsConnectionContext` Context object passed to guards and interceptors at WebSocket connection time. Mirrors `ExecutionContext` so the same guard class works for HTTP and WS: ```python @use_guards(ApiKeyGuard) @ws_controller("/private") class PrivateGateway: ... ``` Fields: `request` (`WsUpgradeRequest`), `connection` (live `WebSocket`), `handler_class`, `handler_func`, `route_template`, `metadata`. Method: `get_metadata(key, default=None)`. #### `WsMessageContext` `WsConnectionContext` subclass passed to guards and interceptors declared on an `@on_message` handler, once per frame. Adds `event: str` (the frame's event; `"__binary__"` for binary frames, the real name for wildcard handlers) and `payload: Any` (the validated payload; raw `bytes` for binary frames); `handler_func` is the handler and `metadata` merges class and method `@set_metadata` (method wins). Also importable from `lauren`. ### Cross-cutting concern readers All read from `cls.__dict__` only. | Function | Returns | |---|---| | `reflect_guards(cls)` | `tuple[type, ...]` — guard classes via `@use_guards` | | `reflect_interceptors(cls)` | `tuple[type, ...]` — interceptor classes via `@use_interceptors` | | `reflect_middlewares(cls)` | `tuple[type, ...]` — middleware classes via `@use_middlewares` | | `reflect_exception_handlers(cls_or_fn)` | `tuple[Any, ...]` — exception handlers via `@use_exception_handlers` | | `reflect_all(cls)` | `ReflectedMeta(guards, interceptors, middlewares)` | ### Static class readers All return `None` (or empty tuple) for undecorated objects. | Function | Returns | |---|---| | `reflect_controller(cls)` | `ControllerMeta \| None` | | `reflect_module(cls)` | `ModuleMeta \| None` | | `reflect_injectable(cls)` | `InjectableMeta \| None` | | `reflect_ws_controller(cls)` | `WsControllerMeta \| None` | | `reflect_routes(cls)` | `tuple[ReflectedRoute, ...]` — all `@get`/`@post`/… on the class | | `reflect_ws_messages(cls)` | `tuple[ReflectedWsMessage, ...]` — all `@on_message` declarations | `reflect_routes` folds the controller prefix into `ReflectedRoute.full_path`: ```python @controller("/users") class UserController: @get("/{id}") async def get_user(self): ... routes = reflect_routes(UserController) routes[0].full_path # "/users/{id}" routes[0].method # "GET" ``` ### Structured class getters Return `None` when the class is not the expected type. | Function | Returns | |---|---| | `get_controller_metadata(cls)` | `ReflectedController \| None` | | `get_module_metadata(cls)` | `ReflectedModule \| None` | `ReflectedController` bundles `meta` (`ControllerMeta`), `guards`, `interceptors`, `middlewares`, `exception_handlers`, and `routes`. `ReflectedModule` bundles `meta` (`ModuleMeta`) — read `meta.controllers`, `meta.providers`, `meta.imports`, `meta.exports` from there. ### User metadata and encoder ```python reflect_user_metadata(obj, key=None, default=None) # → full dict when key is None, single value otherwise reflect_encoder(cls_or_fn) # → JSONEncoder instance or None ``` ### App-level readers (require started app) These return empty tuple / `None` before `LaurenFactory.create()` has run startup. ```python from lauren.reflect import get_all_routes, get_all_ws_gateways, get_route_metadata app = LaurenFactory.create(AppModule) TestClient(app) # triggers startup for r in get_all_routes(app): print(r.method, r.full_path, r.handler.__name__) for gw in get_all_ws_gateways(app): print(gw.path_template, gw.guards) route = get_route_metadata(app, "GET", "/users/{id}") # ReflectedRoute | None ``` | Function | Returns | |---|---| | `get_all_routes(app)` | `tuple[ReflectedRoute, ...]` | | `get_all_ws_gateways(app)` | `tuple[ReflectedWsGateway, ...]` | | `get_route_metadata(app, method, path)` | `ReflectedRoute \| None` | ### Result types | Type | Key fields | |---|---| | `ReflectedRoute` | `method`, `path`, `full_path`, `summary`, `response_model`, `tags`, `deprecated`, `handler`, `status_code` (effective success status), `responses_typed`, `raises` | | `ReflectedResponse` | `status`, `body_type` (`None`: no body), `description`, `source` (`"declared"` / `"raises"` / `"manual"`) — the entries of `ReflectedRoute.responses_typed` | | `ReflectedWsMessage` | `event`, `payload_model` (reserved — always `None` today), `summary`, `handler` | | `ReflectedController` | `cls`, `meta`, `guards`, `interceptors`, `middlewares`, `exception_handlers`, `routes` | | `ReflectedModule` | `cls`, `meta` | | `ReflectedWsGateway` | `cls`, `path_template`, `meta`, `guards`, `interceptors`, `middlewares`, `messages`, `owning_module` | | `ReflectedError` | `cls`, `code`, `status_code`, `category`, `problem_type`, `problem_title`, `summary` — from `error_catalog(include_user=True)` (see [Error catalog as data](#error-catalog-as-data)) | ### `reflect` module All symbols are importable from `lauren.reflect` or directly from `lauren`: ```python import lauren.reflect as reflect from lauren import reflect_routes, get_all_routes, ReflectedRoute ``` --- ## AI Documentation Access `docs` is a module that exposes the bundled `llms.txt` and `llms-full.txt` files programmatically: ```python run import lauren.docs as ld overview = ld.llms_txt() # short overview (llmstxt.org format) reference = ld.llms_full_txt() # this complete reference assert overview.startswith("# lauren") and "_End of reference._" in reference ``` The documentation website also runs a public, read-only **MCP server** at `https://lauren-py.dev/api/mcp` (Streamable HTTP, JSON responses, no auth) with the tools `search_docs(query, limit?, section?)`, `get_doc(path, heading?)`, `list_docs(section?)` and `get_reference(variant?)`, plus every page as a `lauren-docs://docs/` resource. Claude Code: `claude mcp add --transport http lauren-docs https://lauren-py.dev/api/mcp`; other clients: https://lauren-py.dev/mcp. --- ## Common Patterns & Anti-Patterns ### Pattern: per-request DB session ```python @injectable(scope=Scope.REQUEST) class DbSession: def __init__(self, engine: Engine) -> None: self._engine = engine self._s = None @property def s(self): if self._s is None: self._s = self._engine.session() return self._s async def aclose(self): # called automatically by lauren if self._s is not None: await self._s.close() @controller("/x") class C: def __init__(self, db: DbSession): ... ``` Lauren's runtime awaits `aclose()` on every request-scoped instance after each request, so DB sessions close deterministically. Outside a request (a script, a background job), open `async with app.scope() as scope:` and `await scope.resolve(DbSession)` to get the same per-scope instance and teardown. ### Pattern: extract the user at the handler level ```python class CurrentUser(ExtractionMarker): source = "app.current_user" async def extract(self, execution_context: ExecutionContext, extraction: Extraction) -> object: user = execution_context.request.state.get("user") # set by an auth guard if user is None: raise UnauthorizedError("login required") return user @controller("/me") class MeController: @get("/") async def me(self, user: CurrentUser) -> dict: return {"id": user["id"]} ``` Better than threading an AuthContext through the controller constructor — the dependency is visible per-route. ### Pattern: declare the success status ```python fragment @post("/", status_code=201) async def create(self, body: Json[CreateUser]) -> UserOut: return await self._repo.create(body) @post("/with-location") async def create_with_location(self, body: Json[CreateUser]) -> Created[UserOut]: user = await self._repo.create(body) return Created(user, location=f"/users/{user.id}") ``` The tuple form (`return user, 201, {"location": ...}`) still works but is invisible to OpenAPI. ### Anti-pattern: subclassing to "inherit" routes ```python @controller("/a") class A: @get("/") async def idx(self): ... class B(A): # NO - not a controller, startup raises MetadataInheritanceError pass ``` Decorate B explicitly with `@controller(...)` to opt in. ### Anti-pattern: singleton depending on request-scoped ```python @injectable() # SINGLETON class Bad: def __init__(self, session: DbSession): ... # DbSession is REQUEST \u2014 raises DIScopeViolationError ``` Either make `Bad` request-scoped too, or refactor so it receives the session per-method. ### Pattern: sync handler wrapping a blocking library ```python import httpx as _httpx # sync client @controller("/proxy") class ProxyController: @get("/data") def fetch(self) -> dict: # time.sleep / requests.get / blocking DB calls are all safe here — # the sync handler runs in a thread pool, not on the event loop. resp = _httpx.get("https://api.example.com/data") return resp.json() ``` ### Anti-pattern: starting a background loop in `@post_construct` ```python @injectable() class Bad: @post_construct async def start(self) -> None: asyncio.create_task(self.loop()) # unsupervised, outlives its dependencies, dies under TestClient ``` Use a hosted service instead — supervised, stopped before `@pre_destruct`: ```python fragment @hosted_service() class Good: async def run(self, ctx: ServiceContext) -> None: while await ctx.sleep(5.0): await self.tick() ``` ### Anti-pattern: admin tasks as HTTP endpoints ```python fragment @controller("/api") class SeedController: @post("/seed") # anyone who reaches the API can re-seed the database async def seed(self, db: DatabaseService) -> dict: ... ``` Write a command instead and run it where the operator controls the process (`python -m lauren exec app.main:AppModule db:seed`, a Kubernetes `CronJob`): ```python fragment @injectable() class SeedCommands: def __init__(self, db: DatabaseService) -> None: self._db = db @command("db:seed") async def seed(self, ctx: CommandContext, *, count: int = 50) -> None: ctx.echo(f"seeded {await run_seed(self._db, count=count)} rows") ``` ### Anti-pattern: calling asyncio primitives from a sync handler ```python import asyncio _queue: asyncio.Queue = asyncio.Queue() @controller("/events") class EventController: @post("/") def push(self, body: Json[Event]) -> None: # ❌ NOT safe — asyncio.Queue is not thread-safe _queue.put_nowait(body) # ✅ Safe — schedules work back on the event loop asyncio.get_running_loop().call_soon_threadsafe(_queue.put_nowait, body) ``` ### Anti-pattern: a hand-rolled `{"status": "ok"}` health endpoint ```python # Don't: "ok" before startup, during shutdown and while the database is down. @get("/health") async def health(self) -> dict: return {"status": "ok"} # Do: probes that follow the lifecycle state and real checks. @module(imports=[HealthModule.for_root()], providers=[Database]) # Database has @health_check("database") class AppModule: ... ``` Point `livenessProbe` at `/health/live` (no dependency checks) and `readinessProbe` at `/health/ready`. ### Anti-pattern: building `Response.json(...)` manually for every handler ```python # Don't: @get("/") async def h(self) -> Response: return Response.json({"ok": True}) # Do: @get("/") async def h(self) -> dict: return {"ok": True} ``` Lauren serializes the return value automatically. --- ## Companion Packages The framework stays small on purpose; cross-cutting concerns live in companion packages that depend on stock `lauren>=1.0`. ### `lauren-middlewares` Production-grade middlewares, each as a factory function returning a `@middleware()`-decorated class: * `cors(...)` — W3C CORS with origin allow-lists, regex, credentials, expose-headers, preflight short-circuit. * `rate_limit(...)` — token-bucket per IP/key, pluggable store, 429 + `Retry-After`. * `gzip(...)` — compress responses ≥ threshold for compressible types. * `security_headers(...)` — OWASP defaults (HSTS, XCTO, XFO, Referrer-Policy, Permissions-Policy, COOP/CORP, optional CSP). * `request_id(...)` — generate / propagate `X-Request-ID`. * `trusted_hosts(...)` — `Host` header allow-list with subdomain wildcards. * `request_log(...)` — structured access log. * `https_redirect(...)` — HTTP → HTTPS with `X-Forwarded-Proto`. * `body_size_limit(...)` — early 413 on oversize POSTs. * `timeout(...)` — `asyncio.wait_for` wrapper, 504 on expiry. ```bash pip install lauren-middlewares ``` ```python from lauren import LaurenFactory from lauren_middlewares import ( cors, rate_limit, gzip, security_headers, request_id, ) app = LaurenFactory.create( AppModule, global_middlewares=[ request_id(), security_headers(), cors(allow_origins=["https://app.example.com"]), rate_limit(per_minute=60), gzip(), ], ) ``` ### `lauren-logging` Configurable logging module modelled after NestJS's `LoggerModule.forRoot`. Processor pipeline, contextvars binding, request-logging middleware, pluggable backends. ```bash pip install lauren-logging # core pip install lauren-logging[structlog] # structlog backend pip install lauren-logging[otel] # OpenTelemetry processor ``` ```python from lauren_logging import ( LoggingModule, LoggingConfig, LoggingService, ConsoleBackend, ContextvarsProcessor, RedactSecrets, ) LogModule = LoggingModule.configure( LoggingConfig( backend=ConsoleBackend(), processors=[ContextvarsProcessor(), RedactSecrets()], ), ) @module(imports=[LogModule], controllers=[BillingController]) class AppModule: ... @injectable() class BillingService: def __init__(self, log: LoggingService) -> None: self.log = log def charge(self, user_id: int) -> None: self.log.info("charged", user_id=user_id) ``` Backends: `ConsoleBackend`, `StdlibBackend`, `StructlogBackend` (extra), `FileBackend`, `RotatingFileBackend`, `FanOutBackend`, `QueueBackend`, `NullBackend`, `InMemoryBackend`. Processors: `ContextvarsProcessor`, `RedactSecrets`, `Sampler`, `RateLimit`, `TimestampProcessor`, `OpenTelemetryProcessor` (extra). Drop-in `request_logging()` middleware propagates `X-Request-ID`, binds contextvars, and emits a structured request-summary line per request. --- ## Appendix: Cheat Sheet | Task | Pattern | |---|---| | Register a dependency | `@injectable()` on the class; add to `@module(providers=[...])` | | Declare a route | `@controller("/p")` on class, `@get("/x")` on method | | Extract URL param | `id: Path[int]` | | Extract query param | `q: Query[str] = QueryField(default="", ge=..., le=...)` | | Extract JSON body | `body: Json[MyPydanticModel]` | | Raise 404 in handler | `raise NotFoundError("...")` (built in); for a domain code, `class UserNotFound(NotFoundError): code = "user_not_found"`; declare it with `@raises(NotFoundError)` for OpenAPI | | Declare a success status | `@post("/", status_code=201)` or `-> Created[UserOut]` + `return Created(user, location=...)` | | Keep internal fields off the wire | annotate the public type and set `response_validation="filter"` (app or route) | | Add a guard to one route | `@use_guards(MyGuard)` on the method | | Add a guard to all routes on a controller | `@use_guards(MyGuard)` on the class | | Override JSON encoder for one route | `@use_encoder(OrjsonEncoder())` on the method | | Override JSON encoder for all routes on a controller | `@use_encoder(MsgspecEncoder())` on the class | | Read per-route metadata in a guard | `ctx.get_metadata("key", default)` | | Attach a singleton explicitly | `app.container.set_singleton(MyCls, instance)` | | Run post-construct hook | `@post_construct` on an `async def` method of an `@injectable` | | Run a long-lived loop for the app's lifetime | `@hosted_service()` class with `async def run(self, ctx)`; list it in `providers` | | Run code on a schedule | `@interval(seconds=60)` / `@cron("0 3 * * *")` on a singleton method + `ScheduleModule.for_root()` | | Run one job now (admin endpoint, test) | `await registry.trigger("name")` with `registry: SchedulerRegistry` | | Fail readiness before the server stops accepting traffic | `app.begin_draining()`, or `install_signal_handlers(app, shutdown_delay=10)` | | Expose liveness / readiness / startup probes | `@module(imports=[HealthModule.for_root()])` + `@health_check("db")` on a singleton method | | Let a global guard skip public routes | `if ctx.get_metadata(PUBLIC_METADATA_KEY, False): return True` | | Run code with the app's DI graph outside HTTP | `async with LaurenFactory.create_context(AppModule) as ctx:` then `ctx.get(...)` / `ctx.scope()` | | Admin / maintenance task | `@command("db:seed")` on a provider method; `python -m lauren exec app.main:AppModule db:seed` | | Test a command | `result = await ctx.run_command("db:seed", "--count", "3")` → `result.exit_code`, `result.stdout` | | Serve a file (auto MIME, streamed) | `return await Response.file("/path/to/file.pdf")` | | Serve a file with custom name | `return await Response.file(path, filename="report.pdf")` | | Serve a file inline (browser preview) | `return await Response.file(path, inline=True)` | | Serve XML | `return Response.xml("...")` | | Serve Server-Sent Events | `Response.sse(async_iterable_of_dicts)` | | Cache an expensive computation | `await cache.get_or_set(key, factory, ttl=60, tags=(...), type=T)` with `CacheModule.for_root()` imported | | Cache a GET response | `@cache_response(ttl=30, vary=(...), key=lambda ctx: ...)` on the handler | | Make a POST safe to retry | `@idempotent(ttl=86400)` (clients send `Idempotency-Key`) | | Share state across workers | one `Store` (`StoresModule.for_root(default=...)`) behind sessions and the cache | --- _End of reference._