Exceptions¶
Reference for the error types raised by eventsource.
The core hierarchy lives in eventsource.domain.exceptions and is rooted at a single
base class, EventSourceError. The domain module holds the errors that carry
domain meaning:
| Exception | Structured attributes |
|---|---|
EventSourceError |
— (base class) |
OptimisticLockError |
aggregate_id, expected_version, actual_version |
EventNotFoundError |
event_id |
ProjectionError |
projection_name, event_id |
AggregateNotFoundError |
aggregate_id, aggregate_type |
EventStoreError |
— |
EventBusError |
— |
SerializationError |
event_type |
EventVersionError |
expected_version, actual_version, event_id, aggregate_id |
UnhandledEventError |
event_type, event_id, handler_class, available_handlers |
AggregateNotCreatedError |
aggregate_class, suggestion |
Every one of these accepts its attributes as constructor arguments and builds a
human-readable message from them, so the attributes are always populated when
the library raises the error — you can branch on them rather than parsing
str(exc).
Infrastructure exceptions live in eventsource.ports.exceptions, not
eventsource.domain.exceptions (ADR 0041). CheckpointError,
LockAcquisitionError, LockNotHeldError, PositionDecodeError,
PositionForeignError, and SubscriptionError with its seven subclasses
(SubscriptionConfigError, SubscriptionStateError,
SubscriptionAlreadyExistsError, CheckpointNotFoundError,
EventStoreConnectionError, EventBusConnectionError, TransitionError)
describe port-contract failures (stores, buses, locks, positions,
checkpoints, subscriptions), not domain concepts, and were moved out of
domain/exceptions.py for that reason. They are still rooted in
EventSourceError, so except EventSourceError still catches them.
from eventsource.domain.exceptions import LockAcquisitionError (or any of
the other twelve) now raises ImportError — there is no shim; import from
eventsource.ports.exceptions instead.
Six of the domain errors are re-exported from the package root
(AggregateNotCreatedError, AggregateNotFoundError, EventNotFoundError,
EventSourceError, EventVersionError, OptimisticLockError,
ProjectionError); the rest must be imported from
eventsource.domain.exceptions or eventsource.ports.exceptions as
applicable. See
Import paths and public exports.
Other subsystems — snapshots, migration, and the optional bus backends —
define their own error types in their own modules. The three tenant
exceptions used to be defined in eventsource.multitenancy.exceptions; ADR
0038 merged them into the core hierarchy, though they were already
EventSourceError-rooted before the merge, so no rebase was needed. Those
are catalogued under
Related exceptions outside the core hierarchy;
note in particular that eventsource.ports.readmodels.exceptions defines a
second, unrelated OptimisticLockError.
Overview¶
Errors in eventsource fall into two groups.
The core hierarchy in eventsource.domain.exceptions covers the failures that
arise from the fundamental event-sourcing operations: appending events under a
version expectation, loading an aggregate, applying an event to aggregate state,
serializing an event, and running a projection. Everything there derives from
EventSourceError, so a single except EventSourceError: catches the whole
group.
Subsystem errors live beside the code that raises them (or, for
SnapshotError, alongside the core hierarchy in eventsource.domain.exceptions)
— eventsource.application.migration,
eventsource.ports.readmodels.exceptions, and the optional bus backends each
define their own families. They are also
EventSourceError subclasses: except EventSourceError is a reliable
library-boundary handler, and each family root narrows it further.
(LockAcquisitionError, LockNotHeldError, and SubscriptionError
are EventSourceError subclasses too, but live in eventsource.ports.exceptions
rather than eventsource.domain.exceptions as of ADR 0041 — see the diagram
below.)
Three properties of the core hierarchy are worth knowing before you write handling code:
- Errors carry structured data, not just text. Where an error has
attributes, they are constructor arguments assigned to
selfand then formatted into the message.OptimisticLockErrortells you theexpected_versionandactual_version, which is what makes the reload-and-retry loop possible without string parsing. - The hierarchy is flat. Every core error derives directly from
EventSourceError; there are no intermediate base classes. So you can catch everything, or catch one specific type, but there is no middle tier such as "all storage errors". - Some types are markers with no attributes.
EventStoreError,EventBusError, andEventSourceErroritself are plainpasssubclasses; they carry only whatever message the raiser passes toException.__init__. (CheckpointErroris a marker too, but lives ineventsource.ports.exceptions.)
Two of the errors are configuration-sensitive rather than unconditional.
UnhandledEventError is raised by default on DeclarativeAggregate (whose
unregistered_event_handling now defaults to "error"), and on
DeclarativeProjection only when it explicitly sets
unregistered_event_handling = "error"; under "warn" or "ignore" the same
situation produces no exception.
AggregateNotCreatedError is raised only when an aggregate sets
requires_creation_event = True and .state is read before any event has been
applied — state_or_none and is_created are the non-raising alternatives.
The sections below document each core error's attributes and the code paths that raise it, then catalogue the subsystem families and their import paths.
Exception hierarchy¶
eventsource.domain.exceptions and eventsource.ports.exceptions together
define the whole EventSourceError tree, split by ADR 0041 along domain
meaning versus infrastructure meaning. domain/exceptions.py holds the three
tenant exceptions merged in under ADR 0038 (TenantContextNotSetError,
TenantContextResetError, TenantMismatchError — already
EventSourceError-rooted before the merge, so no rebase was needed) plus
everything the domain ring itself raises. ports/exceptions.py holds the
thirteen infrastructure types — LockAcquisitionError, LockNotHeldError,
CheckpointError, PositionDecodeError, PositionForeignError, and
SubscriptionError with its seven subclasses — moved there by ADR 0041 because
they describe port-contract failures (stores, buses, locks, positions,
checkpoints, subscriptions), not domain concepts; roughly a third of the old
domain/exceptions.py had no domain meaning before this split. There are no
intermediate base classes beyond SubscriptionError — every other error is
exactly one level below the root.
Every other error family in the library is also rooted at EventSourceError.
Several keep a builtin base alongside it, so the builtin-shaped except clauses
callers already wrote keep working:
| Family | Root | Base classes |
|---|---|---|
| Snapshots | SnapshotError |
EventSourceError (defined in eventsource.domain.exceptions) |
| Read models | ReadModelError |
EventSourceError |
| Subscriptions | SubscriptionError |
EventSourceError |
| Event registry | EventTypeNotFoundError |
EventSourceError (no longer also a KeyError, ADR 0042) |
| Event registry | DuplicateEventTypeError |
EventSourceError (no longer also a ValueError, ADR 0042) |
| Optional backends | RabbitMQNotAvailableError |
EventSourceError, ImportError |
| Optional backends | RedisNotAvailableError, KafkaNotAvailableError, SQLiteNotAvailableError |
ImportError |
except EventSourceError catches every family in this table except the three
remaining optional-backend import sentinels, which are raised only when an
extra is missing and are deliberately ImportError-shaped.
tests/unit/test_exception_hierarchy.py is the guard that keeps this true.
Hierarchy diagram¶
The tree below shows every named exception class in the library, annotated with the module that defines it. Annotations are given once per family; unannotated entries live in the same module as their nearest annotated ancestor.
Exception
├── EventSourceError eventsource.domain.exceptions
│ ├── OptimisticLockError
│ ├── EventNotFoundError
│ ├── ProjectionError
│ ├── AggregateNotFoundError
│ ├── EventStoreError
│ ├── EventBusError
│ ├── SerializationError
│ ├── EventVersionError
│ ├── UnhandledEventError
│ ├── AggregateNotCreatedError
│ ├── DuplicateHandlerError
│ ├── DuplicateEventError
│ ├── TenantContextNotSetError (ADR 0038: merged here; formerly in eventsource.multitenancy)
│ ├── TenantContextResetError (ADR 0038: merged here; formerly in eventsource.multitenancy)
│ ├── TenantMismatchError (ADR 0038: merged here; formerly in eventsource.multitenancy)
│ ├── EventTypeNotFoundError (ADR 0033: rebased here, no longer also a KeyError as of ADR 0042)
│ ├── DuplicateEventTypeError (ADR 0033: rebased here, no longer also a ValueError as of ADR 0042)
│ ├── HandlerSignatureError (ADR 0033: rebased here, no longer also a ValueError as of ADR 0042)
│ └── MigrationError (ADR 0034: rebased here, was a bare Exception)
│ │ eventsource.application.migration.exceptions
│ ├── MigrationNotFoundError
│ ├── MigrationAlreadyExistsError
│ ├── MigrationStateError
│ │ └── InvalidPhaseTransitionError
│ ├── CutoverError
│ │ ├── CutoverTimeoutError
│ │ └── CutoverLagError
│ ├── ConsistencyError
│ ├── BulkCopyError
│ ├── DualWriteError
│ ├── PositionMappingError
│ ├── CircuitBreakerOpenError
│ └── SubscriptionMigrationError eventsource.application.migration.subscription_migrator
│
├── EventSourceError eventsource.ports.exceptions (ADR 0041)
│ ├── CheckpointError
│ ├── PositionDecodeError
│ ├── PositionForeignError
│ ├── LockAcquisitionError (ADR 0029: rebased onto EventSourceError; ADR 0041: relocated here from domain.exceptions)
│ ├── LockNotHeldError (ADR 0029: rebased onto EventSourceError; ADR 0041: relocated here from domain.exceptions)
│ └── SubscriptionError (ADR 0032: rebased onto EventSourceError; ADR 0041: relocated here from domain.exceptions)
│ ├── SubscriptionConfigError
│ ├── SubscriptionStateError
│ ├── SubscriptionAlreadyExistsError
│ ├── CheckpointNotFoundError
│ ├── EventStoreConnectionError
│ ├── EventBusConnectionError
│ └── TransitionError
│
├── EventSourceError (subsystem roots and standalones)
│ ├── SnapshotError eventsource.domain.exceptions
│ │ ├── SnapshotDeserializationError
│ │ ├── SnapshotSchemaVersionError
│ │ └── SnapshotNotFoundError
│ ├── ReadModelError eventsource.ports.readmodels.exceptions
│ │ ├── ReadModelVersionConflictError (ADR 0050: was OptimisticLockError)
│ │ └── ReadModelNotFoundError
│ ├── RetryError eventsource.application.subscriptions.retry
│ ├── CircuitBreakerOpenError eventsource.application.subscriptions.retry
│ │ (distinct from the migration one)
│ ├── StoreNotFoundError eventsource.application.migration.router
│ ├── WritePausedError eventsource.application.migration.write_pause
│ ├── ShutdownError eventsource.adapters.rabbitmq
│ ├── BatchPublishError eventsource.adapters.rabbitmq
│ └── RabbitMQNotAvailableError eventsource.adapters.rabbitmq
│ (also an ImportError)
│
├── ValueError
│ └── DeserializationError eventsource.adapters.kafka
└── ImportError
├── RedisNotAvailableError eventsource.adapters.redis
├── RabbitMQNotAvailableError eventsource.adapters.rabbitmq
│ (also an EventSourceError, above)
├── KafkaNotAvailableError eventsource.adapters.kafka
└── SQLiteNotAvailableError eventsource.adapters.sqlite.snapshots
Only the migration family has depth beyond one level; everywhere else the hierarchy is flat, so specificity comes from the attributes an error carries rather than from its position in the tree.
One name appears twice in the tree. CircuitBreakerOpenError is defined in
eventsource.application.migration.exceptions (a MigrationError) and in
eventsource.application.subscriptions.retry (a direct EventSourceError
subclass); the two are unrelated classes, so catching one will not catch the
other. Import it by module rather than pulling both into one namespace.
(The read-model conflict error was the second such collision until ADR 0050
renamed it to ReadModelVersionConflictError.)
EventSourceError¶
Base class for the core exception hierarchy. Defined in
eventsource.domain.exceptions and re-exported from the package root.
Import
from eventsource import EventSourceError
# or
from eventsource.domain.exceptions import EventSourceError
Constructor — none of its own. The class body is pass, so it inherits
Exception.__init__ and accepts any arguments:
Attributes — none. Unlike most of its subclasses, EventSourceError carries
no structured fields; only args and whatever message was passed.
The library never raises it directly¶
No code path in src/eventsource/ raises EventSourceError itself — it exists
purely as the root of the hierarchy. Every error the library raises from this
tree is one of the concrete subclasses. Treat a bare EventSourceError in a
traceback as coming from application code, not from the library.
What catching it covers¶
except EventSourceError: catches every concrete subclass defined in
eventsource.domain.exceptions, including TenantContextNotSetError,
TenantContextResetError, and TenantMismatchError (merged in from
eventsource.multitenancy under ADR 0038). It does not catch the
snapshot, read-model, subscription,
migration, event-registry, or optional-backend families — those are rooted at
Exception, KeyError, ValueError, and ImportError respectively. See
Exception hierarchy.
When to catch it¶
Use it at boundaries where you want to convert any event-sourcing failure into something else — an HTTP response, a log line, a dead-letter record — and the specific cause does not change what you do:
try:
await repository.save(order)
except EventSourceError as exc:
logger.exception("event sourcing operation failed")
raise HTTPException(status_code=500, detail=str(exc)) from exc
Do not use it where the recovery depends on the cause. OptimisticLockError
means "reload and retry" and AggregateNotFoundError means "404"; both are
EventSourceError, so catching the base class collapses two very different
outcomes into one. Catch the specific type first and let the base class be the
fallback:
try:
order = await repository.get(order_id)
except AggregateNotFoundError:
return None
except EventSourceError:
raise
Because it carries no attributes, code that catches EventSourceError can only
rely on str(exc) and type(exc). If you need to branch on data — a version
number, an aggregate id, an event type — catch the concrete subclass that
defines the attribute you need. See
Attributes safe to branch on.
OptimisticLockError¶
class OptimisticLockError(EventSourceError):
def __init__(
self,
aggregate_id: UUID,
expected_version: int,
actual_version: int,
) -> None: ...
Raised when a version conflict is detected while appending events to a stream.
Defined in eventsource.domain.exceptions and re-exported from the package root.
Import
from eventsource import OptimisticLockError
# or
from eventsource.domain.exceptions import OptimisticLockError
All three constructor arguments are required and positional-friendly; the message is built from them:
Optimistic lock error for aggregate <aggregate_id>: expected version
<expected_version>, but current version is <actual_version>
Note that eventsource.ports.readmodels.exceptions defines an unrelated class with
the same name. See
Name collision: eventsource.ports.readmodels.exceptions.OptimisticLockError.
Attributes: aggregate_id, expected_version, actual_version¶
| Attribute | Type | Meaning |
|---|---|---|
aggregate_id |
UUID |
The aggregate whose stream the append targeted. |
expected_version |
int |
The version the caller asserted, derived from the ExpectedVersion passed to append. |
actual_version |
int |
The stream's current version at conflict-detection time. |
All three are always populated: the constructor requires them, and every raise
site in the library (adapters/memory/store.py, adapters/postgresql/store.py,
adapters/sqlite/store.py) passes all three positionally. Branching on them is
safe without getattr guards.
from eventsource.ports.positions import ExpectedVersion
try:
await store.append(stream, events, ExpectedVersion.exact(4))
except OptimisticLockError as exc:
exc.aggregate_id # UUID('...')
exc.expected_version # 4
exc.actual_version # 7
str(exc)
# "Optimistic lock error for aggregate ...: expected version 4,
# but current version is 7"
The attributes are assigned before super().__init__ formats the message, so
the message is always consistent with them — never parse str(exc) to recover
the numbers.
One subtlety: expected_version is a plain int sentinel derived from the
ExpectedVersion dataclass the caller passed to append (defined in
eventsource.ports.positions), not the ExpectedVersion object itself. Each
backend module keeps a small internal _NO_STREAM_SENTINEL /
_STREAM_EXISTS_SENTINEL mapping to preserve this field's historical int
shape: ExpectedVersion.no_stream() reports as 0, ExpectedVersion.stream_exists()
reports as -2, and ExpectedVersion.exact(n) reports as n. When a
no_stream/stream_exists append conflicts, expected_version holds the
sentinel value, so exc.expected_version == -2 means "the caller required an
existing stream", not "version negative two". ExpectedVersion.any_() disables
the check and therefore never produces this error.
actual_version is a real version number in every case: the count of events
already in the stream, 0 for a stream that does not exist.
aggregate_id is annotated UUID but not validated at runtime — it is stored
verbatim from the StreamId passed to append.
One attribute is not on the class but is worth knowing about: __cause__. On
the PostgreSQL and SQLite unique-constraint path the error is raised with
raise ... from e, so exc.__cause__ holds the underlying IntegrityError.
On the pre-check path (and on InMemoryEventStore always) __cause__ is
None. A non-None __cause__ therefore distinguishes "lost a genuine race
with a concurrent writer" from "the version you asked for was already stale when
we read it".
Raised by¶
Every raise site in the library is inside an event store's append.
Everything else in this list is a caller that lets the store's error propagate
unchanged — no layer wraps, re-raises, or translates it.
EventAppender.append — the version check itself, and the only origin.
All three shipped backends (InMemoryEventStore, PostgreSQLEventStore,
SQLiteEventStore) implement the same four-branch logic against
current_version, the number of events already stored for the stream:
expected.kind |
Conflict condition |
|---|---|
"any" |
never — check skipped |
"stream_exists" |
current_version == 0 |
"no_stream" |
current_version != 0 |
"exact" |
current_version != expected.version |
InMemoryEventStore performs the check under an asyncio.Lock, so the
pre-check is authoritative there.
PostgreSQLEventStore and SQLiteEventStore run the same read-then-check
inside the write transaction and have a second raise site: a unique-constraint
violation on the (aggregate_id, aggregate_type, version) index is caught and
translated into OptimisticLockError. That path is what catches a genuinely
concurrent writer that slipped past the read check; it re-queries the stream to
fill in actual_version and chains the database error with raise ... from e.
Correctness under concurrency rests on the constraint, not on the pre-check —
which is also why exc.__cause__ is the reliable signal for distinguishing the
two, as described above.
AggregateRepository.save — propagates the store's error; it never
constructs one. The repository derives
expected_version = aggregate.version - len(uncommitted_events), wraps it in
ExpectedVersion.exact(expected_version), and passes that straight to
append, so a conflict here means another writer advanced the stream between
your load and your save. Two consequences worth knowing:
- A
saveon an aggregate with no uncommitted events returns early and cannot raise. - The aggregate is not rolled back by the failure.
mark_events_as_committedonly runs on success, so the uncommitted events are still attached to the stale instance — which is exactly why the retry pattern below reloads rather than re-saving.
AggregateRepository.load and load_or_create never raise this error; they
only read.
SyncEventStoreAdapter.append — runs the wrapped async store's
append on the shared executor and re-raises whatever it produced, so
the same conflict surfaces identically to synchronous callers.
DualWriteEventStore.append — writes to the source store first and
lets its failures propagate, so a conflict on the source store reaches the
caller unchanged. The target write is best-effort and wrapped in
except Exception: a conflict against the migration target is logged and
recorded as a sync failure, never raised.
Note that the read-model backends (readmodels/postgresql.py,
readmodels/sqlite.py, readmodels/in_memory.py) also raise an
OptimisticLockError — but the one from eventsource.ports.readmodels.exceptions,
which is a different class. See
Name collision.
Handling: retry-on-conflict pattern¶
This is the one core exception that is expected under normal load: two commands touched the same aggregate concurrently and one lost. The recovery is always the same — discard the stale in-memory aggregate, reload it, re-apply the command against fresh state, and save again.
The reload is not optional. AggregateRepository.save only calls
mark_events_as_committed() on success, so after a conflict the stale instance
still carries its uncommitted events and its unchanged version. Calling save
again on that same object recomputes the identical
expected_version = aggregate.version - len(uncommitted_events) and fails the
same way.
import asyncio
import random
from eventsource import OptimisticLockError
MAX_ATTEMPTS = 3
async def ship_order(repository, order_id, tracking_number):
for attempt in range(MAX_ATTEMPTS):
order = await repository.load(order_id)
order.ship(tracking_number=tracking_number)
try:
await repository.save(order)
return order
except OptimisticLockError as exc:
if attempt == MAX_ATTEMPTS - 1:
raise
logger.info(
"conflict on %s: expected v%s, actual v%s — retrying",
exc.aggregate_id,
exc.expected_version,
exc.actual_version,
)
await asyncio.sleep(random.uniform(0, 0.05 * 2**attempt))
raise AssertionError("unreachable")
Points worth keeping:
- Put the load and the command inside the loop. Only the
savebelongs in thetry. Reloading gives you fresh state; re-runningshipre-derives the decision from that state, which is the entire point of the retry. A loop that retriessavealone spins forever. repository.loadraisesAggregateNotFoundError, not this error. If the aggregate may legitimately not exist, useload_or_create— it swallowsAggregateNotFoundErrorand hands back a fresh instance — and keep that call inside the loop too.- The command must be safe to re-apply. Events emitted onto the stale instance are discarded along with it, but side effects outside the aggregate (emails, payments, outbound HTTP) are not. Keep them out of the retried region, or move them behind the outbox.
- Cap the attempts and back off. An unbounded loop turns a hot aggregate into a livelock. A small bound with jittered sleep is usually enough; persistent conflict on one aggregate is an aggregate-boundary design problem, not a retry-count problem.
exc.expected_versionandexc.actual_versionare the useful telemetry. A gap that grows across retries — actual moving several versions per attempt — says the aggregate is a write hotspot rather than an unlucky collision.exc.__cause__distinguishes the two conflict paths.Nonemeans the pre-check caught a version you already knew was stale; a chainedIntegrityErrormeans you lost a genuine race inside the write transaction. Both retry identically, but the ratio is worth a metric.
There is no purpose-built retry helper for command conflicts in the library. The
generic retry_async in eventsource.application.subscriptions.retry will work if — and
only if — the closure you hand it performs the reload itself:
from eventsource import OptimisticLockError
from eventsource.application.subscriptions.retry import RetryConfig, retry_async
async def attempt():
order = await repository.load(order_id)
order.ship(tracking_number=tracking_number)
await repository.save(order)
return order
order = await retry_async(
attempt,
config=RetryConfig(max_retries=2),
retryable_exceptions=(OptimisticLockError,),
operation_name="ship_order",
)
Note the differences from the hand-written loop: you must pass
retryable_exceptions explicitly (the default TRANSIENT_EXCEPTIONS does not
include OptimisticLockError), and exhausting the retries raises RetryError
wrapping the last failure rather than the OptimisticLockError itself. The
retry policies in eventsource.application.projections.retry are for
projection delivery and do not apply to the command side at all.
If you deliberately do not want the version check — bulk import, replay into a
fresh store — pass ExpectedVersion.any_() to append rather than catching
and ignoring the error.