Retryable Errors

Some database failures are transient and worth retrying: connection loss, deadlocks, serialization failures, and vendor transport errors. Those are distinct cases. A vendor hook does not rename them.

Named transient states

SqlState.classify turns a SqlCondition into a case. The message and SQLSTATE stay the ones on the ServerError. A vendor hook does not rename a condition that already has a case:

  • Connection (class 08): ConnectionLost

  • Serialization (40001): SerializationFailure

  • Deadlock (Postgres 40P01, MySQL errno 1213): Deadlock

  • Canceled (a driver statement timeout, or Postgres 57014): Timeout

  • Unique (23505): UniqueViolation. The message stays the server's sentence.

  • Syntax (class 42): SyntaxError, even when the vendor hook returns true

SqlState.defaultRetryable is true for Connection, Serialization, and Deadlock. The JDBC session uses that as the default JdbcSessionConfig.retry hook. The hook only fills SqlCondition.Other. A match becomes Retryable.

Driving retries with ZIO

{
  def reportWithRetry(session: SqlSession) =
    (sql"SELECT * FROM ${Table[User]}"
      .query[User])
      .retry(
        Schedule.recurs(3) && Schedule.exponential(100.millis) && Schedule.recurWhile[SaferisError](worthRetry)
      )
  reportWithRetry
}
saferis.docs.RetryableErrors$$$Lambda/0x00000000881aa448@3e92676a

Supplying a vendor hook

Drivers that tunnel over HTTP (Databricks, Snowflake) can surface transport errors as vendor-specific codes. Put a hook on JdbcSessionConfig.retry. It does not replace the named states above:

{
  val databricks: ServerError => Boolean =
    e => e.vendorCode.contains(8000)

  val session = PostgresJdbc.layer(JdbcSessionConfig(retry = databricks))
  val vendor  = SqlState.classify(
    ServerError(SqlCondition.Other, "http blip", vendorCode = Some(8000)),
    Some(SqlText("select 1")),
    databricks,
  )
  (vendor, session)
}
(Retryable(ServerDetail(http blip,Some(select 1),None,Some(8000),None)),Suspend(zio.ZLayer$$$Lambda/0x00000000880afa40@30fa671f))

Named states stay named. The hook only classifies what SqlState.classify would otherwise call QueryError.

When *not* to retry

SaferisError.Retryable only signals that an error might be safe to retry; your application still owns the semantics:

  • For read-only queries, retrying is generally safe.

  • For writes, retry only if the operation is idempotent (e.g., upsert by primary key, set-to-fixed-value updates) or if the failure happened before any partial state could be observed.

  • For transactions, the whole transaction must be re-attempted; a single statement retry inside a failed transaction is meaningless.