fix(consumer): retry fetch failures instead of taking the group down - #596
Merged
Conversation
A moving partition leader used to stop GenConsumer on the fetch error, and
because ConsumerGroup supervises with max_restarts: 0, one leaderless partition
killed every consumer in the group. Most visible at replication-factor 1, where
the partition is genuinely leaderless for the length of a broker restart.
The fetch loop now retries errors Retry.fetch_retryable?/1 accepts — leader
moves, :no_broker, closed sockets, timeouts, and non-atom transport reasons
(SSL {:tls_alert, _}) normalised to a retryable :transport_error — with jittered
exponential backoff held as a deadline in state, matching brod/KafkaJS/librdkafka.
The backoff also covers a failed :offset_out_of_range reset (previously a
MatchError). Retries are unbounded, but a partition stuck past
:fetch_unavailable_warn_ms (default 30s) is surfaced once via Logger.error and a
[:kafka_ex, :consumer, :partition_unavailable] telemetry event.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A fetch error used to stop the consumer:
handle_info(:timeout)returned{:stop, reason, state}on any{:error, reason}. BecauseKafkaEx.Consumer.ConsumerGroupsupervises withmax_restarts: 0, one transient fetch failure — a leader move, a closed socket, an SSL alert — took the whole group down. This branch makes the consumer's fetch loop back off and retry transient/leadership errors, and only stop on errors that are genuinely fatal.Changes
gen_consumer.ex— retry loophandle_info(:timeout)now honours a backoff deadline held in state (fetch_retry_at) rather than stopping. It has to be a state deadline, not a bare GenServer timeout, because any inbound message re-arms the callback at timeout 0.handle_fetch_error/2: ifRetry.fetch_retryable?/1accepts the error, compute a jittered exponential backoff (:fetch_retry_base_delay_ms250 →:fetch_retry_max_delay_ms5000, both app-env settable) and reschedule; otherwise{:stop, reason, state}. Retries are unbounded, matching brod / KafkaJS / librdkafka.:fetch_unavailable_warn_ms(default 30000) of continuous failure, as aLogger.errorplus a[:kafka_ex, :consumer, :partition_unavailable]telemetry event. The consumer keeps retrying. Mirrors librdkafka'stopic.metadata.propagation.max.ms.handle_offset_out_of_rangenow returns{:ok, state}/{:error, reason}instead of pattern-matching the reset offset with=. The reset needs a live leader too, so it fails exactly during a leader move — this hands the error back to the backoff path instead of crashing with aMatchError.retry.exfetch_retryable?/1=transient_error?/1 or leadership_error?/1— the consumer fetch-loop retriability predicate.transient_error?(:transport_error)→ true.backoff_delay/3uses an integer bit-shift with a clamped exponent instead of:math.pow, which overflows toArithmeticErrorpast attempt 1023 (reachable now that fetch retries are unbounded).client.exbuild_transport_error/1maps a non-atom reason (an SSL{:tls_alert, _}tuple) to a retryable:transport_errorinstead of:unknown, which the fetch loop treated as fatal — this is what took TLS clusters' groups down.telemetry.ex[:kafka_ex, :consumer, :partition_unavailable]event +emit_partition_unavailable/5, added to the consumer-group event list and moduledoc.Tests
gen_consumer_fetch_retry_test.exs: retry-not-stop on:no_brokerand on an SSL-alert transport error (asserted via process{:DOWN}monitoring), a call served mid-backoff not stranding the consumer, still-stop on a non-retryable error, the once-only unavailable telemetry, recovery once the leader returns, and backoff on an unreachable offset reset.retry_test.exs,telemetry_test.exs,mock_client.exupdated.Worth a closer look
:noneauto_offset_resetstill raises on offset-out-of-range, andload_offsets/1still raises if the starting offset can't be read, so a consumer that starts while its leader is moving still fails to start. Only the running consumer is now resilient. Documented as an intentional limit.🤖 Generated with Claude Code