How Netflix‑Inspired Real‑Time Pipeline Patterns Make a Simple Todo API Resilient

You already monitor JSON API freshness, but a single flaky endpoint can still stall your ingestion and break SLA guarantees. This post shows how to wrap a simple Todo API with Netflix‑inspired circuit breaker, bulkhead, and fan‑out patterns so that the pipeline stays fast and stays up—even when the remote service intermittently times out or returns errors.

Key Takeaways

  • Circuit breaker stops cascading failures by opening after a configurable number of consecutive timeouts.
  • Bulkhead (implemented with a ThreadPoolExecutor semaphore) caps concurrent work, protecting downstream resources.
  • Fan‑out via parallel workers raises throughput without sacrificing control, and measurable latency metrics reveal the gains.
  • The patterns add only a few milliseconds of overhead in the healthy path, yet they dramatically improve fault tolerance.
  • All three patterns are lightweight enough to drop into existing Python ingestion scripts.

Data and Sources

Netflix‑Inspired Real‑Time Pipeline Patterns

Circuit Breaker

A circuit breaker watches a downstream call and flips to open after a threshold of failures (here, three consecutive timeouts). While open, calls fail fast, preventing threads from queuing behind a hung service. After a cool‑down period the breaker attempts a “half‑open” probe; a successful response closes the circuit.

Bulkhead

Bulkheading isolates a part of the system by limiting the number of concurrent executions. In Python we achieve this with a ThreadPoolExecutor whose max_workers equals the bulkhead size. If the pool is saturated, additional work is queued, protecting downstream resources from overload.

Fan‑out

Fan‑out means sending many independent requests in parallel. With a bounded executor we get parallelism without unbounded thread creation. The pattern boosts throughput for APIs that can handle concurrent reads, such as the Todo endpoint.

Why These Patterns Matter for Real‑Time Ingestion

When you poll an API every few seconds, a single timeout can block the whole batch, causing downstream freshness alerts to fire. By combining a circuit breaker (to short‑circuit repeated failures), a bulkhead (to keep resource usage bounded), and fan‑out (to keep latency low), the ingestion remains responsive and observable.

Implementing the Patterns in Code

Configuration

We define three tunables: BULKHEAD_LIMIT (max concurrent workers), TIMEOUT_SECONDS for HTTP calls, and FAILURE_THRESHOLD for the breaker. Adjust these values to match your production SLA.

Circuit Breaker Class

The CircuitBreaker tracks consecutive failures, opens after the threshold, and automatically closes after a cooldown if a probe succeeds. It raises a custom BreakerOpenError when open, allowing the caller to treat it as a fast failure.

Worker Function

Each worker calls fetch_todos() through the breaker, measures elapsed time, and returns a tuple indicating success or failure. Timeouts and non‑200 responses are caught and counted as failures.

Executor Loop & Metrics

We run a fixed number of batches (e.g., five). For each batch we submit BULKHEAD_LIMIT tasks to the executor, wait for them, aggregate latency, success count, failure count, and the breaker’s current state. After all batches we dump the aggregated data to pipeline_metrics.json for downstream monitoring (e.g., Prometheus or dbt alerts).

Limitations and Tradeoffs

  • Latency overhead: In a healthy system the breaker adds a few milliseconds per call (state checks, timing). If latency budgets are sub‑millisecond, a more specialized library (e.g., pybreaker) may be preferable.
  • Custom breaker simplicity: Our implementation lacks advanced features like rolling windows, percentile‑based thresholds, or external metrics dashboards. For large fleets you may want a battle‑tested library.
  • ThreadPoolExecutor & GIL: CPU‑bound work still competes for the Global Interpreter Lock. Our pattern is I/O‑bound (network calls), so threads are appropriate; for massive parallelism consider asyncio or a process pool.
  • Scalability: The demo uses a single endpoint; real‑world pipelines often need per‑resource bulkheads or hierarchical circuit breakers.
  • Partial data: If the API returns a subset of fields, downstream validation must handle missing keys; the pattern does not auto‑repair data quality.

Frequently Asked Questions

How does the breaker reset after a failure?

After the breaker opens, a cooldown timer (default 10 s) starts. The next call after the cooldown attempts a request; a successful response resets failure_count to zero and closes the circuit.

Can I use asyncio instead of threads?

Yes. The same logical patterns map to asyncio.Semaphore for bulkhead and an async-aware circuit breaker. The thread‑based version is simpler for engineers already comfortable with concurrent.futures.

What if the API returns partial data?

The worker validates the expected keys (userId, id, title, completed). Missing keys are logged as failures, incrementing the breaker’s count. You can extend the validation logic to fallback to defaults or trigger alerts.

How can I persist metrics for monitoring?

In the script we write a JSON file (pipeline_metrics.json). In production you would push the same payload to a time‑series store (Prometheus, InfluxDB) or a log aggregation service, where alerts can be defined on latency or success‑rate thresholds.

Does this approach work with authentication‑required APIs?

Absolutely. The only change is to add the appropriate auth headers or token refresh logic inside fetch_todos(). The resilience patterns remain unchanged.

Recommendation and Next Step

Start by adding a bulkhead to your existing ingestion script—set max_workers to a safe concurrency level. Once you have bounded parallelism, layer in the circuit breaker to protect against repeated timeouts. Finally, enable fan‑out by submitting multiple fetch tasks per batch and instrument the latency/success metrics as shown. Export the generated pipeline_metrics.json to your monitoring stack (Prometheus, dbt freshness alerts, etc.) and set alert thresholds based on the observed baseline.

Complete Script

#!/usr/bin/env python3
"""
Resilient Todo ingestion using Netflix‑style patterns.

- Bulkhead: ThreadPoolExecutor with limited workers.
- Circuit breaker: opens after 3 consecutive timeouts.
- Fan‑out: parallel fetches per batch.
- Metrics: per‑batch latency, success/failure counts, breaker state.
- Output: console table + pipeline_metrics.json
"""

import json
import logging
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Tuple

import requests

# ----------------------------------------------------------------------
# Configuration
# ----------------------------------------------------------------------
BULKHEAD_LIMIT = 5          # max concurrent workers (bulkhead size)
TIMEOUT_SECONDS = 2         # HTTP request timeout
FAILURE_THRESHOLD = 3       # consecutive failures before opening breaker
COOLDOWN_SECONDS = 10       # breaker cool‑down before half‑open probe
BATCH_COUNT = 5             # number of fan‑out batches to run
OUTPUT_FILE = "pipeline_metrics.json"

# ----------------------------------------------------------------------
# Logging setup
# ----------------------------------------------------------------------
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
    datefmt="%H:%M:%S",
)

# ----------------------------------------------------------------------
# Circuit breaker implementation
# ----------------------------------------------------------------------
class BreakerOpenError(RuntimeError):
    """Raised when the circuit breaker is open."""


class CircuitBreaker:
    def __init__(self, failure_threshold: int, cooldown: int):
        self.failure_threshold = failure_threshold
        self.cooldown = cooldown
        self.failure_count = 0
        self.state = "CLOSED"  # CLOSED, OPEN, HALF_OPEN
        self.open_since = None

    def _reset(self):
        self.failure_count = 0
        self.state = "CLOSED"
        self.open_since = None
        logging.debug("Breaker closed")

    def _open(self):
        self.state = "OPEN"
        self.open_since = time.time()
        logging.warning("Breaker opened after %d failures", self.failure_threshold)

    def _maybe_half_open(self):
        if self.state == "OPEN" and (time.time() - self.open_since) >= self.cooldown:
            self.state = "HALF_OPEN"
            logging.info("Breaker transitioning to HALF_OPEN")

    def call(self, func, *args, **kwargs):
        self._maybe_half_open()
        if self.state == "OPEN":
            raise BreakerOpenError("Circuit breaker is OPEN")
        try:
            result = func(*args, **kwargs)
        except Exception as exc:
            self.failure_count += 1
            logging.debug("Breaker observed failure %d/%d", self.failure_count, self.failure_threshold)
            if self.failure_count >= self.failure_threshold:
                self._open()
            raise exc
        else:
            if self.state == "HALF_OPEN":
                # Successful probe – close the breaker
                self._reset()
            else:
                # Normal successful call – keep closed
                self.failure_count = 0
            return result

    def __repr__(self):
        return f"CircuitBreaker(state={self.state}, failures={self.failure_count})"


breaker = CircuitBreaker(failure_threshold=FAILURE_THRESHOLD, cooldown=COOLDOWN_SECONDS)

# ----------------------------------------------------------------------
# Worker that fetches the Todo list through the breaker
# ----------------------------------------------------------------------
API_URL = "https://jsonplaceholder.typicode.com/todos"


def _http_get():
    """Low‑level HTTP GET with timeout and basic status check."""
    response = requests.get(API_URL, timeout=TIMEOUT_SECONDS)
    if response.status_code != 200:
        raise RuntimeError(f"Unexpected status {response.status_code}")
    return response.json()


def fetch_todos() -> Tuple[bool, float]:
    """
    Fetch todos via the circuit breaker.

    Returns:
        (success, elapsed_ms)
    """
    start = time.time()
    try:
        data = breaker.call(_http_get)
    except BreakerOpenError:
        logging.debug("Breaker open – fast failure")
        return False, (time.time() - start) * 1000
    except (requests.exceptions.Timeout, RuntimeError) as exc:
        logging.warning("Fetch failed: %s", exc)
        return False, (time.time() - start) * 1000
    else:
        # Basic validation – ensure expected keys exist in at least one record
        if isinstance(data, list) and data:
            sample = data[0]
            required = {"userId", "id", "title", "completed"}
            if not required.issubset(sample.keys()):
                logging.error("Missing expected fields in response")
                return False, (time.time() - start) * 1000
        return True, (time.time() - start) * 1000


# ----------------------------------------------------------------------
# Main orchestration
# ----------------------------------------------------------------------
def main():
    metrics = []

    with ThreadPoolExecutor(max_workers=BULKHEAD_LIMIT) as executor:
        for batch_num in range(1, BATCH_COUNT + 1):
            batch_start = time.time()
            futures = [executor.submit(fetch_todos) for _ in range(BULKHEAD_LIMIT)]
            successes = failures = 0
            elapsed_times = []

            for fut in as_completed(futures):
                success, elapsed_ms = fut.result()
                elapsed_times.append(elapsed_ms)
                if success:
                    successes += 1
                else:
                    failures += 1

            batch_elapsed = (time.time() - batch_start) * 1000
            breaker_state = breaker.state

            batch_record = {
                "batch": batch_num,
                "batch_elapsed_ms": round(batch_elapsed, 2),
                "avg_request_ms": round(sum(elapsed_times) / len(elapsed_times), 2),
                "successes": successes,
                "failures": failures,
                "breaker_state": breaker_state,
            }
            metrics.append(batch_record)

            # Console table line
            logging.info(
                "Batch %d | elapsed %d ms | success %d | fail %d | breaker %s",
                batch_num,
                round(batch_elapsed),
                successes,
                failures,
                breaker_state,
            )

    # Persist metrics
    with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
        json.dump(metrics, f, indent=2)
    logging.info("Metrics written to %s", OUTPUT_FILE)


if __name__ == "__main__":
    main()

Post a Comment

Hi! How can we help you? Send us a message and we'll get back to you.