Skip to main content

Error Codes

ScramDB speaks the PostgreSQL wire protocol and PostgreSQL's own SQLSTATE codes, so most of a client's cluster error handling is code it already has. The whole cluster failure surface is four SQLSTATEs, produced by one function in the engine, plus one connection-level case that carries no SQLSTATE at all because no server ever got the chance to send one. 40001 is the same serialization_failure every PostgreSQL driver and ORM already retries; clustering just adds new reasons the cluster can raise it. 57P01 and 57P03 are PostgreSQL's own admin_shutdown and cannot_connect_now, both plain reconnect cases. The one genuinely new thing a distributed engine has to hand a client is 08007: an outcome that is honestly unresolved rather than known-good or known-bad. This page exists mainly to make that one case impossible to get wrong.

One function, classify, owns this entire mapping. Every cluster-shaped failure in the engine's driver layer is matched there explicitly; a new failure variant does not compile until it has a contracted code, and nothing cluster-shaped falls through to a generic internal error. Read the table below once and a retry loop built from it needs no other input.

The master table​

SQLSTATEPostgreSQL nameRetry classWhat actually happenedWhat the client must do
40001serialization_failureDefiniteThe transaction left no effect anywhere in the cluster.Re-execute the transaction. Safe unconditionally.
08007transaction_resolution_unknownInDoubtThe write was accepted and may still commit; its outcome was never observed.Reconnect, verify whether it applied, and retry only if the statement is idempotent.
57P01admin_shutdownUnavailableThis node is draining or shutting down; the statement never reached the cluster.Reconnect to a different node.
57P03cannot_connect_nowUnavailableThe cluster has not finished forming yet; the statement never reached it.Back off and reconnect.
(none)driver-level connection errorUnavailable, handled like 57P01The connection died before any server could answer. Nothing was decided.Reconnect to a different node and re-execute.
40P01deadlock_detectedDefiniteTwo or more transactions on different nodes each waited on a lock the other held. ScramDB detected the cycle cluster-wide and aborted one of them; the aborted transaction left no effect.Re-execute the aborted transaction.
42704undefined_objectnot a cluster failureSHOW named a configuration parameter that does not exist.Fix the parameter name. Retrying will not help.

Three retry classes cover the whole table:

  • Definite: the transaction provably left no effect. A blind re-execute is unconditionally safe, exactly what PostgreSQL's own 40001 already trains every ORM to do.
  • InDoubt: the write may still commit. It was accepted somewhere and its outcome was never confirmed, so re-executing blindly risks applying it twice.
  • Unavailable: the statement never entered the cluster at all. Nothing is in doubt because nothing was ever attempted; the right move is to reconnect, possibly elsewhere, rather than retry in place.

How the contract holds together​

One function, no exceptions​

Every cluster-shaped failure the engine's driver layer can produce is classified by one function. It matches each failure case explicitly: add a new one and the match stops compiling until it is given a contracted code. That is deliberate, and it is the only mechanism that keeps a cluster failure from reaching the generic XX000 internal-error fallback or getting a SQLSTATE invented for it at the call site that happened to hit it.

The engine holds itself to the same rule internally. Its own bounded internal retries, for instance a bulk COPY riding out a leader election mid-load, only ever fire for a Definite failure, never for an in-doubt one; retrying an in-doubt failure inside the engine would double-apply exactly as a client's blind retry would.

08007 is never folded into 40001

InDoubt and Definite are never merged, and no code path collapses one into the other. 40001 means the transaction provably left no effect: re-executing it is always safe. 08007 means the opposite is possible: the write was accepted and may already have committed. Treating an in-doubt outcome as a definite one and blindly retrying is exactly how a non-idempotent write gets applied twice. This is the one distinction on this page that actually matters; the rest is bookkeeping.

Do not parse the message to decide whether to retry​

The message text always names the real cause, even when several causes share one SQLSTATE, so a log line still tells a human whether a given 40001 was a write conflict, an election, or a stale schema epoch. It is not, however, a stable API: retry logic should switch on the SQLSTATE alone. If a situation ever exists where the SQLSTATE is not enough to decide correctly, that is a bug in ScramDB worth reporting, not a reason to start matching strings.

Where the hint text actually is​

Every cluster failure carries a one-sentence hint alongside its message ("re-execute it", "reconnect and verify", and so on). On the wire that hint is not a separate structured field: it arrives appended to the message text itself, as a trailing HINT: <text> line under the same SQLSTATE. A client that only reads the raw message string still gets the hint; there is nowhere else to look for it.

SQLSTATE 40001: serialization_failure​

Hint, fixed for every cause below: the transaction left no effect; re-execute it

Every one of these is Definite: whatever happened, it happened before anything committed, so re-executing the transaction from the start is always correct. The contract folds six distinct causes into this one code; the message always says which one it was.

CauseMessage shape
Write conflict on a shardwrite conflict on shard {group}: {detail}; transaction rolled back everywhere, retry
Serialization failure on a shard (a read observed a since-committed conflicting write)serialization failure on shard {group}: {detail}; retry the transaction (statements must re-execute so reads observe the conflicting commit)
No reachable leader for a shard groupno reachable leader for shard group {group} after {attempts} attempts (last: {last}) (no proposal was accepted, so this transaction left no effect)
A proposal rejected before it entered the logpropose to {group} failed: {detail} (the proposal was rejected before it entered the log)
Timestamp allocation failedtimestamp allocation failed: {detail} (allocated before any write was proposed, so nothing applied)
Stale schema epoch (the statement planned against a superseded catalog)schema changed while this statement was planning ({detail}); re-execute so it re-plans against the current catalog

{group} is a Debug-formatted shard group id, for example GroupId(7); {detail}, {attempts}, and {last} are filled in with the specifics of what actually happened. None of that changes the SQLSTATE or the hint, only the message.

A refused staleness bound does not carry 40001 today

The error contract defines a seventh 40001 cause, a bounded-staleness read whose budget could not be met, and it is unit-tested. It is not currently reachable: no live code path constructs it. A SET learner_read = stale read whose replica has fallen past the bound is refused by the read path itself, with a message naming the group and how far behind it is, and that message carries no SQLSTATE of its own. It therefore reaches the client as XX000 (internal_error).

The refusal itself is correct and is the behavior you want: the read fails loudly rather than being served from a replica that is too far behind. Only its classification is wrong. Until that is wired up, do not write a client branch that expects 40001 here, and do not treat every XX000 from a stale-read pool as an engine bug. See Read freshness and routing for the behavior in full.

SQLSTATE 08007: transaction_resolution_unknown​

Hint, fixed for every cause below: the transaction's outcome is UNKNOWN and it may still commit; reconnect and verify before any retry - a blind retry can double-apply

Every one of these is InDoubt. In every case a write was accepted into a shard's log, and this connection lost the ability to confirm what happened to it next.

CauseMessage shape
Leadership changed after the proposal was appended but before its outcome was observedproposal to {group} was appended but leadership changed before its outcome was observed; it may still commit
The outcome could not be observed at all (a timeout, or the wait expired)outcome for {group} could not be observed ({detail}); the transaction may still commit
A secondary shard disagreed with the primary after the commit pointcommit point passed but shard {group} reported {detail} (start_ts {start_ts}); cluster recovery is resolving this transaction
The connection to the shard leader was lost after the commit point of no returnconnection to the shard leader was lost after the commit point of no return ({detail})

For a non-idempotent effect (a payment, an email, a call to an external API), the correct response to 08007 is never a bare retry counter: reconnect, verify whether the write actually landed, and only replay it if it is safe to apply twice or is itself protected by a dedupe key.

SQLSTATE 57P01: admin_shutdown​

Hint: this node is shutting down; reconnect to another node

One cause: the node the client was talking to is draining, whether from an operator-initiated drain or an ordinary shutdown. The statement never reached the cluster, so nothing needs undoing; the fix is to reconnect somewhere else. Message shape: node is draining: {detail}. See Failover for what a client experiences end to end when a node goes away.

SQLSTATE 57P03: cannot_connect_now​

Hint: the cluster is not serving yet; back off and reconnect

One cause: the cluster, specifically its own metadata group, has not finished forming yet. This is a startup-time condition, not something a running cluster produces once it is up. Message shape: cluster is not ready: {detail}.

No SQLSTATE: the connection just died​

A dead socket is not a database error and carries no SQLSTATE, because no server ever got the chance to send one. The client's driver raises its own transport-level exception instead. This is, in practice, the single most common thing a client sees when a node is killed: a node that dies closes its sockets before it can answer anything.

Nothing was decided. Treat it exactly like 57P01: reconnect to a different node and re-execute. A retry loop written only against the SQLSTATE table above, and not against this case, is the retry loop most likely to be wrong in production, because it is the one case with nothing to switch on.

SQLSTATE 40P01: deadlock_detected, across nodes​

A deadlock in a single-node database is a cycle in one lock table. In a cluster the cycle can span nodes: transaction A on node 1 waits for a row locked by transaction B on node 2, which waits for a row locked by A. No single node's lock table can see that cycle, so without cluster-wide detection both transactions would wait until their lock timeouts expired, which reads to a client as an unexplained stall rather than a deadlock.

ScramDB registers every refused blocking lock acquisition as a wait edge in the replicated metadata group, so the whole waits-for graph is visible in one place. When a cycle appears, one transaction is chosen as the victim deterministically (every node picks the same one from the same graph, so the decision needs no extra round of agreement) and aborted with 40P01. The survivors proceed immediately rather than waiting out a timeout.

Wait edges are cleared when the lock is granted, and expire on their own if the waiting node disappears, so a dead node cannot leave a phantom edge that deadlocks live traffic.

Treat 40P01 exactly as you treat it in PostgreSQL: the victim left no effect, so re-execute it. If you see it often, the fix is lock-ordering discipline in the application, not a retry loop.

SQLSTATE 42704: undefined_object, from an unknown SHOW parameter​

This one is not a cluster failure, is not retryable, and is not covered by classify; it belongs on this page because it is a case an AI coding agent probing session settings programmatically will hit. SHOW <parameter> against a name the session does not recognize returns 42704 with the message unrecognized configuration parameter "{parameter}" and no hint text.

This is deliberately not an empty string. Returning "" for an unknown parameter is the less compatible behavior, not the more forgiving one: a caller that asks for a setting and gets an empty value back may read that as a real, valid answer and act on it, silently. Every client that already targets PostgreSQL already knows how to handle 42704; returning it is what makes an unrecognized parameter unambiguous instead of quietly wrong.

This contract is tested, not just asserted​

The mapping above is pinned down by tests that fail if any code or class in this table drifts: every failure case is checked against its exact contracted SQLSTATE and retry class (every_driver_error_maps_to_its_contracted_sqlstate), an in-doubt failure is asserted to never carry 40001 and to always describe its outcome as unresolved (in_doubt_is_never_folded_into_serialization_failure), and the availability and default codes are checked against this same table (availability_failures_use_availability_sqlstates, class_default_sqlstates_match_the_table). If this page and the engine's behavior ever disagree, that is a bug in the engine, not a stale doc.