Serving internals#
What a contributor needs to know about fastrag.serve beyond the wire
contract (HTTP API): the layout, the lock design, and
the model. fastrag.serve is the port of the pre-queue-4 CLI serving layer
onto the new QueryEngine + ChatStore design; the wire contract is
unchanged, so a client written against the old app works unmodified.
Layout#
Only five modules are public; everything else is _-prefixed and internal.
app.py—create_app, the app factory with no engine of its own.config.py—ServeConfig, frozen and validated.cli.py—CLI, the Typer client (HTTP API).cleanup.py—cleanup_expired, the batched expiry sweep._auth.py,_errors.py,_generation.py,_guards.py,_locking.py,_logging.py,_metrics.py,_net.py,_openapi.py,_rate_limit.py,_routes.py,_schemas.py,_shield.py, and_sse.pyare internal toserve._openapi.pyforce-publishes the SSE event payload schemas into the OpenAPI document’scomponents/schemas— used only byscripts/generate_openapi.py(task openapi), see Frontend types. The ULID minting_generation.pyuses for a stored assistant message id lives atfastrag._ids, shared withfastrag.query.chat(see Architecture’s “shared private layer”)._metrics.pyholdsServeMetrics(the app-ownedCollectorRegistryand its nine series) andMetricsMiddleware; both are wired up byapp.py, never imported by a caller directly — see Metrics._net.pyholdsresolve_client_ip(shared by the rate limiter and the session writer, so the two can never disagree about who a request is from). The optional GeoIP lookup is grouped by feature ingeolocalization/:resolver.pyholdsCountryResolver(built bycreate_app, closed by its lifespan) anddependencies.pytheget_country_resolverdependencyget_or_create_sessionreceives it through;_rate_limit.pyholds the sliding-windowRateLimiteritself, deliberately free of any import from_metrics.py,_errors.py, or_net.py—config.pyimports_rate_limit.parse_rateto validate its own rate-limit fields, and a_rate_limit → _metrics → configedge would make that an import cycle. See Admission: rate limiting and client IP/country for the user-facing behaviour both modules produce.
create_app(engine=, chat_store=, config=) — engine is a
QueryEngine. The app borrows the store’s engine (via the
PostgresChatStore.engine property) for the advisory lock and /healthz;
it creates no engine or session itself.
Design#
The session id is the bearer secret. There is no username or password; the session ULID is the credential, stored as the plain-text primary key and used as both the bearer token and the cookie value. Session tokens are deliberately not hashed.
Generations are serialized per chat with a session-level Postgres advisory lock, acquired before generate and released after; contention returns
409. A store at a non-default schema needs a schema-qualified variant beforecleanup_expiredwill sweep that schema.Regenerate/edit appends rather than truncates.
ChatStorehas no range-delete primitive, soPOST /v1/chats/{id}/messages/{mid}validates the request fully and returns the same SSE stream, but the chat stays linear and grows — seecli.py’s module docstring. Restoring true truncate needs aChatStoreprimitive first.Rate limiting is a dependency, not a middleware.
_guards.rate_limit(...)is a FastAPI dependency (dependencies=[Depends(rate_limit("generation"))]on the gated routes), not aBaseHTTPMiddlewarelikeRequestGuardMiddleware/MetricsMiddleware. It has to be: a dependency runs inside the router, after FastAPI’sExceptionMiddlewareis already wrapping the call, so raisingApiErrorthere is caught by the same registered handler every other request-time error goes through. ABaseHTTPMiddlewaresits outsideExceptionMiddlewarein Starlette’s stack — an exception raised directly from itsdispatch(as opposed to one raised inside thecall_nextit awaits) would bypass those handlers entirely and fall through to a generic 500, which is exactly whyRequestGuardMiddlewareabove builds itsJSONResponseby hand instead of raising. The one limit that isn’t a route-level dependency,rate_limit_new_session, is checked inline in_auth.get_or_create_sessionfor the same reasonenforce_content_lengthisn’t middleware either: it only applies to one branch of one function, not to a route as a whole.
See also#
Serving for the user’s door; Admission: rate limiting and client IP/country for the rate limiter and IP/country resolution this section describes the placement of; Architecture for where the package sits in the tree.