At Least Once with Idempotent Consumer in Python

Implements a thread-safe idempotent consumer that processes each unique message exactly once, even when a producer sends duplicates under an at-least-once delivery model.

Medium Python 3.9+ Aug 9, 2026 Reliability & rate limiting 15 views 0 copies

Python code

45 lines
Python 3.9+
import threading
import time
import uuid
from collections import Counter


class IdempotentConsumer:
    def __init__(self):
        self.processed = set()
        self._lock = threading.Lock()

    def consume(self, message_id, payload):
        with self._lock:
            if message_id in self.processed:
                return "duplicate"
            self.processed.add(message_id)
        # Simulate work
        time.sleep(0.01)
        return f"processed: {payload}"


class MockProducer:
    def __init__(self, consumer):
        self.consumer = consumer

    def send_at_least_once(self, payload, times=3):
        results = Counter()
        for _ in range(times):
            msg_id = str(uuid.uuid4())
            if self.consumer.consume(msg_id, payload) == "duplicate":
                results["duplicate"] += 1
            else:
                results["processed"] += 1
        return dict(results)


if __name__ == "__main__":
    consumer = IdempotentConsumer()
    producer = MockProducer(consumer)
    # Each unique message is processed exactly once, retries are duplicates
    first = producer.send_at_least_once("hello")
    second = producer.send_at_least_once("hello")
    print("First batch:", first)
    print("Second batch:", second)
    print("Total unique processed:", len(consumer.processed))

Output

stdout
First batch: {'processed': 3, 'duplicate': 0}
Second batch: {'processed': 0, 'duplicate': 3}
Total unique processed: 3

How it works

The IdempotentConsumer stores every successfully processed message ID in a set guarded by a threading.Lock. The lock ensures atomic check-and-add, preventing two threads from both processing the same ID. The consume method returns "duplicate" for retries, letting the producer count duplicates. The mock producer mimics at-least-once semantics by generating a fresh UUID for each attempt; with the same message content, the consumer rejects repeat IDs. This pattern guarantees that even if the network or broker retries a send, the work is done once per unique message.

Common mistakes

  • Forgetting to check-and-add atomically under the same lock, allowing race conditions
  • Using the message payload as the ID instead of a unique message ID
  • Not clearing processed IDs for truly ephemeral messages, causing unbounded memory growth

Variations

  1. Use a Redis SET with expiry (e.g., SETNX) for distributed idempotency across services
  2. Persist processed IDs in a database table with a unique constraint for durable deduplication

Real-world use cases

  • Consuming events from a Kafka-style queue with exactly-once processing semantics.
  • Handling webhook deliveries where the sender retries on failure and you must avoid duplicate side effects.
  • Processing payment notifications or order updates where reprocessing could charge a customer twice.

Sponsored

Run this sample

Open the browser IDE to tweak the example and see results without installing anything.

Open editor

More from Reliability & rate limiting

Related tutorials and quizzes for this topic.