Request Lifecycle
This page traces what happens from the moment a browser sends an HTTP request to when it receives a JSON response. The hexagonal architecture means there is a consistent chain: router → use case → port → adapter. Understanding this makes it straightforward to know where to add new behavior and where to look when something breaks.
Request flow
sequenceDiagram
participant Browser
participant FastAPI as FastAPI (entrypoints/api)
participant Router as Route handler (api/routers/)
participant Container as RuntimeContainer (composition/)
participant UseCase as Application use case (application/)
participant Port as Port interface (ports/)
participant Adapter as Adapter impl (adapters/)
Browser->>FastAPI: HTTP request to /api/...
FastAPI->>FastAPI: CORS check, JSON logging middleware
FastAPI->>Router: Route matched
alt Protected endpoint
Router->>Router: get_runtime_container() → CurrentUser dependency
Router->>Router: Extract Bearer token from header
Router->>Router: IdentityAdapter.validate_token() (local or Cognito)
Router-->>Router: CurrentUser(user_id, groups, role)
end
Router->>Container: runtime.use_cases.*
Container->>UseCase: execute(*Command)
UseCase->>Port: port.method(...)
Port->>Adapter: concrete implementation
Adapter-->>Port: result
Port-->>UseCase: result
UseCase-->>Container: *Result
Container-->>Router: domain object or error
Router-->>Browser: JSON response (200) or error envelope (4xx/5xx)
Runtime container
The central concept in the request lifecycle is the RuntimeContainer (composition/container/). It is built once at startup by build_api_runtime() (in composition/wiring/) and wired into every request via the get_runtime_container FastAPI dependency.
The container holds:
- Settings — the parsed
RuntimeSettingsobject - Adapters — fully constructed adapter instances (Postgres, S3, SQS, Cognito, etc.)
- Use cases — pre-wired use-case instances with their port dependencies injected
Route handlers access use cases like:
runtime = Depends(get_runtime_container)
result = await runtime.use_cases.create_scan.execute(command)
This means route handlers never instantiate services directly, never import adapters, and never read settings — all of that is resolved by the composition layer at startup.
Key files
app/main.py— Compatibility shim → callsentrypoints.api.create_app()app/entrypoints/api/__init__.py— Creates the FastAPI app, sets up CORS, attaches JSON logging middleware, mounts routers, configures lifespan hooksapp/api/routes.py— Imports every router module and includes them under/api(with/v1as a compatibility alias)app/api/dependencies/container.py— Providesget_runtime_containerFastAPI dependencyapp/api/dependencies/current_user.py— ResolvesCurrentUserfrom Bearer token viaIdentityAdapterapp/composition/settings/—RuntimeSettingsloader: reads env vars, applies profile defaults, merges Secrets Manager valuesapp/composition/wiring/—build_api_runtime(),build_scan_runner_runtime(), etc. — selects adapter implementations per settingsapp/api/errors.py— Structured error envelope definition
Authentication
Every protected route declares a dependency on CurrentUser (from api/dependencies/current_user.py). This dependency:
- Reads the
Authorization: Bearer <token>header - Calls
IdentityAdapter.validate_token(token): - In
customauth mode (LocalIdentityAdapter): resolveslocal:{email}tokens; email prefix determines role (root,operator,customer) - In
cognitoauth mode (CognitoIdentityAdapter): fetches the Cognito JWKS and validates the JWT signature, expiry, and claims; extracts user groups from the token - Returns a
CurrentUser(user_id, groups, role)object
If the token is missing or invalid, FastAPI returns 401 before the route handler runs.
Some routes additionally check CurrentUser.role against operator or root. These checks happen inside the route handler using helpers from api/dependencies/request_context.py.
Error shape
All errors, regardless of where they occur, return the same JSON envelope:
{
"schema_version": 1,
"error": {
"code": "not_found",
"message": "Repository abc123 not found",
"retryable": false,
"details": {}
}
}
codeis a stable machine-readable string (use it for branching in frontend code)messageis human-readable (show it in the UI)retryabletells the client whether a retry makes sense (e.g.,truefor transient infrastructure errors)detailsis optional structured context (e.g., which field failed validation)
Debugging a failed request
Start with the most external check and work inward:
- Did the request reach FastAPI? Check the API logs for the request line (JSON log with
method,path,status_code). - Did auth fail? A
401withinvalid_tokencode means the token is bad or missing. A403means the user lacks the required role/group. - Did the use case fail? Look for
5xxerrors and thecodein the response body. Use cases raise typed exceptions thatapi/errors.pymaps to HTTP responses. - Did an adapter fail? Check Postgres connectivity (
pg_isready), S3 reachability, SQS access. In AWS, check CloudWatch logs for the specific ECS container. - Did the container wire correctly? If you see
AttributeErrororNoneTypeerrors on use-case access, checkcomposition/wiring/— a port may not have been wired for the current runtime settings.