Skip to content

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 RuntimeSettings object
  • 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 → calls entrypoints.api.create_app()
  • app/entrypoints/api/__init__.py — Creates the FastAPI app, sets up CORS, attaches JSON logging middleware, mounts routers, configures lifespan hooks
  • app/api/routes.py — Imports every router module and includes them under /api (with /v1 as a compatibility alias)
  • app/api/dependencies/container.py — Provides get_runtime_container FastAPI dependency
  • app/api/dependencies/current_user.py — Resolves CurrentUser from Bearer token via IdentityAdapter
  • app/composition/settings/RuntimeSettings loader: reads env vars, applies profile defaults, merges Secrets Manager values
  • app/composition/wiring/build_api_runtime(), build_scan_runner_runtime(), etc. — selects adapter implementations per settings
  • app/api/errors.py — Structured error envelope definition

Authentication

Every protected route declares a dependency on CurrentUser (from api/dependencies/current_user.py). This dependency:

  1. Reads the Authorization: Bearer <token> header
  2. Calls IdentityAdapter.validate_token(token):
  3. In custom auth mode (LocalIdentityAdapter): resolves local:{email} tokens; email prefix determines role (root, operator, customer)
  4. In cognito auth mode (CognitoIdentityAdapter): fetches the Cognito JWKS and validates the JWT signature, expiry, and claims; extracts user groups from the token
  5. 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": {}
  }
}
  • code is a stable machine-readable string (use it for branching in frontend code)
  • message is human-readable (show it in the UI)
  • retryable tells the client whether a retry makes sense (e.g., true for transient infrastructure errors)
  • details is optional structured context (e.g., which field failed validation)

Debugging a failed request

Start with the most external check and work inward:

  1. Did the request reach FastAPI? Check the API logs for the request line (JSON log with method, path, status_code).
  2. Did auth fail? A 401 with invalid_token code means the token is bad or missing. A 403 means the user lacks the required role/group.
  3. Did the use case fail? Look for 5xx errors and the code in the response body. Use cases raise typed exceptions that api/errors.py maps to HTTP responses.
  4. Did an adapter fail? Check Postgres connectivity (pg_isready), S3 reachability, SQS access. In AWS, check CloudWatch logs for the specific ECS container.
  5. Did the container wire correctly? If you see AttributeError or NoneType errors on use-case access, check composition/wiring/ — a port may not have been wired for the current runtime settings.