Subscriptions API Reference¶
Technical reference for the subscription configuration and multi-instance
coordination surface of the eventsource.application.subscriptions package.
eventsource.application.subscriptions is the largest package in the library: it covers the
subscription state machine (Subscription, SubscriptionState), the manager and
its collaborators (SubscriptionManager, SubscriptionRegistry,
SubscriptionLifecycleManager, PauseResumeController, HealthCheckProvider),
catch-up/live transition (TransitionCoordinator), flow control, filtering,
retry and circuit breaking, error handling, health checks, metrics, graceful
shutdown, and coordination across instances. Everything listed in
eventsource.application.subscriptions.__all__ is public.
This page documents two of those areas in full detail:
| Area | Module | Covered here |
|---|---|---|
| Configuration | eventsource.application.subscriptions.config |
StartPosition, CheckpointStrategy, SubscriptionConfig, create_catch_up_config(), create_live_only_config() |
| Coordination | eventsource.application.subscriptions.coordination |
Topic constants, coordination messages, leader election, peer tracking and redistribution, callback type aliases |
Configuration is the entry point for almost every subscription: a
SubscriptionConfig is a frozen dataclass that decides where a subscription
starts reading, how it batches, when it checkpoints, what it filters, and how it
retries. Coordination is the opposite end of the stack — protocols and message
types for deployments running more than one instance, where exactly one instance
should hold leadership and peers need to learn when another is shutting down so
its work can be redistributed.
Every field, default, validation rule, and method below is described as it behaves in the current source. Where a name is a Protocol rather than a concrete class, that is called out explicitly: Protocols define a shape you implement, and the package ships in-memory implementations intended for tests and single-instance deployments.
Overview¶
Both areas are re-exported from the package root, so the documented names are importable either from the package or from their defining module:
from eventsource.application.subscriptions import SubscriptionConfig
from eventsource.adapters.memory import InMemoryLeaderElector
# equivalent to
from eventsource.application.subscriptions.config import SubscriptionConfig
from eventsource.adapters.memory.coordination import InMemoryLeaderElector
InMemoryLeaderElector and SharedLeaderState are the one name-availability
break in the ADR 0032 move: the LeaderElector Protocol pair lives in
eventsource.ports.coordination, but their only concrete implementation is an
adapter, and application/ may not import adapters/ — so it now lives in
eventsource.adapters.memory.coordination (re-exported from
eventsource.adapters.memory) rather than in eventsource.application.subscriptions
alongside the rest of the coordination surface.
Configuration at a glance¶
SubscriptionConfig is a @dataclass(frozen=True) with defaults for every
field, so SubscriptionConfig() is valid and describes a subscription that
resumes from its last checkpoint, reads 100 events per batch, checkpoints once
per batch, retries failures up to five times with exponential backoff, and
trips a circuit breaker after five consecutive failures. Overriding a field
means overriding one decision, not restating the rest.
The fields fall into five groups, each documented in its own subsection below:
| Group | Fields |
|---|---|
| Starting position and batching | start_from, batch_size |
| Checkpointing | checkpoint_strategy, checkpoint_interval_seconds |
| Timeouts | processing_timeout |
| Filtering | event_types, aggregate_types, tenant_id |
| Error handling and retry | continue_on_error, max_retries, initial_retry_delay, max_retry_delay, retry_exponential_base, retry_jitter |
| Circuit breaker | circuit_breaker_enabled, circuit_breaker_failure_threshold, circuit_breaker_recovery_timeout |
Because the dataclass is frozen, validation happens once in __post_init__ and
a constructed instance is always valid: invalid combinations raise ValueError
at construction time rather than failing mid-stream. The retry and circuit
breaker fields are stored flat on the config and converted on demand by
get_retry_config() and get_circuit_breaker_config(), which build the
RetryConfig and CircuitBreakerConfig objects that eventsource.application.subscriptions.retry
consumes.
create_catch_up_config() and create_live_only_config() are thin factories
over the same dataclass — they set a handful of fields and leave the rest at
their defaults. They exist to name the two most common shapes, not to unlock
behavior you cannot express directly.
Coordination at a glance¶
The coordination module has no dependency on the event store or the bus. It defines message types, protocols, and an in-memory implementation; transporting messages between processes is left to whatever bus or control plane you already run. The four topic constants exist so that separate deployments agree on channel names for that transport.
Coordination splits into four layers:
- Topic constants — the
__eventsource_coordination-prefixed channel names for shutdown, heartbeat, and work-assignment traffic. - Messages —
ShutdownIntent,ShutdownNotification,HeartbeatMessage, andWorkAssignment: the payloads instances exchange. - Leader election —
LeaderElector(eventsource.ports.coordination) are Protocols describing what a leader-election backend must provide.InMemoryLeaderElector, backed bySharedLeaderState(eventsource.adapters.memory.coordination), is the only concrete implementation shipped; it coordinates within a single process and is intended for tests and single-instance deployments. Kubernetes, Redis, and Consul backends are named in the module docstring as future work, not as available classes. - Peer tracking and redistribution —
PeerInforecords what is known about a peer, andWorkRedistributionCoordinatorties the pieces together: broadcasting shutdown intent, tracking heartbeats, detecting peer timeouts, and dispatching registered callbacks.
The five callback type aliases (LeaderChangeCallback, PeerShutdownCallback,
HeartbeatCallback, WorkAssignmentCallback, PeerTimeoutCallback) are all
Callable[[X], Awaitable[None]] — every coordination callback is async and
returns nothing. They are aliases, not classes; any coroutine function with the
right parameter type satisfies them.
Import surface (eventsource.application.subscriptions)¶
All names on this page are exported from eventsource.application.subscriptions.__all__
(123 entries in total for the package). They are not re-exported from the
top-level eventsource package — unlike most of the library, subscription
names must be imported from the subpackage:
# Correct
from eventsource.application.subscriptions import SubscriptionConfig, CheckpointStrategy
# Wrong — AttributeError / ImportError
from eventsource import SubscriptionConfig
Each name is also importable from its defining module. The package-level import is the supported form; the module path is stable but more verbose.
Configuration names¶
Defined in eventsource/subscriptions/config.py. The module has no __all__
of its own; the package __all__ is the contract.
| Name | Kind | Import |
|---|---|---|
StartPosition |
Type alias — Literal["beginning", "end", "checkpoint"] \| int |
from eventsource.application.subscriptions import StartPosition |
CheckpointStrategy |
Enum (EVERY_EVENT, EVERY_BATCH, PERIODIC) |
from eventsource.application.subscriptions import CheckpointStrategy |
SubscriptionConfig |
@dataclass(frozen=True) |
from eventsource.application.subscriptions import SubscriptionConfig |
create_catch_up_config |
Function returning SubscriptionConfig |
from eventsource.application.subscriptions import create_catch_up_config |
create_live_only_config |
Function returning SubscriptionConfig |
from eventsource.application.subscriptions import create_live_only_config |
config.py uses from __future__ import annotations and imports DomainEvent,
RetryConfig, and CircuitBreakerConfig only under TYPE_CHECKING. Those
types therefore appear in annotations without being imported at runtime — the
runtime import of RetryConfig/CircuitBreakerConfig happens inside
get_retry_config() and get_circuit_breaker_config() when you call them.
Importing eventsource.application.subscriptions.config directly pulls in only
dataclasses, enum, typing, and uuid.
Coordination names¶
Defined in src/eventsource/application/subscriptions/coordination.py, which
does declare its own __all__ — the message types, callback aliases, and
work-redistribution names below are re-exported unchanged by the package.
LeaderElector and InMemoryLeaderElector/
SharedLeaderState are re-exported from eventsource.ports.coordination and
eventsource.adapters.memory.coordination respectively (see the previous
section) and are not re-exported by application.subscriptions.
| Group | Names |
|---|---|
| Topic constants | COORDINATION_TOPIC_PREFIX, SHUTDOWN_NOTIFICATIONS_TOPIC, HEARTBEAT_TOPIC, WORK_ASSIGNMENT_TOPIC |
| Enum | ShutdownIntent |
| Message types | ShutdownNotification, HeartbeatMessage, WorkAssignment |
| Callback aliases | LeaderChangeCallback, PeerShutdownCallback, HeartbeatCallback, WorkAssignmentCallback, PeerTimeoutCallback |
Leader election (eventsource.ports.coordination) |
LeaderElector |
Leader election, in-memory (eventsource.adapters.memory) |
InMemoryLeaderElector, SharedLeaderState |
| Work redistribution | PeerInfo, WorkRedistributionCoordinator |
LeaderElector is a Protocol class: import it
for type annotations and structural conformance, not to subclass. The only
concrete elector shipped is InMemoryLeaderElector.
A representative import¶
from eventsource.adapters.memory import InMemoryLeaderElector
from eventsource.application.subscriptions import (
CheckpointStrategy,
HEARTBEAT_TOPIC,
PeerInfo,
ShutdownIntent,
SubscriptionConfig,
WorkRedistributionCoordinator,
create_catch_up_config,
)
from eventsource.ports.coordination import LeaderElector
Nothing in either module requires an optional dependency: both are importable
with only the core pydantic + sqlalchemy install. Other parts of the
package (metrics, observability) degrade to no-op implementations when
OpenTelemetry is absent, but configuration and coordination have no such
guards.
Configuration¶
eventsource.application.subscriptions.config defines five public names. Together they
answer, for a single subscription: where to start, how much to read at a time,
when to persist a checkpoint, which events to keep, and what to do when a
handler fails.
from eventsource.application.subscriptions import (
CheckpointStrategy,
StartPosition,
SubscriptionConfig,
create_catch_up_config,
create_live_only_config,
)
StartPosition¶
A type alias, not a class — there is nothing to instantiate beyond a Position
itself. It is the annotation of SubscriptionConfig.start_from and the set of
values StartFromResolver.resolve() (in eventsource.application.subscriptions.transition)
understands:
| Value | Resolved starting position |
|---|---|
"beginning" |
None — read from the start of the feed |
"end" |
await event_store.current_position() — the current end, so only new events are seen |
"checkpoint" |
The subscription's stored checkpoint position; falls back to None (logging an info message) when no checkpoint exists |
Position |
That exact opaque position token, returned unchanged |
The Position branch is tested first (isinstance(start_from, Position)), so an
explicit position is never confused with a literal. Any other value raises
ValueError: Unknown start_from value: ... at resolve time — string literals
are not validated by SubscriptionConfig.__post_init__, only by the resolver.
SubscriptionConfig no longer accepts a bare int for start_from at all — the
member was deleted when positions became opaque; pass a Position obtained from
the store or a checkpoint instead.
The default for SubscriptionConfig.start_from is "checkpoint", which makes
resume-from-where-you-left-off the behavior you get without asking for it. Note
that "checkpoint" degrades to reading from the start of the feed for a
subscription that has never checkpointed; use an explicit Position or "end"
if a fresh subscription must not read history.
CheckpointStrategy¶
class CheckpointStrategy(Enum):
EVERY_EVENT = "every_event"
EVERY_BATCH = "every_batch"
PERIODIC = "periodic"
A plain Enum (not StrEnum, not IntEnum) with three members. Members compare
by identity — the runners test config.checkpoint_strategy == CheckpointStrategy.X —
so pass the member, not its string. The .value strings are stable and appear in
runner log records ("checkpoint_strategy": self.config.checkpoint_strategy.value
in the catch-up runner's start log).
| Member | Value | Checkpoint written | Trade-off |
|---|---|---|---|
EVERY_EVENT |
"every_event" |
After each successfully processed event | Safest, slowest — one checkpoint write per event |
EVERY_BATCH |
"every_batch" |
Once per drained batch (the default) | Balanced; on crash you reprocess at most one batch |
PERIODIC |
"periodic" |
When checkpoint_interval_seconds has elapsed |
Fewest writes, most reprocessing after a crash |
The value is the checkpoint_strategy field of SubscriptionConfig, whose
default is CheckpointStrategy.EVERY_BATCH.
How each runner interprets it¶
The two runners in eventsource.application.subscriptions.runners read the same field but
do not behave identically, and the difference is the main thing to know here.
Catch-up runner (runners/catchup.py) honors all three literally, per batch:
EVERY_EVENT— checkpoints inside the per-event loop, after the event has been delivered and its position recorded. Events dropped by the subscription filter are skipped withcontinuebefore this point, so a filtered event never triggers a per-event checkpoint (its position is still recorded on the subscription).PERIODIC— calls the same per-event hook, but only writes whenelapsed >= config.checkpoint_interval_seconds.EVERY_BATCH— checkpoints once after the batch loop, using the lastEventEnvelopeseen. The guard is(events_in_batch > 0 or events_filtered > 0) and last_stored_event is not None, so a batch in which every event was filtered out still checkpoints — that is what keeps a subscription with a narrow filter from re-reading the same filtered range after a restart. An empty batch writes nothing.
Live runner (runners/live.py) receives events one at a time from the bus,
so there is no batch boundary to attach to. Its _maybe_checkpoint() maps
EVERY_BATCH onto the EVERY_EVENT branch explicitly — same checkpoint write,
same frequency. In live mode the only strategy that reduces checkpoint writes is
PERIODIC; choosing EVERY_BATCH over EVERY_EVENT changes nothing.
PERIODIC timing¶
Both runners track _last_checkpoint_time with time.monotonic() and compare
elapsed >= config.checkpoint_interval_seconds (default 5.0). Two consequences:
- The clock is reset by every checkpoint write, not just periodic ones — the interval means "at least this long since the last checkpoint of any kind".
- The check runs only when an event arrives. A quiet subscription writes no checkpoint until the next event, however long the interval has been exceeded; there is no background timer.
_last_checkpoint_time is seeded at runner start, so the first periodic
checkpoint comes no earlier than one full interval after startup.
Choosing one¶
EVERY_EVENTwhen reprocessing a single event is unacceptable — a projection with non-idempotent side effects, or an integration that emits externally visible messages.EVERY_BATCH(the default) for catch-up work over large histories: one write perbatch_sizeevents, bounded replay on restart.PERIODICwhen checkpoint writes are themselves the bottleneck and your handlers are idempotent. Bound the damage by loweringcheckpoint_interval_secondsrather than relying on the5.0default.
Checkpoint writes go through _save_checkpoint_with_retry() under every
strategy, so a transient checkpoint-store failure is retried using the
subscription's RetryConfig before it surfaces.
SubscriptionConfig¶
A standard-library dataclasses dataclass — not a pydantic model — with a
default for every one of its 18 fields, so SubscriptionConfig() is a complete,
valid configuration. Frozen means assignment after construction raises
FrozenInstanceError, and because eq=True is also in effect the class gets a
value-based __hash__; the collection-shaped fields are typed as tuple
rather than list so that hashing actually works. To change a setting,
construct a new instance:
Every constructor argument is keyword-usable and independent — overriding one
field leaves the others at their defaults. There is no partial-config or
merge mechanism; a SubscriptionConfig is always the complete set of decisions
for one subscription.
Validation runs once, in __post_init__, and raises ValueError for any
invalid value or inconsistent pair (see
Validation rules). Combined with frozenness
this gives a useful guarantee: a SubscriptionConfig you hold is a config that
passed validation, and it cannot drift afterwards. Misconfiguration fails at
construction, not halfway through a replay.
The fields fall into five groups, documented in the subsections that follow:
| Group | Fields |
|---|---|
| Starting position and batching | start_from, batch_size |
| Checkpointing | checkpoint_strategy, checkpoint_interval_seconds |
| Timeouts | processing_timeout |
| Filtering | event_types, aggregate_types, tenant_id |
| Error handling and retry | continue_on_error, max_retries, initial_retry_delay, max_retry_delay, retry_exponential_base, retry_jitter |
| Circuit breaker | circuit_breaker_enabled, circuit_breaker_failure_threshold, circuit_breaker_recovery_timeout |
The retry and circuit breaker groups are stored flat on the dataclass and are
not consumed in that form: get_retry_config() and
get_circuit_breaker_config() assemble them into the RetryConfig and
CircuitBreakerConfig objects that eventsource.application.subscriptions.retry defines.
Those two methods are the only behavior on the class beyond validation —
SubscriptionConfig is data, and the runners in
eventsource.application.subscriptions.runners are what interpret it.
Defaults, in full, describe a subscription that resumes from its last checkpoint, reads 100 events per batch, checkpoints once per batch, retries a failing event up to five times with exponential backoff, dead-letters it and continues, and trips a circuit breaker after five consecutive failures. If that description already matches what you want, pass no arguments at all.
Fields: starting position and batching¶
| Field | Type | Default |
|---|---|---|
start_from |
StartPosition |
"checkpoint" |
batch_size |
int |
100 |
These two fields decide where a subscription begins reading and how much of
the stream it pulls in at a time. They are independent: start_from is read
once at startup and batch_size shapes each catch-up read.
start_from¶
Typed StartPosition — one of "beginning", "end",
"checkpoint", or an explicit Position token. The default,
"checkpoint", means resume: an existing subscription picks up after its last
saved position, and a subscription that has never checkpointed falls back to
reading from the start of the feed with an info-level log from
StartFromResolver.resolve(). If a fresh subscription must not read
history, say so explicitly with "end" or a Position.
The field is consumed only by StartFromResolver.resolve() in
eventsource.application.subscriptions.transition, which is called once when the
subscription starts. Changing start_from on a running subscription is not
possible (the config is frozen) and would have no effect mid-run anyway.
start_from no longer accepts a bare int — with opaque positions the
integer form is unrepresentable, and __post_init__ no longer has a numeric
value to range-check. It does not validate the string literals either: a
typo such as start_from="checkpont" constructs successfully and fails
later, at resolve time, with ValueError: Unknown start_from value:
checkpont. The resolver tests isinstance(start_from, Position) first, so an
explicit position is never confused with a literal.
str(config.start_from) is what SubscriptionRegistry records in its
registration log entry.
batch_size¶
The maximum number of events the catch-up runner requests from the event
store per read. The live runner also reads from the global feed in
batch_size-limited chunks (it does not read from the bus — see
ADR 0047), but delivers
events from that chunk to the subscriber one at a time regardless of
batch_size; it does not (yet) dispatch through handle_batch().
Each iteration of the catch-up loop computes:
so the runner never over-reads past the target position captured at startup,
and a batch_limit <= 0 ends the batch immediately (returning 0, which
breaks the catch-up loop). The read always proceeds forward; the limit and
starting position are passed through to the store's read_all() as the
from_position argument and FeedReadOptions(limit=batch_limit) (read_all
has no direction parameter — the global feed only reads forward).
batch_size interacts with checkpointing: under the default
CheckpointStrategy.EVERY_BATCH it is also the checkpoint granularity, so it
bounds how many events are reprocessed after a crash. Larger batches mean fewer
store round-trips and fewer checkpoint writes, and more replay on restart.
create_catch_up_config() raises the default tenfold, to 1000, for exactly
that trade.
Note that batch_size is the only per-read memory bound in this config — a
batch is read into a list before its events are dispatched, so a very large
batch_size holds a correspondingly large list of EventEnvelope objects.
There is no separate concurrency knob: neither runner delivers concurrently.
On the live runner, and on catch-up for a subscriber without handle_batch(),
each handle() call is awaited to completion before the next event starts, so
at most one event is ever in flight. On catch-up for a subscriber with
handle_batch(), the whole read batch is handed to it as one call — still no
concurrent delivery, but the unit of work is the batch rather than the event
(see Handle events in batches with handle_batch()
and ordered delivery).
Both the configured value (config.batch_size) and the actual per-batch count
(events_in_batch) appear in catch-up log records under the key batch_size;
the configured value is also set as a span attribute on the catch-up span.
Choosing values¶
| Situation | start_from |
batch_size |
|---|---|---|
| Rebuild a projection from scratch | "beginning" |
1000 (or create_catch_up_config()) |
| Resume a long-running projection | "checkpoint" (default) |
default 100 |
| Tail-follow new events only | "end" (or create_live_only_config()) |
irrelevant — live runner |
| Reprocess from a known incident position | that int |
as for catch-up |
Fields: checkpointing¶
| Field | Type | Default |
|---|---|---|
checkpoint_strategy |
CheckpointStrategy |
CheckpointStrategy.EVERY_BATCH |
checkpoint_interval_seconds |
float |
5.0 |
checkpoint_interval_seconds is consulted only under
CheckpointStrategy.PERIODIC: both runners compare elapsed time against it and
save when elapsed >= checkpoint_interval_seconds. Under the other two
strategies the field is inert (but still validated).
Fields: timeouts¶
| Field | Type | Default |
|---|---|---|
processing_timeout |
float |
30.0 |
It is validated as positive. processing_timeout is enforced: both runners
call the subscriber inside asyncio.timeout(config.processing_timeout), at the
same chokepoint that applies the handler circuit breaker. It bounds one
handler call — a handle_batch() of 500 events gets the same budget as a
single handle(), because it is one call. On expiry the call raises
TimeoutError and is treated as an ordinary handler failure: continue_on_error
governs whether the subscription continues, the event follows the normal DLQ
path, and the timeout counts toward the handler breaker's consecutive-failure
run (the timeout is applied inside the breaker, not around it, so hangs open the
circuit just as raises do).
shutdown_timeout is not a SubscriptionConfig field. It is declared once,
on the SubscriptionManager constructor (default 30.0), which passes it to the
shutdown coordinator; stop_all() and run_until_shutdown() accept a per-call
override. The config used to carry a second, inert copy that nothing read — it
was removed in ADR 0062.
Fields: filtering (event_types, aggregate_types, tenant_id)¶
| Field | Type | Default |
|---|---|---|
event_types |
tuple[type[DomainEvent], ...] \| None |
None |
aggregate_types |
tuple[str, ...] \| None |
None |
tenant_id |
UUID \| None |
None |
None on any field means "no restriction on this dimension". These three are
consumed by EventFilter.from_config() and
EventFilter.from_config_and_subscriber() in
eventsource.application.subscriptions.filtering, which copy them straight across. All
configured dimensions must match for an event to pass — the filter ANDs them.
Matching semantics, as implemented by EventFilter:
event_typesmatches on exact class identity (type(event) in event_types). A subclass of a listed event type does not match. If you need name-pattern matching ("Order*"), that lives onEventFilter.event_type_patterns/EventFilter.from_patterns(), whichSubscriptionConfighas no field for.aggregate_typesmatchesevent.aggregate_typeagainst the tuple of names.tenant_idmatchesevent.tenant_id == tenant_id, which is how you scope a subscription to one tenant for multi-tenant streaming or a tenant-by-tenant migration.
from_config_and_subscriber() adds one fallback: when config.event_types is
None, the subscriber's subscribed_to() types are used instead. A config that
does set event_types always wins.
from uuid import UUID
tenant_config = SubscriptionConfig(
tenant_id=UUID("12345678-1234-5678-1234-567812345678"),
start_from="beginning",
)
Fields: error handling and retry¶
| Field | Type | Default |
|---|---|---|
continue_on_error |
bool |
True |
max_retries |
int |
5 |
initial_retry_delay |
float |
1.0 |
max_retry_delay |
float |
60.0 |
retry_exponential_base |
float |
2.0 |
retry_jitter |
float |
0.1 |
continue_on_error governs what happens once an event has exhausted retries and
been dead-lettered: with the default True the runner moves on to the next
event; with False the runner stops. Both the catch-up and live runners check
it at that point.
The five retry_*/*_retry_* fields are stored flat here and assembled into a
RetryConfig by get_retry_config(). They are not read directly by the
runners.
Fields: circuit breaker¶
| Field | Type | Default |
|---|---|---|
circuit_breaker_enabled |
bool |
True |
circuit_breaker_failure_threshold |
int |
5 |
circuit_breaker_recovery_timeout |
float |
30.0 |
circuit_breaker_enabled is the only one of the three the runners read
directly: when true, they build two independent CircuitBreaker
instances from get_circuit_breaker_config(), one guarding the
subscriber's handle()/handle_batch() calls (handler_circuit_breaker)
and one guarding read-batch/checkpoint-save (infra_circuit_breaker). They
share the same threshold and recovery timeout — there is one config knob,
not two — but independent state: a run of handler failures cannot open the
infra breaker and block checkpointing, and a flaky store cannot mask a
broken handler. See Handle events one at a
time for what
feeds the handler breaker and why a DLQ'd event alone never opens it. When
circuit_breaker_enabled is false, neither breaker is created and the
other two fields have no effect (they are still validated).
Validation rules (__post_init__)¶
Every rule below raises ValueError from the constructor. Messages include the
offending value, and most suggest a workable default.
| Rule | Raised when |
|---|---|
batch_size must be positive |
batch_size < 1 |
processing_timeout must be positive |
<= 0 |
checkpoint_interval_seconds must be positive |
<= 0 |
max_retries must be >= 0 |
negative (0 is legal — it means no retries) |
initial_retry_delay must be positive |
<= 0 |
max_retry_delay must be positive |
<= 0 |
max_retry_delay must be >= initial_retry_delay |
the two are inverted |
retry_exponential_base must be > 1.0 |
<= 1.0 (exactly 1.0 is rejected — it would never back off) |
retry_jitter must be between 0.0 and 1.0 |
outside the inclusive range |
circuit_breaker_failure_threshold must be >= 1 |
< 1 |
circuit_breaker_recovery_timeout must be positive |
<= 0 |
Note the boundaries: retry_jitter accepts both 0.0 and 1.0;
retry_exponential_base is strictly greater than 1.0;
start_from has no numeric range to validate here at all now that the int
member is gone — an unrecognized string value is only caught later by
StartFromResolver.resolve().
get_retry_config() -> RetryConfig¶
Builds a RetryConfig (from eventsource.application.subscriptions.retry) from the flat
retry fields. The import happens inside the method, so config.py stays free of
a runtime dependency on retry.py.
Field mapping — note the names differ on both sides:
SubscriptionConfig |
RetryConfig |
|---|---|
max_retries |
max_retries |
initial_retry_delay |
initial_delay |
max_retry_delay |
max_delay |
retry_exponential_base |
exponential_base |
retry_jitter |
jitter |
The two sets of defaults are identical (5, 1.0, 60.0, 2.0, 0.1), so
SubscriptionConfig().get_retry_config() equals RetryConfig(). A fresh object
is returned on each call. Both runners call this once during startup to
configure their retry handling.
get_circuit_breaker_config() -> CircuitBreakerConfig¶
Builds a CircuitBreakerConfig the same way, with a locally-scoped import:
SubscriptionConfig |
CircuitBreakerConfig |
|---|---|
circuit_breaker_failure_threshold |
failure_threshold |
circuit_breaker_recovery_timeout |
recovery_timeout |
CircuitBreakerConfig has a third field, half_open_max_calls (default 1),
that SubscriptionConfig does not expose — it always takes the default. If you
need a different half-open allowance, construct the CircuitBreaker yourself
rather than going through the subscription config.
The runners call this twice when circuit_breaker_enabled is true — once
per breaker — so handler_circuit_breaker and infra_circuit_breaker are
two separate CircuitBreaker objects built from equal config, not one
object shared between both.
create_catch_up_config()¶
def create_catch_up_config(
batch_size: int = 1000,
checkpoint_every_batch: bool = True,
) -> SubscriptionConfig
Returns a SubscriptionConfig tuned for bulk catch-up: start_from="checkpoint",
batch_size as given (default 1000, ten times the dataclass default), and
checkpoint_strategy set to CheckpointStrategy.EVERY_BATCH when
checkpoint_every_batch is true, CheckpointStrategy.PERIODIC when false.
Every other field keeps its dataclass default — retry and circuit breaker
settings are untouched.
The checkpoint_every_batch=False branch selects PERIODIC, which then uses
the default checkpoint_interval_seconds=5.0; it does not disable
checkpointing.
batch_size is validated by __post_init__ as usual, so
create_catch_up_config(batch_size=0) raises ValueError.
create_live_only_config()¶
Takes no arguments. Returns a SubscriptionConfig with start_from="end",
batch_size=100, and checkpoint_strategy=CheckpointStrategy.EVERY_EVENT —
a subscription that skips history entirely and checkpoints after each event.
start_from="end" resolves to the event store's current position token at
startup, so any events written before the subscription starts are never
delivered. This is the intended shape for tail-following consumers that must not
replay history; it is not appropriate for projections that need a complete
stream.
Because the live runner treats EVERY_BATCH as EVERY_EVENT anyway, the
explicit EVERY_EVENT here documents intent more than it changes behavior — the
meaningful alternative in live mode is PERIODIC.