Error Handling

Saferis uses a principled error type hierarchy defined in SaferisError.scala that enables proper pattern matching and eliminates unsafe casting. All database operations return ZIO effects with SaferisError as the error type.

Error Categories

Error TypeWhen It Occurs
UniqueViolationUnique violation. Message and SQLSTATE stay as sent (23505 on Postgres, 23000 on MySQL).
ForeignKeyViolationForeign key (23503 on Postgres).
NotNullViolationNot null (23502 on Postgres).
CheckViolationCheck (23514 on Postgres). The message stays the server's sentence.
DeadlockThe driver named a deadlock (Postgres 40P01, MySQL errno 1213).
SerializationFailure40001, or SQLite busy and locked.
SyntaxErrorClass 42.
UndefinedTablePostgres 42P01.
UndefinedColumnPostgres 42703.
DataErrorClass 22.
AbortedThe server reported an aborted transaction (Postgres 25P02).
QueryErrorA server error with no named condition.
TimeoutThe driver canceled the statement, or Postgres 57014. See Statement Timeouts
ShutdownPostgres 57P01, 57P02, or 57P03.
RetryableSqlCondition.Other that the vendor hook marked transient. See Retryable Errors
ConnectionLostClass 08, or a socket failure. A socket has no SQLSTATE.
ConnectionErrorCannot acquire a connection, or configure threw
DecodingErrorCannot decode a column value to the expected Scala type
EncodingErrorCannot encode a parameter value for the prepared statement
ReturningOperationFailedINSERT/UPDATE/DELETE RETURNING returned no rows
SchemaValidationSchema verification found mismatches (see Schema Validation)
UnexpectedNon-SQL errors (wrapped in Unexpected)

SQL Error Classification

The case is the condition. The message and the SQLSTATE are what the server sent. The cases do not carry a Throwable.

What the server sentError
23505UniqueViolation
23503ForeignKeyViolation
23502NotNullViolation
23514CheckViolation
40001SerializationFailure
class 08ConnectionLost
class 42SyntaxError
class 22DataError
Postgres 40P01Deadlock
Postgres 25P02Aborted
Postgres 57014Timeout
Postgres 42P01UndefinedTable
Postgres 42703UndefinedColumn
Postgres 57P01, 57P02, 57P03Shutdown
otherQueryError, or Retryable when the vendor hook matches SqlCondition.Other

Pattern Matching on Errors

Use pattern matching for type-safe error handling:

(for
  _      <- ddl.createTable[ErrorUser](ifNotExists = true)
  _      <- dml.insert(ErrorUser(-1, "alice@example.com", "Alice"))
  _      <- dml.insert(ErrorUser(-1, "bob@example.com", "Bob"))
  result <- sql"SELECT * FROM ${Table[ErrorUser]} WHERE ${Table[ErrorUser].email} = ${"alice@example.com"}"
    .queryOne[ErrorUser]
    .either
yield result match
  case Left(SaferisError.DecodingError(col, expected, _)) =>
    s"Failed to decode column '$col' as $expected"
  case Left(error) =>
    s"Other error: ${error.message}"
  case Right(user) =>
    s"Found user: ${user.map(_.name).getOrElse("none")}"
).either
  .provideLayer(DocsTransactor.layer)
Right(Found user: Alice)

Handling Constraint Violations

{
  val schema = Schema[UniqueEmail].withUniqueConstraint(_.email).build
  (for
    _ <- ddl.createTable(schema)
    _ <- dml.insert(UniqueEmail(-1, "alice@example.com"))
    // Try to insert duplicate email
    result <- dml.insert(UniqueEmail(-1, "alice@example.com")).either
  yield result match
    case Left(SaferisError.UniqueViolation(detail)) =>
      s"Unique violation: ${detail.constraint.getOrElse("unknown")}"
    case Left(error) =>
      s"Other error: ${error.message}"
    case Right(_) =>
      "Insert succeeded"
  ).either
    .provideLayer(DocsTransactor.layer)
}
Right(Unique violation: uq_email)

What the violation looks like unhandled

The previous example caught the error with .either. If you don't recover, the constraint violation propagates as a real failure; the actual SaferisError is shown below the snippet:

(for
  _ <- ddl.createTable[CrashKey](ifNotExists = true)
  _ <- dml.insert(CrashKey(1, "first"))
  _ <- dml.insert(CrashKey(1, "duplicate")) // duplicate primary key → constraint violation
yield ())
  .provideLayer(DocsTransactor.layer)
saferis.SaferisError$UniqueViolation: UniqueViolation(ServerDetail(ERROR: duplicate key value violates unique constraint "error_handling_crash_keys_pkey"
  Detail: Key (id)=(1) already exists.,Some(insert into "error_handling_crash_keys" ("id", "label") values (?, ?)),Some(23505),Some(0),Some(error_handling_crash_keys_pkey)))
	at saferis.jdbc.JdbcConnection.execute(JdbcSession.scala:87)

Classifying a SQLSTATE

Drivers call SqlState.classify. Application code matches the case. There is no fromThrowable.

{
  val unique = SqlState.classify(
    ServerError(
      SqlCondition.Unique(Some(ConstraintName("users_email_key"))),
      "duplicate key",
      Some(SqlState.UniqueViolation),
    ),
    Some(SqlText("insert into users values ($1)")),
    _ => false,
  )
  val other = SqlState.classify(ServerError(SqlCondition.Other, "something went wrong"), None, _ => false)
  (unique, other)
}
(UniqueViolation(ServerDetail(duplicate key,Some(insert into users values ($1)),Some(23505),None,Some(users_email_key))),QueryError(ServerDetail(something went wrong,None,None,None,None)))

Accessing Error Details

Each error type provides relevant details:

{
  def logError(error: SaferisError): String = error match
    case e: SaferisError.UniqueViolation =>
      s"Unique ${e.detail.constraint.getOrElse("constraint")} violated. SQL: ${e.detail.sql.getOrElse("N/A")}"
    case e: SaferisError.ForeignKeyViolation =>
      s"Foreign key violated. SQL: ${e.detail.sql.getOrElse("N/A")}"
    case e: SaferisError.SyntaxError =>
      s"Syntax error: ${e.message}. SQL: ${e.detail.sql.getOrElse("N/A")}"
    case e: SaferisError.DecodingError =>
      s"Failed to decode column '${e.columnName}' as ${e.expectedType}"
    case e: SaferisError.SchemaValidation =>
      s"Schema issues:\n${e.issues.map(_.description).mkString("\n")}"
    case e =>
      e.message

  val sample = SqlState.classify(
    ServerError(
      SqlCondition.Unique(Some(ConstraintName("users_email_key"))),
      "duplicate key",
      Some(SqlState.UniqueViolation),
    ),
    Some(SqlText("insert into users values ($1)")),
    _ => false,
  )
  logError(sample)
}
Unique users_email_key violated. SQL: insert into users values ($1)