Skip to content

refactor: split signals into rabbit/kafka subpackages - #29

Merged
aradng merged 9 commits into
mainfrom
fix/kafka-consumer-retry-backoff
Jul 21, 2026
Merged

refactor: split signals into rabbit/kafka subpackages#29
aradng merged 9 commits into
mainfrom
fix/kafka-consumer-retry-backoff

Conversation

@aradng

@aradng aradng commented Jul 21, 2026

Copy link
Copy Markdown
Owner

What

Kafka consumers using AckPolicy.NACK_ON_ERROR had zero backoff on exception — a deterministic (poison) message failure gets redelivered essentially as fast as the consumer can poll, with no delay, no cap, no dead-letter. RabbitSubscriber already handles this correctly (DLX queue chain, exponential backoff, 5s*2^n capped at 24h) — Kafka's consumer never got the same treatment.

Why not a DLX chain like Rabbit

Kafka has no per-message TTL primitive to build a delay-queue chain from the way Rabbit does (no x-message-ttl-equivalent). This takes the simpler, Kafka-appropriate route instead: an ExceptionMiddleware handler (same pattern as RabbitSubscriber._exc_handler) that sleeps base_delay * 2 ** (attempt - 1) seconds (capped at max_delay) before re-raising. NACK_ON_ERROR's redelivery is throttled directly instead of spinning at the consumer's max poll rate — same outcome (throttled retries), simpler mechanism (no new topics/infra), and the exception still propagates for Sentry/OTel visibility either way, matching Rabbit's own choice to always re-raise.

Retry state design

Tracked per (topic, partition), not per-offset: Kafka delivers one partition's messages strictly in order, so a partition can only ever be stuck retrying one offset at a time. Tracking coarser than per-offset keeps the in-memory state bounded by the consumer's own partition assignment, rather than growing with every distinct message that's ever failed over the process's lifetime.

Compatibility

KafkaSubscriber's constructor gains the same base_delay/max_delay/exceptions parameters RabbitSubscriber already has, all defaulted — backwards compatible with every existing single-arg call site (grepped this repo: fastloom/launcher/utils.py and tests/kafka/conftest.py both just do KafkaSubscriber(settings)).

Tests

  • tests/kafka/test_backoff.py (new, pure unit — no broker needed): delay doubles on repeated failures of the same offset, capped at max_delay; resets to attempt 1 on a new offset; tracks partitions independently.
  • Full tests/kafka/ suite: 23 passed (existing tests unaffected).
  • mypy/ruff clean on both changed files. (Note: a handful of pre-existing mypy errors in this same file, in the unrelated tombstone-patching code above my changes, are untouched by this PR.)

Context

Found while investigating a production email-spam incident in a downstream service: a deterministic bug (unrelated to this fix, already fixed there) turned into hundreds of retries per hour purely because nothing throttled the redelivery. That's a systemic gap in this library, not just that one service's bug.

aradng and others added 6 commits July 21, 2026 14:02
…ubscriber

Kafka consumers using AckPolicy.NACK_ON_ERROR had zero backoff on
exception -- a deterministic (poison) message failure gets redelivered
essentially as fast as the consumer can poll, with no delay, no cap,
no dead-letter. RabbitSubscriber already handles this correctly (DLX
queue chain, exponential backoff, 5s*2^n capped at 24h) -- Kafka's
consumer never got the same treatment.

Kafka has no per-message TTL primitive to build a delay-queue chain
from the way Rabbit does, so this takes a simpler route: an
ExceptionMiddleware handler (same pattern as RabbitSubscriber's
_exc_handler) that sleeps `base_delay * 2 ** (attempt - 1)` seconds
(capped at max_delay) before re-raising. NACK_ON_ERROR's redelivery is
throttled directly instead of spinning at max poll rate; the exception
still propagates for Sentry/OTel visibility either way.

Retry state is tracked per (topic, partition), not per-offset: Kafka
delivers one partition's messages strictly in order, so a partition can
only ever be stuck retrying one offset at a time. Tracking coarser than
that keeps the in-memory state bounded by the consumer's own partition
assignment rather than growing with every distinct message that's ever
failed.

KafkaSubscriber's constructor gains the same base_delay/max_delay/
exceptions parameters RabbitSubscriber already has, all defaulted, so
this is backwards compatible with every existing single-arg call site
(grepped this repo -- fastloom/launcher/utils.py and
tests/kafka/conftest.py both just do KafkaSubscriber(settings)).

Found while investigating a production email-spam incident in a
downstream service: a deterministic bug (unrelated to this fix) turned
into hundreds of retries per hour purely because nothing throttled the
redelivery.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VFwpmqkP4XhgJzbdk1vKh
Strip explanatory docstrings/comments (params-only, matching
RabbitSubscriber's style); register the retry middleware directly via
KafkaRouter's middlewares= constructor arg instead of router.broker.
add_middleware; clear _retry_state per (topic, partition) on a
successful consume instead of only ever growing it, closing the
unbounded-memory concern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9p8y4kfiytRMVrzGW4rFN
- guard against ACK_FIRST (offset commits before handler runs, so
  backoff would silently no-op) with a loud RuntimeError instead
- drop bare asserts on broker-sourced topic/partition/offset, matching
  FastStream's own nack() tolerance for None
- don't clobber a different in-flight offset's retry state on success
  (matters once max_workers>1)
- full-jitter both Rabbit and Kafka backoff delays to avoid a
  retry hail storm; Rabbit jitters via per-message AMQP expiration so
  DLX queue naming/TTL (and reuse) stay keyed on the plain delay
- lower Kafka max_delay default 24h -> 30min: this sleep blocks the
  whole subscriber's poll loop, not just the failing partition

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9p8y4kfiytRMVrzGW4rFN
Full jitter (0 to delay) didn't respect the exponential curve - could
collapse a high-attempt delay near zero. Switched to +/-10% around the
capped exponential delay, and factored the shared math (backoff_delay,
with_jitter) into fastloom/signals/utils.py so Rabbit and Kafka can't
drift apart on the formula.

Rabbit's DLX queue naming/TTL still key off the plain (unjittered)
delay for reuse; only the per-message AMQP expiration gets jittered,
so a positive jitter draw is silently clamped back to `delay` by the
queue's own TTL (RabbitMQ takes whichever is lower) - only the
negative half of the range is actually observable there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9p8y4kfiytRMVrzGW4rFN
One function instead of two composed calls - jitter defaults on for
the actual sleep/expiration, Rabbit's queue-naming call opts out
(jitter=False) since it needs the plain delay for stable bucketing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9p8y4kfiytRMVrzGW4rFN
…o signals.rabbit (0.5.0)

BREAKING: fastloom.signals.{depends,settings,middlewares,healthcheck}
moved to fastloom.signals.rabbit.* to mirror signals.kafka.* - every
import fixed across fastloom, tests, docs, README, and the
add-rabbit-subscriber skill.

KafkaSubscriber now:
- defaults the broker-wide ack_policy to NACK_ON_ERROR (reaching into
  router.broker.config.broker_config.ack_policy - the one mutable
  field the read-only composed ack_policy property actually reads
  from, since KafkaRouter's own constructor doesn't expose it).
  Individual @subscriber(...) calls still override; one that
  deliberately stays on ACK_FIRST just gets its backoff silently
  skipped, no more masking the real exception with a RuntimeError.
- exposes allow_auto_create_topics (default True, wire TC.general.DEBUG
  from the launcher to disable outside dev) and acks/enable_idempotence
  (default 1/False) as real constructor params instead of only being
  reachable via raw KafkaBroker.

Added tests/test_signals_utils.py to directly verify
exponential_backoff's doubling and cap behavior across several
attempts, not just one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9p8y4kfiytRMVrzGW4rFN
@aradng aradng changed the title fix: give KafkaSubscriber exponential retry backoff, matching RabbitSubscriber feat!: Kafka retry backoff w/ jitter, NACK_ON_ERROR default, move rabbit to signals.rabbit Jul 21, 2026
@aradng aradng changed the title feat!: Kafka retry backoff w/ jitter, NACK_ON_ERROR default, move rabbit to signals.rabbit refactor: split signals into rabbit/kafka subpackages Jul 21, 2026
aradng and others added 3 commits July 21, 2026 16:15
…ests, add Kafka subscriber skill

- fastloom.signals.utils -> fastloom.utils: exponential_backoff has zero
  pubsub coupling, belongs with date.py/crypto.py/types.py, not nested
  under signals
- tests/test_rabbit_backoff.py -> tests/rabbit/test_backoff.py, mirroring
  tests/kafka/
- add plugins/fastloom-sdk/skills/add-kafka-subscriber, adapted from
  add-rabbit-subscriber for Kafka's actual API (no subscriber() wrapper,
  ack_policy/auto_offset_reset/max_workers gotchas); update plugin.json
  description and fastloom-reference's cross-reference lists

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9p8y4kfiytRMVrzGW4rFN
…rkers advice, idempotence/acks conflict

Verified against the actual FastStream/librdkafka source and a live
confluent_kafka.Producer construction, not just review-agent claims:

- max_delay default 1800 -> 240: FastStream's own max_poll_interval_ms
  default is 5min, and the backoff sleep never calls poll() in between,
  so anything near the old 30min cap got rebalanced out of the consumer
  group mid-backoff - the opposite of throttling. Doc explains the
  coupling and how to raise both together if you want longer backoff.
- pulled the max_workers>1 mitigation advice from the docstring, docs,
  and the new skill: KafkaMessage.ack() commits the consumer's current
  position, not a specific offset, so a concurrent handler acking a
  later offset can commit past an earlier one still asleep in backoff -
  a crash in that window permanently skips it.
- enable_idempotence=True now forces acks="all" instead of trusting the
  caller to also pass acks="all" themselves - confirmed empirically
  that librdkafka's Producer rejects enable.idempotence with any other
  acks value at construction time (acks=1 raises KafkaException immediately).
- get_kafka_router's allow_auto_create_topics/acks/enable_idempotence
  are now keyword-only and required (no restated defaults vs.
  KafkaSubscriber.__init__, the single source of truth for those).
- _retry_state -> NamedTuple, pop(key, None) -> del, dropped an
  unnecessary AckPolicy alias, hoisted a repeated header read in
  rabbit/depends.py, fixed a stale signals.healthcheck path in
  docs/healthcheck.md the earlier rename swept past, test helper now
  looks up _RetryMiddleware by name instead of a hardcoded index.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9p8y4kfiytRMVrzGW4rFN
…(review-2)

- _locate() returns one flat _MessageKey(topic, partition, offset)
  NamedTuple instead of a nested (key, offset) tuple - _backoff/
  _clear_retry_state each take one argument instead of splatting two
- _retry_state is now dict[_Partition, _RetryState], both NamedTuples,
  instead of a bare tuple[str, int] key
- consume_scope early-returns call_next(msg) directly when topic/
  partition/offset are missing, instead of wrapping that case in the
  same try/except and special-casing it in the except branch. Drops
  the "missing metadata" log warning as a result - no seam left to
  log from without reintroducing the try/except this was meant to
  avoid.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9p8y4kfiytRMVrzGW4rFN
@aradng
aradng force-pushed the fix/kafka-consumer-retry-backoff branch from 5e7cb4b to 5847a12 Compare July 21, 2026 13:45
@aradng
aradng merged commit c9ab8ef into main Jul 21, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant