Handling Partial Failures in Production Systems

By Oleksandr Andrushchenko — Published on

Handling Partial Failures in Production Systems
Handling Partial Failures in Production Systems

A distributed system rarely fails as one complete unit. More often, one dependency becomes slow, one availability zone loses connectivity, one database replica falls behind, or one request times out while the operation still completes in the background.

These partial failures are difficult because the caller cannot always determine whether an operation failed, succeeded, or is still running. Production resilience depends on combining timeouts, retries, idempotency, circuit breakers, isolation, load shedding, and graceful degradation without creating retry storms or duplicated side effects.

Table of Contents

Why Partial Failures Are Different

In a single-process application, a function either returns, raises an error, or the process terminates. Distributed calls have an additional state: the outcome may be unknown.

A caller can time out while the remote service continues processing. Retrying may be necessary, but the retry can also duplicate the original operation.

Client              API              Payment Provider
  │                  │                       │
  │── create order ─►│                       │
  │                  │── charge card ───────►│
  │                  │                       │
  │                  │       charge succeeds│
  │                  │◄──── response lost ──X│
  │                  │                       │
  │◄──── timeout ────│                       │
  │                  │                       │
  │── retry order ──►│── charge again ─────►│

The timeout tells the client that no response arrived before the deadline. It does not prove that the remote operation failed.

Partial failures create several forms of uncertainty:

  • Transport uncertainty: the request or response may have been lost.
  • Execution uncertainty: the remote operation may still be running.
  • Commit uncertainty: a database transaction may have committed before the connection failed.
  • Replication uncertainty: one replica may contain newer data than another.
  • Ownership uncertainty: a failed worker may recover after another worker takes over.

Production resilience is therefore not based on eliminating failures. It is based on limiting failure propagation and making recovery safe.

Common Types of Partial Failures

Partial failures appear at every layer of a distributed architecture. Different failure types require different handling because a retry that helps one failure may amplify another.

Network Failures

Networks can drop, delay, duplicate, or reorder packets. A connection can be established successfully and fail while the response is being transmitted.

Common symptoms include connection resets, DNS failures, TLS handshake errors, packet loss, and timeouts.

Production impact: callers may retry an operation that already completed, or hold threads and connections while waiting for a response that will never arrive.

Recommended response: use bounded timeouts, retry only safe operations, attach idempotency keys to state-changing requests, and record the remote operation identifier when available.

Slow Services

A service can remain technically available while responding too slowly to be useful. Slow database queries, overloaded worker pools, garbage collection, and downstream saturation can all increase latency.

Slow dependencies are often more dangerous than immediate failures because requests continue accumulating while resources remain occupied.

Normal dependency:

100 requests/sec
20 ms average latency
≈ 2 concurrent requests


Slow dependency:

100 requests/sec
5 sec latency
≈ 500 concurrent requests

Concurrency grows roughly with arrival rate multiplied by response time. A latency increase can therefore exhaust connection pools, memory, threads, and file descriptors even when request volume remains unchanged.

Service Crashes

A service may terminate before processing, during processing, or after committing state but before acknowledging success.

The recovery strategy depends on where durable progress was recorded. Stateless requests can be retried elsewhere, while stateful workflows require checkpoints or idempotency records.

Production note: process restart is not sufficient recovery when the crashed process owned in-memory queues, locks, or uncommitted workflow state.

Dependency Failures

A healthy service can fail because one of its dependencies is unavailable. A product API may depend on pricing, inventory, recommendations, authentication, and database services.

Product API
├── Product Database
├── Inventory Service
├── Pricing Service
└── Recommendation Service

Not every dependency is equally critical. Product data may be mandatory, while recommendations may be optional.

A resilient design classifies dependencies as:

  • Required: the request cannot produce a valid result without the dependency.
  • Degradable: the request can return reduced functionality.
  • Asynchronous: work can be completed later without blocking the response.

Resource Exhaustion

Services fail partially when a limited resource is exhausted. The process may remain alive while being unable to serve new requests.

Typical exhausted resources include:

  • database connections;
  • HTTP connection pools;
  • worker threads;
  • CPU time;
  • memory;
  • disk space;
  • message-consumer capacity;
  • external API quotas.

Retries often make resource exhaustion worse. A service already unable to process 1,000 requests should not receive 3,000 retry attempts.

Stale and Inconsistent State

Replicated systems may return different results from different nodes. A write can succeed on the leader while a replica remains behind.

A client that writes data and immediately reads from a stale replica may incorrectly conclude that the write failed.

Recommended response: define consistency requirements explicitly. Use leader reads, session consistency, version checks, or read-after-write routing when stale data would trigger incorrect recovery.

Failure Handling Patterns

No single resilience pattern handles every failure. Timeouts limit waiting, retries recover transient errors, circuit breakers reduce pressure, bulkheads isolate resources, and graceful degradation preserves useful functionality.

Timeouts

A timeout limits how long a caller waits for an operation. Every remote call should have a bounded deadline because the default operating-system timeout may be far longer than the user or upstream service can tolerate.

Different phases may require different limits:

  • Connection timeout: maximum time to establish a connection.
  • Read timeout: maximum wait for response data.
  • Write timeout: maximum time to transmit the request.
  • Pool timeout: maximum wait for an available connection.
  • Overall deadline: total time allowed for the operation.
Incoming request deadline: 2,000 ms

Authentication:       150 ms
Database query:       500 ms
Payment provider:    900 ms
Response serialization:
                      100 ms
Safety margin:        350 ms

Downstream timeouts must fit inside the upstream request deadline. A service with two seconds remaining should not start a downstream call configured to wait five seconds.

Advantages

  • Prevents requests from waiting indefinitely.
  • Releases connections and worker capacity.
  • Limits latency propagation through service chains.

Disadvantages

  • A timeout may occur while the remote operation succeeds.
  • Aggressive values create false failures.
  • Static values may not fit every operation.

When to Use / Real-World Use Cases

Use timeouts for every database, cache, message broker, HTTP, DNS, and external API operation. Choose values from service-level objectives and measured tail latency rather than arbitrary defaults.

Example

import httpx

timeout = httpx.Timeout(
    connect=0.5,  # Fail quickly when a host cannot be reached.
    read=1.0,     # Bound the wait for response data.
    write=0.5,    # Avoid blocking while sending a request.
    pool=0.2,     # Do not wait indefinitely for a pooled connection.
)

Retries

Retries repeat operations that may have failed because of a temporary condition. They work well for short network interruptions, leader changes, throttling, and transient dependency errors.

Retries are dangerous when the operation is not idempotent or the dependency is overloaded.

Advantages

  • Recover from brief transient failures.
  • Hide short failovers from callers.
  • Improve success rates without manual intervention.

Disadvantages

  • Increase dependency load during incidents.
  • Extend request latency.
  • Can duplicate non-idempotent operations.
  • Can hide persistent failures until retry budgets are exhausted.

When to Use / Real-World Use Cases

Retry connection resets, selected timeouts, HTTP 429 responses, and temporary 5xx responses when the operation is safe to repeat. Do not retry validation failures, authentication errors, or deterministic business-rule failures.

Example

Failure Retry? Reason
HTTP 429 Usually Retry after the server-provided delay.
HTTP 503 Usually The service may be temporarily unavailable.
Connection reset Sometimes Safe only when request semantics are idempotent.
HTTP 400 No The same invalid request will fail again.
HTTP 401 Not without correction Credentials must be refreshed or fixed first.
Insufficient inventory No This is a business outcome, not a transient infrastructure error.

Exponential Backoff

Exponential backoff increases the delay after each failed attempt. This gives the dependency time to recover and reduces repeated pressure.

def backoff_delay(
    attempt: int,
    base_seconds: float = 0.2,
    maximum_seconds: float = 5.0,
) -> float:
    """
    Increase retry delay exponentially while enforcing an upper bound.
    """
    return min(base_seconds * (2 ** attempt), maximum_seconds)

A retry sequence with a 200-millisecond base may wait approximately 200 milliseconds, 400 milliseconds, 800 milliseconds, and 1.6 seconds.

Advantages

  • Reduces pressure during prolonged failures.
  • Allows dependencies time to recover.
  • Works well with rate limiting and temporary unavailability.

Disadvantages

  • Increases total operation latency.
  • Identical clients may still retry simultaneously.
  • Large retry counts can exceed the request deadline.

When to Use / Real-World Use Cases

Use exponential backoff for temporary network failures, service failovers, rate limits, and message reprocessing. Stop when the operation deadline or retry budget is exhausted.

Example

Attempt 1 ── fails
             wait 200 ms

Attempt 2 ── fails
             wait 400 ms

Attempt 3 ── fails
             wait 800 ms

Attempt 4 ── final failure

Jitter

Jitter adds randomness to retry delays. Without it, thousands of clients that fail at the same time may retry at the same intervals.

Without jitter:

Client A: retry at 1s, 2s, 4s
Client B: retry at 1s, 2s, 4s
Client C: retry at 1s, 2s, 4s

With jitter:

Client A: retry at 0.7s, 2.4s, 3.6s
Client B: retry at 1.2s, 1.8s, 4.5s
Client C: retry at 0.9s, 2.7s, 3.3s

Advantages

  • Spreads retries over time.
  • Reduces synchronized traffic spikes.
  • Improves recovery after shared outages.

Disadvantages

  • Makes exact retry timing less predictable.
  • Requires a bounded strategy to avoid excessive delay.

When to Use / Real-World Use Cases

Use jitter whenever many clients can experience the same failure, including service outages, deployment restarts, cache expiration, and rate-limit recovery.

Example

import random


def full_jitter_delay(
    attempt: int,
    base_seconds: float = 0.2,
    maximum_seconds: float = 5.0,
) -> float:
    """
    Select a random delay between zero and the exponential cap.
    """
    cap = min(base_seconds * (2 ** attempt), maximum_seconds)
    return random.uniform(0, cap)

Circuit Breakers

A circuit breaker stops sending requests to a dependency after failures exceed a threshold. It protects the caller from repeatedly consuming resources on an unhealthy dependency.

                 failure threshold reached
CLOSED ─────────────────────────────────────► OPEN
  ▲                                             │
  │                                             │ wait recovery interval
  │                                             ▼
  └──────── successful test request ───── HALF-OPEN
                               failure ────────► OPEN

Closed allows normal requests. Open rejects requests immediately. Half-open allows a small number of test requests to determine whether recovery has occurred.

Advantages

  • Fails quickly while a dependency is unhealthy.
  • Reduces connection and thread consumption.
  • Gives the dependency time to recover.
  • Can trigger fallbacks immediately.

Disadvantages

  • Poor thresholds can open during normal latency variation.
  • Local breakers may disagree across application instances.
  • An open breaker intentionally rejects requests that might succeed.

When to Use / Real-World Use Cases

Use circuit breakers around remote dependencies where repeated failures consume meaningful resources. Typical examples include payment gateways, recommendation services, external APIs, and overloaded databases.

Example

A product API opens the recommendation-service circuit after ten failures within thirty seconds. Product requests continue without recommendations until periodic test requests succeed.

Bulkheads

Bulkheads isolate resources so one failing dependency or workload cannot exhaust the entire application.

Application
├── Payment pool:        20 connections
├── Inventory pool:     30 connections
├── Recommendation pool: 5 connections
└── Internal database:  40 connections

If the recommendation service becomes slow, only its five connections remain occupied. Payment and inventory operations continue using separate capacity.

Advantages

  • Limits blast radius.
  • Protects critical workloads from optional workloads.
  • Makes capacity allocation explicit.

Disadvantages

  • Reserved capacity may remain unused.
  • Requires separate pools, queues, or worker groups.
  • Incorrect limits can underutilize or starve workloads.

When to Use / Real-World Use Cases

Use bulkheads when one process handles workloads with different criticality or dependencies with different failure characteristics.

Example

Background exports run in a separate worker queue from checkout operations. A large export backlog cannot consume the workers needed to place orders.

Load Shedding

Load shedding rejects low-priority or excessive work before the system becomes fully saturated. Controlled rejection preserves capacity for requests that can still complete successfully.

Advantages

  • Prevents queue growth and cascading latency.
  • Protects critical traffic.
  • Allows faster recovery after overload.

Disadvantages

  • Some valid requests are intentionally rejected.
  • Prioritization rules can be difficult to define.
  • Clients must handle rejection correctly.

When to Use / Real-World Use Cases

Use load shedding when queues, concurrency, memory, or downstream capacity approach safe limits. Reject optional analytics, expensive searches, or low-priority batch work before checkout or authentication traffic.

Example

from fastapi import HTTPException


def reject_when_saturated(
    active_requests: int,
    maximum_requests: int,
) -> None:
    """
    Reject new work before resource exhaustion causes system-wide failure.
    """
    if active_requests >= maximum_requests:
        raise HTTPException(
            status_code=503,
            detail="Service is temporarily at capacity",
            headers={"Retry-After": "2"},
        )

Graceful Degradation

Graceful degradation returns reduced but valid functionality when an optional dependency fails.

Advantages

  • Preserves core functionality.
  • Reduces user-visible outages.
  • Allows optional dependencies to fail independently.

Disadvantages

  • Fallback data may be stale or incomplete.
  • Degraded behavior can hide dependency failures.
  • Fallback logic increases test complexity.

When to Use / Real-World Use Cases

Use graceful degradation when the response remains correct without optional enrichment. Examples include omitting recommendations, returning cached pricing with an expiration notice, or delaying analytics events.

Example

{
  "product_id": "p-4821",
  "name": "Mechanical Keyboard",
  "price": 129.99,
  "recommendations": [],
  "degraded_components": [
    "recommendation-service"
  ]
}

Failure Pattern Comparison

Resilience patterns address different stages of failure propagation and are commonly combined.

Pattern Primary Purpose Best For Main Risk
Timeout Bound waiting time Every remote operation Unknown operation outcome
Retry Recover transient failures Temporary network or service errors Duplicate work and added load
Exponential backoff Reduce retry pressure Longer transient incidents Extended total latency
Jitter Desynchronize clients Shared outages and rate limits Less predictable timing
Circuit breaker Stop repeated failing calls Unhealthy remote dependencies Rejecting requests during partial recovery
Bulkhead Isolate resource usage Mixed workloads and dependencies Unused reserved capacity
Load shedding Prevent total saturation Traffic spikes and overload Intentional request rejection
Graceful degradation Preserve partial functionality Optional dependencies Stale or incomplete responses

Failure Detection

Failure handling depends on detecting unhealthy behavior without incorrectly removing healthy instances. Detection should combine active checks, passive request data, and end-to-end monitoring.

Health Checks

Health endpoints expose whether a process is alive and whether it is ready to receive traffic.

Liveness answers whether the process should be restarted. Readiness answers whether the process should receive new requests.

Check Question Failure Action
Liveness Is the process stuck or irrecoverable? Restart the container or process.
Readiness Can the process serve traffic now? Remove it from load balancing.
Startup Has initialization completed? Delay liveness checks during slow startup.

Liveness checks should not fail because an optional dependency is unavailable. Restarting every application instance during a shared dependency outage can convert a partial failure into a complete outage.

Heartbeats

Heartbeats indicate that workers, consumers, replicas, or leaders remain active. Missing heartbeats trigger suspicion and eventual reassignment.

Heartbeat systems require an expiration policy and a recovery policy. A worker that resumes after expiration must not continue processing work already reassigned to another worker.

Production note: combine heartbeat expiration with ownership generations or fencing tokens when duplicate ownership would be unsafe.

Passive Monitoring

Passive monitoring derives health from real request outcomes. Error rate, latency, connection failures, queue depth, and timeout rates often reveal degradation earlier than a simple health endpoint.

A service can return HTTP 200 from its health endpoint while every business request times out on the database.

Useful passive indicators include:

  • success rate by operation;
  • p95, p99, and p99.9 latency;
  • retry rate;
  • circuit-breaker state;
  • connection-pool wait time;
  • queue age;
  • dependency error rate.

Synthetic Monitoring

Synthetic checks execute controlled end-to-end requests from outside the service. They validate routing, authentication, application logic, dependencies, and response correctness.

Use synthetic transactions for critical flows such as login, checkout, order lookup, and message publication.

Warning: synthetic requests should use isolated test accounts and idempotent cleanup. Monitoring must not create real orders, charges, or customer notifications.

Designing for Failures

Resilience is easier when failure handling is built into data models, APIs, and deployment architecture rather than added after incidents.

Idempotent Operations

An idempotent operation can be repeated without producing additional side effects. Idempotency is essential when callers retry after uncertain outcomes.

A state-changing API can require an idempotency key:

POST /payments
Idempotency-Key: order-93482-payment-v1

The service stores the key with the completed result. Later requests with the same key return the original result instead of executing the operation again.

Operation Naturally Idempotent? Protection Needed
GET resource Usually Read consistency and cache correctness
PUT complete resource state Usually Version checks for concurrent updates
DELETE resource Usually Stable success response after prior deletion
POST charge payment No Idempotency key and durable result storage
Increment balance No Unique operation identifier or conditional state transition

Stateless Services

Stateless service instances do not depend on local memory for durable workflow progress. Any healthy instance can handle the next request.

Session data, idempotency records, job state, and workflow checkpoints should be stored in durable systems when they must survive process replacement.

Trade-off: moving state to shared infrastructure improves failover but adds network calls and dependency complexity.

Fallback Strategies

A fallback provides an alternative result when the preferred dependency is unavailable.

Common strategies include:

  • returning cached data;
  • using a secondary provider;
  • omitting optional enrichment;
  • queueing work for later;
  • returning a stable default;
  • requiring manual review for uncertain outcomes.

Fallbacks must preserve correctness. Returning a cached product description may be acceptable, while returning stale inventory during checkout may oversell stock.

Data Fallback Option Risk
Product description Cached value Low
Recommendations Empty list or popular items Low
Shipping estimate Broader delivery window Moderate
Inventory availability Do not guess High overselling risk
Payment result Pending verification High duplicate-charge risk

Redundancy

Redundancy runs multiple instances, replicas, zones, or providers so one failure does not remove the entire capability.

Redundancy must cover independent failure domains. Three replicas on one host do not protect against host failure.

Region
├── Availability Zone A
│   ├── API instance
│   └── Database replica
├── Availability Zone B
│   ├── API instance
│   └── Database replica
└── Availability Zone C
    ├── API instance
    └── Database replica

Redundancy also introduces coordination problems: replica lag, failover selection, split brain, duplicate consumers, and state synchronization.

Bounded Concurrency

Bounded concurrency limits the number of operations running against a dependency. It prevents one slow dependency from creating unlimited in-flight work.

import asyncio


payment_semaphore = asyncio.Semaphore(20)


async def call_payment_provider(
    request: dict,
) -> dict:
    """
    Limit concurrent payment requests so provider latency cannot consume
    every application connection and task.
    """
    async with payment_semaphore:
        return await send_payment_request(request)

Concurrency limits should be paired with queue limits or acquisition timeouts. Otherwise requests may wait indefinitely before entering the bounded section.

Asynchronous Recovery

Some work does not need to complete inside the original request. Durable queues allow APIs to acknowledge accepted work and complete it asynchronously.

Client
  │
  │── submit export ──► API
  │                     │
  │                     ├── store export request
  │                     └── publish durable job
  │
  │◄── 202 Accepted ────│

Worker
  │── process export
  │── retry transient failures
  └── store final result

Asynchronous recovery is useful for exports, notifications, media processing, synchronization, and other long-running operations.

Queue consumers still need idempotency, dead-letter handling, retry limits, and visibility into message age.

Production Implementation Example

The following example combines request deadlines, idempotency, bounded retries, jitter, a circuit breaker, and Kubernetes health checks for an order API calling an external payment provider.

Resilient Request Flow

The order flow stores durable intent before calling the provider. A stable idempotency key links every retry to the same payment operation.

Client
  │
  │── POST /orders + idempotency key
  ▼
Order API
  │
  ├── reserve idempotency record
  ├── create pending order
  ├── call payment provider
  │      ├── timeout
  │      ├── bounded retries
  │      ├── exponential backoff
  │      ├── jitter
  │      └── circuit breaker
  │
  ├── payment confirmed ──► mark order paid
  │
  └── payment uncertain ──► mark verification pending
                             publish reconciliation job

Unknown payment outcomes should not be converted directly into failure. A reconciliation process can query the provider using the stable external payment identifier.

FastAPI Client with Timeouts and Retries

The client retries only selected transient failures and stops when the overall deadline is exhausted.

from __future__ import annotations

import asyncio
import random
import time
from dataclasses import dataclass

import httpx


class PaymentUnavailableError(RuntimeError):
    pass


class PaymentRejectedError(RuntimeError):
    pass


@dataclass(frozen=True)
class PaymentResult:
    provider_payment_id: str
    status: str


class PaymentClient:
    def __init__(
        self,
        base_url: str,
        maximum_attempts: int = 3,
    ) -> None:
        self.maximum_attempts = maximum_attempts

        self.client = httpx.AsyncClient(
            base_url=base_url,
            timeout=httpx.Timeout(
                connect=0.5,
                read=1.5,
                write=0.5,
                pool=0.2,
            ),
            limits=httpx.Limits(
                # Bound resource usage during provider degradation.
                max_connections=30,
                max_keepalive_connections=15,
            ),
        )

    async def create_payment(
        self,
        order_id: str,
        amount_cents: int,
        idempotency_key: str,
        deadline_monotonic: float,
    ) -> PaymentResult:
        """
        Create one logical payment operation.

        The same idempotency key is sent on every attempt so a timeout cannot
        cause multiple provider charges.
        """
        last_error: Exception | None = None

        for attempt in range(self.maximum_attempts):
            remaining = deadline_monotonic - time.monotonic()

            if remaining <= 0:
                raise PaymentUnavailableError(
                    "Payment deadline was exhausted"
                ) from last_error

            try:
                response = await self.client.post(
                    "/payments",
                    json={
                        "order_id": order_id,
                        "amount_cents": amount_cents,
                    },
                    headers={
                        "Idempotency-Key": idempotency_key,
                    },
                    # Never allow one attempt to exceed the remaining deadline.
                    timeout=min(remaining, 1.5),
                )
            except (
                httpx.ConnectError,
                httpx.ConnectTimeout,
                httpx.ReadTimeout,
                httpx.RemoteProtocolError,
            ) as error:
                last_error = error

                if attempt == self.maximum_attempts - 1:
                    break

                await asyncio.sleep(
                    self._retry_delay(attempt)
                )
                continue

            if response.status_code in {200, 201}:
                payload = response.json()

                return PaymentResult(
                    provider_payment_id=payload["payment_id"],
                    status=payload["status"],
                )

            if response.status_code == 429:
                last_error = PaymentUnavailableError(
                    "Payment provider rate limited the request"
                )

                if attempt == self.maximum_attempts - 1:
                    break

                retry_after = self._parse_retry_after(
                    response.headers.get("Retry-After")
                )

                await asyncio.sleep(
                    min(retry_after, max(0, remaining))
                )
                continue

            if response.status_code in {502, 503, 504}:
                last_error = PaymentUnavailableError(
                    f"Transient provider response: {response.status_code}"
                )

                if attempt == self.maximum_attempts - 1:
                    break

                await asyncio.sleep(
                    self._retry_delay(attempt)
                )
                continue

            if 400 <= response.status_code < 500:
                # Validation and business rejections should not be retried.
                raise PaymentRejectedError(response.text)

            last_error = PaymentUnavailableError(
                f"Unexpected provider response: {response.status_code}"
            )
            break

        raise PaymentUnavailableError(
            "Payment provider did not return a confirmed result"
        ) from last_error

    @staticmethod
    def _retry_delay(attempt: int) -> float:
        """
        Full jitter prevents application replicas from retrying together.
        """
        exponential_cap = min(0.2 * (2 ** attempt), 2.0)
        return random.uniform(0, exponential_cap)

    @staticmethod
    def _parse_retry_after(value: str | None) -> float:
        if value is None:
            return 1.0

        try:
            return max(float(value), 0.0)
        except ValueError:
            return 1.0

The client does not retry all errors. Business rejections and invalid requests return immediately because repeating them would not change the outcome.

Idempotent Order Creation

The API reserves the idempotency key before performing external side effects.

CREATE TABLE idempotency_records (
    idempotency_key VARCHAR(200) PRIMARY KEY,
    request_hash VARCHAR(64) NOT NULL,
    status VARCHAR(30) NOT NULL,
    response_status INTEGER,
    response_body JSONB,
    created_at TIMESTAMP NOT NULL,
    updated_at TIMESTAMP NOT NULL
);

CREATE TABLE orders (
    id UUID PRIMARY KEY,
    idempotency_key VARCHAR(200) NOT NULL UNIQUE,
    status VARCHAR(30) NOT NULL,
    amount_cents INTEGER NOT NULL,
    provider_payment_id VARCHAR(200),
    created_at TIMESTAMP NOT NULL,
    updated_at TIMESTAMP NOT NULL
);

The request hash prevents reuse of the same idempotency key with different request data.

from __future__ import annotations

import hashlib
import json
import time
from uuid import uuid4

from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel, Field


app = FastAPI()


class CreateOrderRequest(BaseModel):
    amount_cents: int = Field(gt=0)


class OrderResponse(BaseModel):
    order_id: str
    status: str


@app.post("/orders", response_model=OrderResponse)
async def create_order(
    request: CreateOrderRequest,
    idempotency_key: str = Header(alias="Idempotency-Key"),
) -> OrderResponse:
    """
    Create an order with durable idempotency protection.
    """
    request_hash = hashlib.sha256(
        json.dumps(
            request.model_dump(),
            sort_keys=True,
        ).encode()
    ).hexdigest()

    existing = await repository.get_idempotency_record(
        idempotency_key
    )

    if existing is not None:
        if existing.request_hash != request_hash:
            raise HTTPException(
                status_code=409,
                detail="Idempotency key was used with different request data",
            )

        if existing.status == "completed":
            # Return the exact previously stored response.
            return OrderResponse.model_validate(
                existing.response_body
            )

        if existing.status == "processing":
            raise HTTPException(
                status_code=409,
                detail="The operation is already being processed",
                headers={"Retry-After": "1"},
            )

    order_id = str(uuid4())

    acquired = await repository.reserve_idempotency_key(
        idempotency_key=idempotency_key,
        request_hash=request_hash,
        order_id=order_id,
        amount_cents=request.amount_cents,
    )

    if not acquired:
        # Another request acquired the same key concurrently.
        raise HTTPException(
            status_code=409,
            detail="The operation is already being processed",
            headers={"Retry-After": "1"},
        )

    try:
        payment = await payment_client.create_payment(
            order_id=order_id,
            amount_cents=request.amount_cents,
            idempotency_key=idempotency_key,
            deadline_monotonic=time.monotonic() + 2.0,
        )
    except PaymentRejectedError:
        response = OrderResponse(
            order_id=order_id,
            status="payment_rejected",
        )

        await repository.complete_order(
            order_id=order_id,
            order_status="payment_rejected",
            idempotency_key=idempotency_key,
            response_body=response.model_dump(),
            response_status=200,
        )

        return response
    except PaymentUnavailableError:
        # The payment outcome may be unknown rather than failed.
        await repository.mark_payment_verification_pending(
            order_id=order_id,
            idempotency_key=idempotency_key,
        )

        await reconciliation_queue.publish(
            {
                "order_id": order_id,
                "idempotency_key": idempotency_key,
            }
        )

        return OrderResponse(
            order_id=order_id,
            status="payment_verification_pending",
        )

    response = OrderResponse(
        order_id=order_id,
        status="paid",
    )

    await repository.complete_order(
        order_id=order_id,
        order_status="paid",
        provider_payment_id=payment.provider_payment_id,
        idempotency_key=idempotency_key,
        response_body=response.model_dump(),
        response_status=200,
    )

    return response

The uncertain outcome is represented explicitly as payment_verification_pending. This prevents the API from incorrectly telling the client to submit a new payment.

Circuit Breaker Implementation

The circuit breaker tracks recent failures and temporarily rejects calls after the failure threshold is reached.

from __future__ import annotations

import asyncio
import time
from enum import Enum
from typing import Awaitable, Callable, TypeVar


T = TypeVar("T")


class CircuitState(str, Enum):
    CLOSED = "closed"
    OPEN = "open"
    HALF_OPEN = "half_open"


class CircuitOpenError(RuntimeError):
    pass


class AsyncCircuitBreaker:
    def __init__(
        self,
        failure_threshold: int = 5,
        recovery_timeout_seconds: float = 10.0,
    ) -> None:
        self.failure_threshold = failure_threshold
        self.recovery_timeout_seconds = recovery_timeout_seconds
        self.state = CircuitState.CLOSED
        self.failure_count = 0
        self.opened_at: float | None = None
        self._half_open_request_in_progress = False
        self._lock = asyncio.Lock()

    async def call(
        self,
        operation: Callable[[], Awaitable[T]],
    ) -> T:
        """
        Execute the operation only when the circuit permits it.

        Only one test request enters while half-open, preventing a recovery
        probe from becoming another traffic spike.
        """
        async with self._lock:
            self._update_state()

            if self.state == CircuitState.OPEN:
                raise CircuitOpenError(
                    "Dependency circuit is open"
                )

            if self.state == CircuitState.HALF_OPEN:
                if self._half_open_request_in_progress:
                    raise CircuitOpenError(
                        "Dependency recovery probe is already running"
                    )

                self._half_open_request_in_progress = True

        try:
            result = await operation()
        except Exception:
            await self._record_failure()
            raise
        else:
            await self._record_success()
            return result

    def _update_state(self) -> None:
        if (
            self.state == CircuitState.OPEN
            and self.opened_at is not None
            and time.monotonic() - self.opened_at
            >= self.recovery_timeout_seconds
        ):
            self.state = CircuitState.HALF_OPEN
            self._half_open_request_in_progress = False

    async def _record_failure(self) -> None:
        async with self._lock:
            self.failure_count += 1
            self._half_open_request_in_progress = False

            if (
                self.state == CircuitState.HALF_OPEN
                or self.failure_count >= self.failure_threshold
            ):
                self.state = CircuitState.OPEN
                self.opened_at = time.monotonic()

    async def _record_success(self) -> None:
        async with self._lock:
            self.state = CircuitState.CLOSED
            self.failure_count = 0
            self.opened_at = None
            self._half_open_request_in_progress = False

A production breaker should commonly use a rolling failure window rather than one lifetime counter. It may also distinguish slow-call thresholds from explicit failures.

Kubernetes Health Configuration

Kubernetes probes should distinguish startup, liveness, and readiness.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: order-api
  template:
    metadata:
      labels:
        app: order-api
    spec:
      containers:
        - name: order-api
          image: example/order-api:1.8.0
          ports:
            - containerPort: 8000

          # Startup probe prevents slow initialization from triggering
          # premature liveness failures.
          startupProbe:
            httpGet:
              path: /health/startup
              port: 8000
            periodSeconds: 2
            failureThreshold: 30

          # Liveness checks only whether the process is irrecoverably stuck.
          # It should not fail because an optional remote service is down.
          livenessProbe:
            httpGet:
              path: /health/live
              port: 8000
            periodSeconds: 10
            timeoutSeconds: 2
            failureThreshold: 3

          # Readiness removes the instance from service when it cannot handle
          # normal requests, without restarting it.
          readinessProbe:
            httpGet:
              path: /health/ready
              port: 8000
            periodSeconds: 5
            timeoutSeconds: 2
            failureThreshold: 2

          resources:
            requests:
              cpu: "250m"
              memory: "256Mi"
            limits:
              cpu: "1000m"
              memory: "512Mi"

The readiness endpoint may check critical local state and connection-pool availability. It should avoid expensive dependency calls on every probe.

Failure Scenarios in Production

Resilience patterns become easier to evaluate when applied to complete failure scenarios.

Payment Provider Timeout

A payment request times out after the provider receives it. The provider may have charged the card even though the API did not receive a response.

Unsafe response: mark the payment failed and ask the client to retry with a new operation identifier.

Safer response:

  1. Use one stable idempotency key for every attempt.
  2. Record a pending payment before the provider call.
  3. Retry only within a bounded deadline.
  4. Mark the result as uncertain if no confirmation arrives.
  5. Query the provider asynchronously using the same payment reference.
  6. Complete or reverse the order after reconciliation.

This approach treats uncertainty as a valid workflow state rather than incorrectly converting it into failure.

Database Connection Pool Exhaustion

Slow queries hold connections longer, causing new requests to wait for the pool. Application latency rises, clients retry, and additional requests consume more memory.

Slow queries
    ↓
Connections remain busy
    ↓
Pool wait time increases
    ↓
Request latency increases
    ↓
Clients retry
    ↓
More concurrent requests
    ↓
Application saturation

Recovery strategy:

  • set a short pool acquisition timeout;
  • limit request concurrency;
  • cancel queries that exceed execution budgets;
  • reject low-priority traffic;
  • identify and optimize the slow query;
  • avoid increasing pool size beyond database capacity.

A larger connection pool can worsen the incident by moving saturation from the application to the database.

Message Consumer Failure

A consumer processes a message, writes to a database, and crashes before acknowledging the message. The broker later delivers it again.

Required protections:

  • unique message or operation identifiers;
  • idempotent state transitions;
  • transactional processing where possible;
  • bounded retries;
  • dead-letter handling for persistent failures;
  • monitoring of oldest-message age.
CREATE TABLE processed_messages (
    consumer_name VARCHAR(100) NOT NULL,
    message_id VARCHAR(200) NOT NULL,
    processed_at TIMESTAMP NOT NULL,
    PRIMARY KEY (consumer_name, message_id)
);

BEGIN;

-- Duplicate delivery produces no new record.
INSERT INTO processed_messages (
    consumer_name,
    message_id,
    processed_at
)
VALUES (
    :consumer_name,
    :message_id,
    CURRENT_TIMESTAMP
)
ON CONFLICT DO NOTHING;

-- Continue only when the message was inserted for the first time.
-- Application code should verify the affected row count before applying
-- the business update in this same transaction.

COMMIT;

Regional Dependency Outage

A service in one region becomes unavailable while the application remains healthy. Immediate failover to another region may be possible, but cross-region consistency and capacity must be considered.

Failover Strategy Advantages Trade-Offs
Retry same region Simple and low latency during brief failures Useless during a sustained regional outage
Fail over to secondary region Restores dependency availability May use stale data or exceed secondary capacity
Return cached response Fast degraded operation Data freshness may be insufficient
Queue for later Preserves work without blocking Not suitable for synchronous decisions
Reject request Preserves correctness Reduces availability

Secondary regions must be tested under real traffic assumptions. A standby with only 20 percent of required capacity does not provide reliable failover.

Observability for Partial Failures

Partial failures can remain hidden because aggregate availability may look healthy while one dependency, tenant, region, or operation is failing.

Failure Metrics

Metrics should separate original requests from retries and expose both dependency and application behavior.

Metric What It Reveals
Request success rate Overall user-visible reliability
Dependency success rate Which downstream component is failing
Retry attempts Hidden transient failures and added load
Retry success rate Whether retries are actually useful
Timeout rate Requests exceeding configured latency budgets
Circuit state Dependencies currently isolated
Connection-pool wait time Resource pressure before complete exhaustion
Queue age Whether asynchronous processing is falling behind
Load-shed requests How much demand is rejected to preserve stability
Degraded responses How often fallbacks replace normal behavior

Retry success should be measured. A retry policy that adds 30 percent more traffic but recovers only 0.1 percent of requests is likely harmful.

Distributed Tracing

Distributed tracing connects operations across service boundaries. A trace should record every dependency call, retry attempt, timeout, circuit decision, and fallback.

POST /orders                         1,840 ms
├── INSERT pending order                18 ms
├── POST payment attempt 1             520 ms timeout
├── retry backoff                      173 ms
├── POST payment attempt 2             604 ms timeout
├── publish reconciliation job          21 ms
└── return verification pending         12 ms

Without retry spans, one slow logical request may appear as a single unexplained latency spike.

Structured Logging

Logs should contain identifiers that connect retries and recovery operations.

{
  "event": "payment_attempt_failed",
  "order_id": "8f7b1c1c-73be-4aa5-908f-c2031d7c7b99",
  "idempotency_key": "order-93482-payment-v1",
  "attempt": 2,
  "failure_type": "read_timeout",
  "elapsed_ms": 1504,
  "remaining_deadline_ms": 287,
  "circuit_state": "closed",
  "provider": "primary-payment-provider"
}

Do not log sensitive payment data, access tokens, passwords, or complete personal information.

Alerting on Symptoms

Alerts should focus on user impact and exhaustion signals rather than every individual exception.

Useful alerts include:

  • Error-budget burn: success rate is consuming the allowed reliability budget too quickly.
  • Tail-latency increase: p99 latency exceeds the service objective.
  • Retry amplification: total dependency calls significantly exceed original requests.
  • Pool saturation: connection acquisition time or utilization approaches limits.
  • Queue delay: oldest-message age exceeds the processing objective.
  • Circuit open duration: a dependency remains isolated beyond the normal recovery window.
  • Degraded response rate: fallback behavior exceeds an expected threshold.

One dependency error may be harmless. A rising timeout rate combined with pool saturation and retry growth indicates a cascading failure.

Common Mistakes

Mistake Why It Causes Problems Better Approach
Retrying every failure automatically Permanent errors and overloaded dependencies receive more traffic without improving success. Retry only classified transient failures and enforce a strict attempt and deadline budget.
Retrying non-idempotent operations without a stable key Timeouts can produce duplicate charges, orders, notifications, or state transitions. Require durable idempotency keys and return the original recorded result on repeated requests.
Using one timeout for every dependency Fast operations wait too long while legitimate slow operations fail prematurely. Allocate operation-specific timeouts within the caller’s overall request deadline.
Configuring retries at every service layer Nested retries multiply traffic and latency across dependency chains. Define retry ownership at one appropriate layer and propagate deadlines downstream.
Increasing connection pools during database saturation More concurrent queries can move exhaustion into the database and increase recovery time. Reduce slow-query duration, bound concurrency, and size pools from measured database capacity.
Failing liveness checks when a shared dependency is unavailable Every application instance may restart simultaneously, causing a complete outage and cold-start pressure. Keep liveness focused on local process health and use readiness for temporary inability to serve traffic.
Using unbounded queues as overload protection Requests remain in memory until latency and memory usage become uncontrollable. Set queue capacity and age limits, then shed excess work with explicit retry guidance.
Returning stale fallback data without defining safety limits Old inventory, pricing, permissions, or payment state can create incorrect business outcomes. Classify which data may be stale and enforce maximum age and correctness rules per field.
Treating a timeout as proof of failure The remote operation may have completed, making compensation or retry unsafe. Represent uncertain outcomes explicitly and reconcile them using stable operation identifiers.
Monitoring average latency only Tail latency and saturation can affect a meaningful percentage of users while averages remain normal. Track p95, p99, p99.9, pool wait time, queue age, retry rate, and degraded responses.

Production Checklist

  • Define request deadlines: assign a maximum total duration to every synchronous operation and propagate the remaining budget downstream.
  • Configure phase-specific timeouts: set explicit connection, read, write, and pool acquisition limits for remote clients.
  • Classify retryable failures: document which status codes, exceptions, and business outcomes may be retried.
  • Limit retry attempts: enforce a small maximum attempt count that fits inside the operation deadline.
  • Add exponential backoff: increase delays between attempts so unhealthy dependencies receive less pressure.
  • Add jitter: randomize retry timing to prevent synchronized clients from generating recovery spikes.
  • Require idempotency keys: protect non-idempotent APIs, message handlers, and external side effects from duplicate execution.
  • Store idempotency results durably: preserve the original status and response so retries return the same outcome.
  • Represent uncertain outcomes: use states such as pending verification instead of assuming a timeout means failure.
  • Use circuit breakers: stop repeated calls to dependencies that are consistently failing or timing out.
  • Isolate dependency resources: use separate connection pools, concurrency limits, or worker queues for unrelated workloads.
  • Bound every queue: define maximum queue size, maximum message age, and overload behavior.
  • Implement load shedding: reject low-priority work before critical resources become fully exhausted.
  • Define fallback safety: document which data may be stale, omitted, delayed, or replaced by defaults.
  • Separate liveness and readiness: restart only irrecoverably unhealthy processes and remove temporarily impaired instances from traffic.
  • Make consumers idempotent: assume every message can be delivered more than once.
  • Configure dead-letter handling: isolate persistent message failures after a bounded retry count.
  • Measure retry amplification: compare original request volume with total downstream attempts.
  • Monitor saturation: track connection-pool utilization, worker concurrency, queue age, memory, CPU, and file descriptors.
  • Test realistic failures: simulate timeouts, packet loss, slow dependencies, process pauses, database failover, and regional outages.

Conclusion

Partial failures are unavoidable in distributed systems because components communicate over unreliable networks and fail independently. A service can remain healthy while one dependency is slow, one replica is stale, or one request has an unknown outcome.

Reliable systems combine timeouts, bounded retries, exponential backoff, jitter, circuit breakers, bulkheads, load shedding, graceful degradation, and idempotency. Each pattern solves a different part of the failure path.

The strongest designs do not merely retry until success. They control resource usage, preserve correctness during uncertainty, isolate failures, expose degraded behavior, and provide an asynchronous recovery path when a synchronous result cannot be confirmed.

Key Takeaway

A partial failure is not only an error; it is often an unknown outcome combined with limited resources. Production resilience requires bounding the time and capacity spent on failures while ensuring that retries, failover, and recovery cannot duplicate or corrupt business operations.

Comments (0)

Author

Enjoyed this article?
Support Oleksandr Andrushchenko
This helps Oleksandr Andrushchenko continue creating useful content

Article info

Created: Jul 21
Updated: Jul 21
Published: Jul 21

Article actions

0 Likes
0 Dislikes
Copy persistent article link: