Skip to content

Configuration

Configuration comes from environment variables. Every variable is prefixed APP_, and nested settings use a double underscore: APP_LOG__LEVEL fills settings.log.level. One flat environment therefore produces a structured object.

For local work, copy the example file and edit it:

cp .env.example .env

.env is never committed. .env.example is the documented default.

Variables

This table is generated from the settings model. To change a description, edit Field(description=...) in src/reference_service/settings.py and run just config-docs.

Variable Type Default Meaning
APP_ENVIRONMENT local | staging | production local local prints colourised, human-readable logs. Anything else prints one JSON object per line.
APP_SERVICE_NAME string reference-service The OpenAPI document's title, and the service.name field on every log record.
APP_HTTP_PORT integer, 1–65535 8000 The port to serve on. Read by just dev, by the container's start command, and by the image's health check.
APP_LOG__LEVEL debug | info | warning | error | critical info The root log level.
APP_LOG__LEVELS JSON object {} Per-logger overrides, as JSON. Silencing a chatty library is configuration, not a code change.
APP_LOG__REDACT_FIELDS JSON array ["access_token","api_key","apikey","authorization","card_number","cookie","cvv","passwd","password","refresh_token","secret","secret_access_key","secret_key","set_cookie","token"] Field names whose values are replaced by [REDACTED] before a record is rendered, as a JSON array. Matched by exact name at any depth, ignoring case and treating - and _ alike. Setting this REPLACES the default list rather than adding to it.
APP_OTEL__ENABLED boolean false Turn on traces and metrics. Off by default: with it off the process builds no providers, opens no socket and starts no background task.
APP_OTEL__LOGS_ENABLED boolean false Export logs over OTLP in addition to standard output. Requires APP_OTEL__ENABLED.
APP_OTEL__ENDPOINT string unset Where traces and metrics go, over OTLP/gRPC. Required when APP_OTEL__ENABLED is true — enabling the SDK with nowhere to send data stops the process at startup rather than dropping every span from a background thread.
APP_OTEL__SAMPLE_RATIO float, 0.0–1.0 1.0 Fraction of new traces recorded, 0.0 to 1.0. Sampling is parent-based, so a request arriving with a sampled parent is always recorded whatever this says.
APP_OTEL__METRIC_EXPORT_INTERVAL_MS integer, ≥ 1000 60000 How often metrics are pushed. just o11y lowers it to 10000 so panels move while you watch.
APP_DATABASE__DSN PostgreSQL URL unset, required once any APP_DATABASE__* is set Where to store orders. Leave it unset to run with no database at all — the service starts on an in-memory repository and serves normally.
APP_DATABASE__POOL_SIZE integer, ≥ 1 10 Connections held open to PostgreSQL. This is a true ceiling: max_overflow is pinned to 0, so an eleventh concurrent checkout waits rather than opening a further connection, and gives up after SQLAlchemy's 30-second pool_timeout.
APP_DATABASE__STATEMENT_TIMEOUT_MS integer, ≥ 0 5000 Applied by the server per connection. A statement running longer is cancelled, so one pathological query cannot hold a pooled connection indefinitely.
APP_PAYMENT__BASE_URL URL unset, required once any APP_PAYMENT__* is set The payment provider's base URL. Leave it unset to run on the in-memory gateway, which authorises everything. just up points it at a local stub.
APP_PAYMENT__API_KEY secret unset Sent as a bearer token. SecretStr, so it cannot reach a log line or a traceback by accident — it always prints as a row of asterisks, never the real value.
APP_PAYMENT__RETRY_ATTEMPTS integer, ≥ 1 3 Attempts, not retries: 3 means one call and two further tries.
APP_PAYMENT__RETRY_INITIAL_WAIT_SECONDS float, > 0 0.1 Wait before the first retry. Backs off from here.
APP_PAYMENT__RETRY_MAX_WAIT_SECONDS float, > 0 2.0 The backoff's ceiling before jitter. stamina adds up to a further second of random jitter on top (wait_jitter, fixed, not configured by this variable), and the whole retry loop separately stops at a fixed 45-second wall-clock budget regardless of this value — see the payment gateway's infrastructure/http/payment_gateway.py.
APP_PAYMENT__BREAKER_FAILURE_THRESHOLD integer, ≥ 1 5 Consecutive failures before the circuit opens.
APP_PAYMENT__BREAKER_RESET_AFTER_SECONDS float, > 0 30.0 How long the circuit stays open before admitting one probe.
APP_PAYMENT__HTTP__CONNECT_TIMEOUT_SECONDS float, > 0 2.0 How long to wait for the TCP/TLS handshake.
APP_PAYMENT__HTTP__READ_TIMEOUT_SECONDS float, > 0 5.0 How long to wait for a response once the request is sent. Never retried on expiry — see Outbound HTTP calls.
APP_PAYMENT__HTTP__WRITE_TIMEOUT_SECONDS float, > 0 5.0 How long to wait while sending the request body.
APP_PAYMENT__HTTP__POOL_TIMEOUT_SECONDS float, > 0 1.0 How long to wait for a free connection from this client's own pool, before any socket to the gateway opens.
APP_PAYMENT__HTTP__MAX_CONNECTIONS integer, ≥ 1 20 The connection pool's ceiling.
APP_PAYMENT__HTTP__MAX_KEEPALIVE_CONNECTIONS integer, ≥ 0 10 Idle connections kept open for reuse.
APP_CACHE__DSN Redis URL unset, required once any APP_CACHE__* is set Where the order cache lives. Leave it unset to run with no cache at all — the service reads and writes the order repository directly. See CachedOrderRepository.
APP_CACHE__TTL_SECONDS integer, ≥ 1 300 How long a cached order stays valid. A safety net, not the primary invalidation path — saving an order deletes its cache entry outright; the TTL only bounds how stale a value can get if that delete itself fails.
APP_CACHE__POOL_SIZE integer, ≥ 1 10 Connections held open to Redis.
APP_CACHE__CONNECT_TIMEOUT_SECONDS float, > 0 0.5 How long to wait for the connection to Redis. Deliberately sub-second: a cache that can stall a request for longer than the database query it is trying to avoid has made the service slower than having no cache. redis-py treats 0 as "wait forever", not "give up immediately", which is why this is > 0 rather than ≥ 0.
APP_CACHE__OPERATION_TIMEOUT_SECONDS float, > 0 0.5 How long to wait for a single Redis command. Same reasoning as the connect timeout, and the same redis-py 0-means-forever trap.
APP_STORAGE__BUCKET string, 3–63 chars unset, required once any APP_STORAGE__* is set The S3 bucket receipts are stored in. Checked against Amazon's naming rule (lowercase, digits, hyphens, dots) at startup, so a typo like a capital letter fails as exit 78, not as the first failed PutObject.
APP_STORAGE__ENDPOINT_URL URL unset Leave it unset for real Amazon S3 — botocore derives the endpoint from the region on its own. Set it for everything else — MinIO, Cloudflare R2, Ceph, or any other S3-compatible provider. This one field is the whole of "one adapter for every provider": there is no provider branch anywhere in S3ReceiptStore.
APP_STORAGE__REGION string us-east-1 Required by botocore's request signing even against a provider, like MinIO, that ignores it. us-east-1 is the conventional filler value.
APP_STORAGE__ACCESS_KEY_ID secret unset, required once any APP_STORAGE__* is set SecretStr, so it cannot reach a log line or a traceback by accident.
APP_STORAGE__SECRET_ACCESS_KEY secret unset, required once any APP_STORAGE__* is set SecretStr, same reasoning.
APP_STORAGE__CONNECT_TIMEOUT_SECONDS float, > 0 2.0 How long to wait for the connection to the storage endpoint.
APP_STORAGE__READ_TIMEOUT_SECONDS float, > 0 5.0 How long to wait for a response once the request is sent.

Both settings are optional, and absent is a supported configuration, not a broken one. Leave APP_CACHE__DSN unset and the service serves every order read straight from PostgreSQL, exactly as if the cache decorator were never wrapped around the repository. Leave APP_STORAGE__BUCKET (and the rest of the storage block) unset and GET /orders/{id}/receipt serves from an in-memory store that vanishes on restart, instead of 503ing. Neither gap shows up on /readyz as a failure — see the readiness change.

The database URL carries no driver and no sslmode

Two things that look like omissions are deliberate, and the service refuses to start if either is wrong.

No driver suffix. Write postgresql://…, not postgresql+asyncpg://…. The engine adds the asyncpg driver itself when it builds the connection pool.

No sslmode. It is a libpq parameter that asyncpg does not understand, so it would fail at the first connection rather than at startup. The migration container needs ?sslmode=disable against a local server, and adds it to its own URL in compose.yaml — a connection string this application never reads.

Both are rejected during settings validation, which means exit 78 and a message naming the field, not a traceback from inside a driver an hour later.

APP_LOG__LEVELS takes a JSON object, quoted so the shell does not split it:

APP_LOG__LEVELS='{"httpx": "warning", "uvicorn.error": "warning"}'

APP_LOG__REDACT_FIELDS takes a JSON array the same way, and replaces the default list rather than adding to it — copy the default from the table above and add your own names to it:

APP_LOG__REDACT_FIELDS='["password","token","authorization","pin","otp"]'

Matching is by field name only, at any depth, ignoring case and treating - and _ alike. See Logging for what that does and does not cover.

APP_OTEL__LOGS_ENABLED doubles your log bill

Standard output is the source of truth for logs. Most platforms already run an agent that reads a container's standard output and forwards it. Turning on log export as well sends every record twice — once through the agent, once over the network — doubling both ingest volume and cost.

It is off by default and should stay off in production. It exists for local work, where trace-to-log correlation in Grafana is worth having and there is no agent.

Bad configuration stops the process

Settings are built once at startup and validated then. A missing or malformed value prints a readable message to standard error and exits with code 78:

Invalid configuration:
  http_port: Input should be less than or equal to 65535 (less_than_equal)

Each line names the setting, what is wrong with it, and the rule that rejected it. The offending value is deliberately not echoed. Pydantic's own rendering includes it, and for APP_DATABASE__DSN that value is a connection string with a password in it — a misconfiguration would otherwise write the database password to the startup logs. The trade-off is real and applies to every setting: a typo in APP_HTTP_PORT is now named but not shown. A blanket rule cannot be forgotten the way a list of "settings that hold secrets" can.

78 is EX_CONFIG from sysexits.h, the conventional Unix exit code for a configuration error. An orchestrator can tell "this was misconfigured" apart from "this crashed".

The alternative — accepting a bad value and failing later — turns a typo into a 500 response an hour after deployment, at which point nobody connects the two. Failing at startup means a bad deployment never receives traffic.

This is why APP_LOG__LEVEL is a fixed set of five values rather than a plain string. APP_LOG__LEVEL=verbose as a plain string would pass validation and then raise ValueError: Unknown level: 'VERBOSE' deep inside logging setup — a crash, in place of the readable exit-78 message every other bad value produces.

Settings are frozen, with one gap

Settings and its sub-models are frozen: assigning to a field after startup raises rather than silently changing behaviour under a running server.

One gap remains, and it is documented rather than hidden. APP_LOG__LEVELS holds an ordinary Python dictionary. Freezing refuses to replace the dictionary, but the dictionary object itself is still mutable, so settings.log.levels["x"] = "debug" succeeds. Nothing in the code does this. It is recorded here because a claim that settings are simply "frozen" would promise more than the code delivers.

One more gap worth knowing

Unknown keys in a .env file are rejected. An unknown variable set directly in the process environment — APP_SOMETHING_UNKNOWN=1 — is silently ignored.

The two sources are treated differently by the settings library, and there is no option that closes the environment-variable half. So a typo in a deployment manifest will not be caught: the variable is ignored and the default is used. Prefer changing .env.example and keeping deployment variables reviewed.