Lesson 6 · Reliability core
Idempotency & retries
How at-least-once + a smart handler ≈ exactly-once, without transactions.
Your win: explain why at-least-once forces you to handle
duplicates, how an idempotent handler makes a duplicate harmless, and how our
retryable bool decides retry-vs-drop when there's no dead-letter topic.
At-least-once hands you duplicates. Plan for them.
Lesson 5 showed at-least-once can re-deliver an event after a crash or a rebalance. So the same event will hit your handler twice sometimes. The fix isn't to prevent that (expensive) — it's to make processing it twice have the same effect as once. That property is idempotency, and it's the single most important consumer-side habit in Kafka.1
How notification actually does it
The async-notification consumer dedupes against a store keyed by an idempotency id before doing the real work — if it's seen this id, it skips:
internal/notification/transports/kafka/consumer_upsert_async_notification.go:127// Idempotency guard, before processing:
seen := idempotentService.GetOne(ctx, msg.IdempotentID,
IdempotentTypeUpsertAsyncNotificationKafka)
if seen != nil { return false, nil } // already handled → drop, no re-work
idempotentService.Insert(ctx, msg.IdempotentID, ...) // mark handled
processNotification(ctx, msg) // safe now
Because the record lives in the database, it survives restarts — so a duplicate delivered after a crash still gets caught. Note there are no Kafka transactions here at all; the guarantee is entirely application-level.
The retry contract: the retryable bool
Recall our handler signature returns (retryable bool, err error)
(Lesson 1's glossary term). That bool is our retry policy:
| Handler returns | Meaning | Wrapper does |
|---|---|---|
(false, nil) | success | commit / advance, move on |
(true, err) | transient failure | wait, retry up to RetryLogicAttempts (default 10) |
(false, err) | permanent failure | give up — drop (loose) or stall (strict) |
The retry loop (wait WaitingTimeToRetryLogic, retry N times) lives at
internal/golibs/kafka/consumer.go:110. spike's email
handler shows the judgement call in action:
SendGrid failed? → return true, err // transient — retry
ErrPayloadTooLarge? → return false, err // retrying can't help — don't
status update failed
AFTER a successful send → return false, err // avoid double-send
The missing dead-letter topic
Many Kafka systems route give-up messages to a dead-letter topic (DLQ) for
later inspection. Our repo has none. So a permanent failure is
either logged-and-dropped (loose / the handleMessageStrictly path,
consumer_upsert_async_notification.go:107) or it stalls
the partition (strict). Knowing this gap is real, current knowledge of your own
system — exactly the kind of thing a good interviewer probes.2
Confluent — Message Delivery Guarantees (idempotency & EOS)
See how Kafka's built-in exactly-once compares to app-level idempotency, so you can argue why we chose the latter.
Check yourself (from memory)
Q1. An idempotent handler is one where processing an event twice…
Q2. spike returns (false, err) for ErrPayloadTooLarge because…
true instead.
Q3. When a handler permanently fails and there's no DLQ, the message is…
1. Confluent — Message Delivery Guarantees.
2. Repo: repo-kafka-map.md — idempotency, the retryable contract, no DLQ.