Troubleshooting¶
A symptom → likely cause → fix table for the failure modes teams hit most often, seeded from a
production-readiness review of this codebase. If you hit something not covered here, dbkit
check/dbkit pools (see cli.md) and the structured logs (§25.3) are the next place to look.
"Why didn't my write retry?"¶
Retries for writes are off by default and gated by two independent conditions — if either is missing, the write fails immediately instead of retrying:
RetryConfig.retry_writesmust beTrue(defaultFalse).- The specific
Querymust haveidempotent=True(or you passed anidempotent_override=Truefor that call).
This is intentional (§14) — dbkit never guesses whether a write is safe to repeat. Run
dbkit query-list to see which registered writes are marked idempotent, and whether dbkit's
best-effort heuristic thinks the SQL looks unguarded (a WARNING next to the query — see
docs/api/query.md and §3 below).
A DatabaseCommitUnknownError (or any error with transaction_state_unknown=True) is never
retried automatically, regardless of retry_writes/idempotent — the commit's outcome is
genuinely ambiguous, and retrying blind could duplicate a write that already landed. See the
next section.
"I got a DatabaseCommitUnknownError — what do I do?"¶
This means the connection failed during COMMIT and dbkit cannot tell whether the transaction
actually committed on the server before the failure (§15). Both db.transaction() and
db.execute()/db.insert_many()/etc. give this same guarantee.
- Do not blindly retry the same write — if it already committed, retrying would duplicate
it (unless the write itself is idempotent at the database level, e.g.
ON CONFLICT). - The safe pattern is to check whether the write already landed (e.g. query for the row by its natural/idempotency key) before deciding to retry or skip.
- For message-driven writes,
dbkit.integrations.ack_after_commitalready does the right thing: it never acks the broker message on a commit-unknown outcome, so the message redelivers and the transactional-inbox pattern (§28) dedupes safely on the next attempt.
"My query looks idempotent but dbkit query-list warns about it anyway"¶
The heuristic (_core/idempotency_lint.py) only recognizes a few explicit textual patterns
(ON CONFLICT, WHERE NOT EXISTS, MERGE) on INSERT statements — it cannot see your
schema's unique constraints. If your INSERT is genuinely safe to run twice (e.g. a unique
constraint on a natural key that would raise on the second attempt rather than duplicate data),
the warning is a false positive and safe to ignore. If it's not actually safe, this is exactly
the case the warning exists to catch: retrying a non-idempotent INSERT after a transient
failure (or a genuinely-unknown commit outcome) can silently duplicate the row.
Pool exhaustion / requests hanging¶
-
Symptom: requests intermittently hang or time out under load,
db_pool_wait_secondsis high, ordbkit poolsshowschecked_outnearsize + max_overflow. Cause: the pool is undersized for your concurrency, or connections are held longer than expected (checkdb_connection_hold_duration_seconds— seedocs/observability.md). Fix: raisePoolConfig.size/max_overflow, or find what's holding connections open (a missingawait, a long-running transaction, a stream not being closed). -
Symptom: a specific concurrency tier (
ConcurrencyConfig.reads/writes/bulk_writes) raisesDatabaseOverloadedErrorunder load. Cause: that tier's semaphore is saturated and no slot freed up within the operation's effective timeout — this is a deliberate bound (previously this queued forever with no signal; see the review's "Concurrency-limiter tiers" finding). Fix: raise the tier's limit, or reduce concurrent demand on it; the error is retryable, so a normal backoff/retry (respecting idempotency, as above) is appropriate.
Connection-budget surprises¶
-
Symptom: PostgreSQL starts rejecting connections (
too many clients already) under a fleet-wide traffic spike, even though a single instance looks fine. Cause: total connections across every pod × shard × replica × pool size can exceed PostgreSQL'smax_connectionseven when each individual pool looks small. Fix: rundbkit connection-budget config.yaml --replicas N(N = your pod/replica count) before a rollout, and setConnectionBudgetConfig.enforce_at_startup=Trueso a misconfiguration fails fast at startup instead of under load.dbkit check/config-validatenow also print a[WARNING]line wheneverenvironment != "development"and no budget is configured, or one is configured butenforce_at_startup=False— the same warning covers per-database budgets, not just the process-wide one. -
Symptom:
dbkit check/config-validateprints[WARNING] ... DSN has no explicit sslmode/ssl parameter. Cause: the target's URL has nosslmode/sslquery parameter at all — dbkit never enforces TLS itself, but flags a DSN that doesn't even state its intent outside development. Fix: add an explicit?sslmode=require(or stricter) to the DSN; this warning is purely informational and never fails the command, so it's safe to leave in place temporarily while you plan the change.
The .raw escape hatch bypasses error handling¶
- Symptom: a query run via
tx.raw/conn.rawraises a raw driver exception (e.g. a barepsycopg.errors.*) instead of aDatabaseErrorsubclass, and yourexcept DatabaseErrorhandler doesn't catch it. Cause:.rawis an explicit escape hatch to the underlying SQLAlchemy connection — by design, anything run through it skips classification, metrics, tracing, and retry/circuit- breaker handling entirely (seedocs/api/database.md). Fix: use the normaldb.execute/fetch_*/stream/bulk methods instead, unless you specifically need one of the two documented raw-driver escape hatches (tx.pipeline(),db.copy_records()), or intend to own error handling yourself for that statement.
PgBouncer¶
-
Symptom: prepared-statement errors or "prepared statement already exists" under PgBouncer. Cause: PgBouncer's transaction pooling mode can route the same logical connection to a different physical backend each transaction; driver-side prepared-statement autoprep (psycopg's
prepare_threshold, asyncpg'sstatement_cache_size) then targets the wrong backend. Fix: setPoolConfig.pgbouncer_compatible=True, which disables that autoprep. Verify withdbkit poolsor by checkingraw.driver_connection.prepare_threshold is Noneafter startup. -
Concern: "does disabling autoprep for PgBouncer compatibility cost meaningful latency?" Measured (
benchmarks/bench_pgbouncer_compatible.py, paced small reads, repeated on one connection, localhost): p50 latency withpgbouncer_compatible=TruevsFalsediffered by well under a tenth of a millisecond across repeated runs (e.g. +0.01ms to -0.05ms) — noise-level on a sub-millisecond query, not a meaningful cost. This measures the client-side autoprep-off cost directly (the setting only changes driver behavior); it does not model actual PgBouncer connection-routing overhead, which depends on your specific PgBouncer deployment.
asyncpg-specific¶
- Symptom:
COPY/tx.pipeline()raiseDatabaseUnsupportedOperationError, or a bare literal parameter (sql("SELECT :n")with no column context) fails with a asyncpgDataErrorabout an unexpected type. Cause: both are genuine asyncpg limitations, not dbkit bugs: COPY/pipeline mode are psycopg-only raw-driver escape hatches, and asyncpg requires explicit typing for parameters with no column to infer a type from (unlike psycopg). SeeREADME.md's compatibility note. Fix: use psycopg for COPY/pipeline mode; for untyped literal comparisons, add an explicitCAST(:n AS integer)(or the appropriate type) to the SQL text. - Symptom: the sync
Databasefacade doesn't work with an asyncpg DSN. Cause: asyncpg has no synchronous API at all — this is expected, not a bug (greenlet_ spawn has not been calledis the underlying SQLAlchemy error you'll see). Fix: useAsyncDatabasewith asyncpg, or use psycopg for the sync frontend.
Read-your-writes across threads¶
- Symptom: inside
db.consistency_scope(mode="read_your_writes"), a read issued from a worker thread (loop.run_in_executor,asyncio.to_thread, a plainthreading.Thread, or the syncDatabasefacade driven from a thread pool) doesn't see a write made earlier in the same scope — it still hits a replica. Cause: the override is acontextvars.ContextVar.asyncio.create_task()copies the current context, so a task created inside the scope inherits it — but a new OS thread does not automatically inherit the calling thread's/task's context at all (this is standardcontextvarsbehavior, not a dbkit limitation). Fix: capturecontextvars.copy_context()in the async caller before dispatching to the thread, and run the thread's work through that captured context (e.g.ctx.run(thread_fn, ...)instead of callingthread_fndirectly) — or simplest, just construct the read'sDatabaseTargetwithrole="write"/role="primary_only"explicitly for that specific read instead of relying on the scope.
Circuit breaker¶
- Symptom:
DatabaseCircuitOpenErrorraised immediately, without even attempting a connection. Cause: the breaker for thatdatabase/shard/roletripped after enough infrastructure-category failures (connection/pool/availability/timeout — never integrity/programming errors) withinwindow_seconds. Fix: checkdb_circuit_breaker_state(0=closed/1=half_open/2=open,docs/observability.md) and the underlying infra issue; the breaker will move to half-open and probe again afteropen_secondson its own — no manual reset needed.
Forcing fresh connections before a planned failover¶
- Need: before a planned topology change (e.g. promoting a new primary), force a specific
engine's pooled connections closed so the next call rebuilds fresh ones immediately, rather
than waiting for
PoolConfig.recycle_secondsto naturally cycle them out. Fix: callawait db.drain_engine(key)(both frontends), wherekeyis the string printed bydb.pool_status()/dbkit pools(e.g."prod:app:default:primary:psycopg"). Only idle pooled connections are closed — one already checked out by an in-flight call keeps working until released, the same guaranteeevict_lrurelies on. There is deliberately nodbkitCLI command for this. Each CLI invocation is a fresh, separate process with an empty engine registry of its own — it cannot reach into an already-running application's live engines. Calldrain_engine()from within the running process instead: wire it to aSIGHUPhandler, an admin HTTP route, or whatever your deployment already uses to trigger operational actions on a live instance.