Exceptions¶
Every exception hare raises: where it sits in the tree, when it is raised, the SQL a database error carries, and how to handle the common ones.
HareError(Exception)
├── ConfigurationError
├── FieldError
├── QueryError(HareError, ValueError)
├── NoValuesFetched(HareError, AttributeError)
├── IncompleteInstanceError
├── ValidationError(HareError, ValueError)
├── UnSupportedError
├── ObjectLookupError — .model: type[Model] | None
│ ├── MultipleObjectsReturned
│ └── DoesNotExist(ObjectLookupError, LookupError)
├── TransactionManagementError
│ ├── DistributedTransactionPartiallyCommittedError — .xid, .coordinator_alias, .pending_participant_aliases
│ └── DistributedTransactionCommitAmbiguousError — .xid, .coordinator_alias, .participant_aliases
├── DatabaseError — .sql, .parameters
│ ├── OperationalError
│ │ ├── TransactionRetryError
│ │ ├── TooManyParametersError
│ │ └── CascadeDepthLimitError — caught by delete() itself, never reaches your code from it
│ │ └── SqliteTriggerRecursionLimitError — hare.dialects.sqlite.exceptions
│ ├── IntegrityError
│ │ ├── StaleObjectError — .model, .pk, .expected_version
│ │ └── ProtectedError — .protected_objects: list[Model]
│ └── DBConnectionError(DatabaseError, ConnectionError)
│ └── PoolTimeoutError
├── NonExistentTimeError(HareError, ValueError)
└── DecryptionError(HareError, ValueError)
Every exception the core of hare raises is a HareError (other packages add
subclasses and a few exceptions of their own). The tree separates two types of failure:
- The call is wrong —
QueryError,FieldError,ConfigurationError,ValidationError,UnSupportedError,NoValuesFetched,IncompleteInstanceError: raised by hare itself, before anything is sent to the database. - The database refused —
DatabaseErrorand its subclasses: what the connection's driver reported, and the integrity checks hare runs in the database's place (PROTECT,RESTRICT, the optimistic lock).
QueryError and ValidationError are also a ValueError, DoesNotExist a LookupError,
NoValuesFetched an AttributeError, DBConnectionError a ConnectionError — a plain
except ValueError:/except LookupError: written without knowing about hare catches them.
An error a database reported (IntegrityError, OperationalError, DBConnectionError, ...) is raised
from the driver's own exception, on every database: it is the exception's __cause__ (and its
args[0]), carrying the server's sqlstate, constraint_name, table_name, ... on PostgreSQL.
| Exception | Raised when |
|---|---|
FieldError |
A problem with a model field: an unknown field or lookup in a query, an invalid field reference. |
QueryError |
A call can't be run as given: a wrong argument (clone() without a required pk=), a combination of query methods SQL can't express, or an operation on an instance in the wrong state (unsaved, of another tenant). Nothing is sent to the database. Also a ValueError. |
ConfigurationError |
The configuration is invalid: settings, connections, apps, a model's Meta, a field's declaration or a manager — found while hare is set up, or the first time it is used. Running a query before Hare.init() is one too. |
TransactionManagementError |
Misuse of the transaction API. |
DistributedTransactionPartiallyCommittedError |
A distributed transaction committed, but a participant didn't finish COMMIT PREPARED — see DistributedTransactionPartiallyCommittedError |
DistributedTransactionCommitAmbiguousError |
The coordinator's own commit failed or its outcome is unknown — see DistributedTransactionCommitAmbiguousError |
DatabaseError |
Base of everything the database refused; carries .sql/.parameters when known (see below). |
OperationalError |
The database failed to run a statement. |
TransactionRetryError |
A concurrent transaction aborted the statement, and running the transaction again can succeed — see TransactionRetryError |
IntegrityError |
A write would break the data's integrity — a constraint the database reported, or a RESTRICT/PROTECT relation hare checked in the database's place. |
StaleObjectError |
Meta.optimistic_lock_field detected a concurrent modification — the UPDATE or the DELETE of an instance's row touched 0 rows because the version didn't match — see StaleObjectError |
ProtectedError |
Deleting a row blocked by an on_delete=PROTECT relation. Carries .protected_objects: list[Model]. |
NoValuesFetched |
Reading a relation (or a field .only()/.defer() left out) that hasn't been loaded — by select_related(), prefetch_related(), prefetch_related_objects() or await; or a value the database computes (GeneratedField) that no read has brought yet — of a new instance on a database returning no rows of a write (ClickHouse), of a clone() not saved yet. Also an AttributeError. |
DoesNotExist |
get()/Model[pk] found no matching row. Carries .model. Also a LookupError. |
MultipleObjectsReturned |
get() matched more than one row. Carries .model. |
IncompleteInstanceError |
Saving a partial (.only()/.defer()) instance that's missing its pk or the fields being saved — also passing one to bulk_create()/bulk_update() without every field the call writes. |
TooManyParametersError |
A statement binds more parameters than the driver or the database accepts in one statement (SQLite's SQLITE_LIMIT_VARIABLE_NUMBER, asyncpg's 32767, the PostgreSQL protocol's 65535 on rust_pg) — the connection stays usable, so this is an OperationalError, not a DBConnectionError. |
CascadeDepthLimitError |
A hard DELETE's native ON DELETE CASCADE stopped at the database's recursion limit before finishing (a client whose Features set cascade_depth_limit — see CascadeDepthLimitError |
DBConnectionError |
Connection-level failure — also a ConnectionError, so a plain except ConnectionError: catches it too. A statement the driver refuses to send (too many parameters, a message it can't encode) is an OperationalError instead — the connection is still fine. |
PoolTimeoutError |
Waiting for a connection of a pool ran out of the connection's pool_acquire_timeout — every connection stayed taken. A DBConnectionError; the pool is busy, not broken, and the next call may get one (Pool metrics and health). |
ValidationError |
A field validator failed, or a value can't be converted to its field's type. A text holding a lone surrogate (half of a surrogate pair, "\ud800") among a statement's values raises it too, on every driver: UTF-8 has no encoding of one. Also a ValueError. |
NonExistentTimeError |
A naive datetime falls in a daylight-saving gap — a wall-clock time the zone skips, with no real instant (Timezone.make_aware()). Also a ValueError. |
DecryptionError |
A value read from an EncryptedTextField/EncryptedJSONField can't be decrypted — it was written with a different key than the one passed to FieldEncryption.configure(), or the column doesn't hold an encrypted token. Also a ValueError. |
UnSupportedError |
The database, dialect, driver or server version can't do what was asked — see UnSupportedError |
DistributedTransactionPartiallyCommittedError¶
Raised by Transactions.distributed(): the distributed transaction's decision was already durably
committed (the coordinator's own COMMIT succeeded), but one or more participants failed to finish
COMMIT PREPARED before the call returned — the transaction DID commit, this is not a rollback. Carries
.xid, .coordinator_alias, .pending_participant_aliases. Resolve the unresolved participants via
hare distributed-recover or a later manual retry.
DistributedTransactionCommitAmbiguousError¶
Raised by Transactions.distributed(): the coordinator's own decision-commit itself failed or its
outcome is unknown (e.g. the connection dropped after the server processed COMMIT but before the
acknowledgement came back) — unlike DistributedTransactionPartiallyCommittedError, it isn't known
whether the transaction committed at all. Carries .xid, .coordinator_alias, .participant_aliases.
Every participant is still holding a prepared transaction; run hare distributed-recover to resolve them
correctly rather than guessing.
TransactionRetryError¶
The database aborted the statement because of a concurrent transaction, and running the whole transaction
again can succeed: a serialization failure under REPEATABLE READ/SERIALIZABLE, a deadlock the
database broke by aborting this side, or a database busy with another writer (SQLite's "database is
locked"), or a ClickHouse transaction meeting a part another one holds (a serialization error). The
connection's driver decides which errors these are (Driver.is_retryable). Inside atomic() the
transaction is rolled back as the error leaves the block — retry the block as a whole, never one
statement of it.
StaleObjectError¶
Meta.optimistic_lock_field detected a concurrent modification — the UPDATE or the DELETE of an
instance's row touched 0 rows because the version didn't match. A row that no longer exists gives
IntegrityError instead. Carries .model, .pk, .expected_version (the version the instance was read
at) — from bulk_update() .pk and .expected_version are None: a batch tells how many objects were
stale, not which.
CascadeDepthLimitError¶
A hard DELETE's native ON DELETE CASCADE stopped at the database's recursion limit before finishing (a
client whose Features set cascade_depth_limit; on SQLite it is SqliteTriggerRecursionLimitError,
past SQLITE_LIMIT_TRIGGER_DEPTH). delete() runs such a DELETE in a transaction, catches this, rolls
back and walks the cascade in Python; only a raw DELETE through execute() raises it to you.
UnSupportedError¶
The connection's database, dialect, driver or server version can't do what was asked — a lookup,
select_for_update(), stream(), distinct(*fields), bulk_create(returning=/use_copy=), a
transaction or isolation level, two-phase commit, a field type, constraint, index option or trigger type,
.explain(output_format=...) on SQLite (plain .explain() works there too, via EXPLAIN QUERY PLAN),
or a server older than the dialect runs on. A lookup given an argument of another sort raises it too —
__year= not an int, __isnull= not a bool, __in= not a list, has_key= not a text; a value that
can't be converted to its field's type is a ValidationError. Raised before anything is sent. A mistake in
the declaration itself, whatever the database, is a ConfigurationError.
Exceptions of other packages¶
Several packages raise subclasses of the exceptions above, so the same except catches them:
| Module | Exceptions |
|---|---|
hare.migrations.exceptions |
HareMigrationError and its subclasses — see Migration errors. |
hare.fields.validators.exceptions |
The ValidationErrors of the built-in validators: InvalidURL (and its InvalidScheme), InvalidDomainName, InvalidEmailAddress, InvalidSlug, InvalidPhoneNumber. |
hare.inspectdb.exceptions |
ManyToManyThroughTableSkippedError, DuplicateModelClassNameError, SchemaNotFoundError (each a ConfigurationError), TableNotFoundError (an OperationalError), UnsupportedDialectError (an UnSupportedError) — see Models from an existing database. |
hare.sql.exceptions |
HareSqlException (a QueryError) and its QueryException, GroupingException, CaseException, JoinException, SetOperationException, FunctionException: a statement the SQL builder can't put together. |
hare.dialects.sqlite.exceptions |
SqliteTriggerRecursionLimitError (a CascadeDepthLimitError). |
The contrib packages that answer requests or deliver events raise exceptions of the standard library's types instead, for the code around them to handle without knowing hare:
| Module | Exceptions |
|---|---|
hare.contrib.request_query.exceptions |
InvalidRequestQuery, RequestQueryForbidden, ApiGetterError and its subclasses — see Exceptions of request queries. |
hare.contrib.outbox.exceptions |
DeliveryError: an outbox event wasn't delivered. |
hare.cli.exceptions |
CLIError: a hare command failed — exit code 1. CLIUsageError (a CLIError): the command was called wrong — exit code 2. See The hare command. |
Exceptions of request queries¶
InvalidRequestQuery (a ValueError, .errors): the values of a request don't make a valid query — the
Litestar adapter answers 400 Bad Request, the FastAPI and Robyn ones 422 Unprocessable Content.
RequestQueryForbidden (a PermissionError, .parameter): the request asks for rows it may not see —
403 Forbidden. ApiGetterError (a HareError, .source_name): a value of another service
Meta.api_getter requires couldn't be loaded — 502 Bad Gateway; its subclasses ApiDataNotFoundError,
ApiRequestError, ApiValueMissingError and ApiValueConversionError say why — see
Meta.api_getter.
The SQL of a database error¶
Every DatabaseError — OperationalError, IntegrityError, DBConnectionError and their subclasses —
carries the statement that failed:
class DatabaseError(HareError):
sql: str | None
parameters: list[Any] | None
def __init__(self, *args: Any, sql: str | None = None, parameters: list[Any] | None = None) -> None: ...
When sql is set, str(error) includes it — useful for logging/Sentry without having to dig it out of
the exception by hand. The bind parameters never go into str(error) (nor into the exception event an
OpenTelemetry span records): they can hold passwords, tokens or personal data. They stay on
error.parameters for debugging — log them only where that data is allowed to go:
try:
await Book.objects.raw(bad_sql)
except OperationalError as error:
logger.error("query failed", extra={"sql": error.sql})
logger.debug("query params", extra={"parameters": error.parameters})
The message the driver or the server itself gives can still name a value, e.g. asyncpg's
invalid input for query argument $1: 'value'. hare converts a value through its field wherever the
field is known — a plain filter/update() value, Value(...) compared with or written to a column, a
text literal in arithmetic with a numeric column (F("pin") + "5") — so a malformed one fails as the
field's ValidationError first (hiding a sensitive=True field's value). A literal the database meets
with no field to go by — annotate(x=Value(...)) compared later, a raw SQL parameter, a function
argument — reaches the driver as is, and its error text can show it.
Handling patterns¶
Catching hare's exceptions where an application turns them into its own errors.
Deleting an author whose books still reference it through on_delete=PROTECT:
try:
await author.delete()
except ProtectedError as error:
raise ValidationError(f"{len(error.protected_objects)} object(s) still reference this row")
A loan another request changed since it was read, with Meta.optimistic_lock_field:
try:
await loan.save()
except StaleObjectError as error:
# someone else modified this row first — refetch and retry, or surface a conflict to the user
logger.warning("stale write on %s pk=%s, expected version %s", error.model.__name__, error.pk, error.expected_version)
...
A row that may not exist — does_not_exist_exception=None makes get() return None instead of raising
DoesNotExist: