The circuit breaker utility stops sending traffic to an unhealthy downstream dependency, giving it room to recover while you decide what happens to the rejected requests.

## Key features

- Stops calling an unhealthy downstream after a configurable number of consecutive failures
- Hands rejected requests to an `on_circuit_open` callback so you decide what happens next (buffer, drop, return a cached value)
- Tests recovery with an explicit half-open probe rather than blindly retrying everything at once
- Shares circuit state across execution environments via Amazon DynamoDB
- Safe under concurrency: one half-open probe across all threads and execution environments, and synchronized failure counting
- Keeps the healthy path write-free: failures are counted in memory and only persisted on a state transition

## Terminology

**Circuit** is a named guard around a single downstream dependency. Each `name` is an independent circuit.

**State** is the circuit's current mode: `CLOSED` (healthy), `OPEN` (downstream considered unhealthy, calls skipped), or `HALF_OPEN` (testing recovery).

**Persistence layer** is the shared storage that holds each circuit's state so every execution environment agrees on whether a circuit is open.

**Recovery timeout** is how long a circuit stays open before allowing a half-open probe.

```
stateDiagram-v2
    [*] --> CLOSED
    CLOSED --> OPEN: N consecutive failures
    OPEN --> HALF_OPEN: recovery timeout elapsed
    HALF_OPEN --> CLOSED: probe succeeds
    HALF_OPEN --> OPEN: probe fails
```

*Circuit breaker state transitions*

## Getting started

We use Amazon DynamoDB as the persistence layer in this documentation.

### IAM Permissions

When using Amazon DynamoDB as the persistence layer, you will need the following IAM permissions:

| IAM Permission | Operation |
| --- | --- |
| **`dynamodb:GetItem`** | Read shared circuit state |
| **`dynamodb:PutItem`** | Persist an opened circuit and elect the half-open probe |
| **`dynamodb:UpdateItem`** | Close or reopen a circuit after a probe |

### Required resources

To start, you'll need:

- **Persistent storage**

  ---

  [Amazon DynamoDB](<#dynamodb-table>)
- **AWS Lambda function**

  ---

  With permissions to read and write your persistent storage

#### DynamoDB table

Unless you're looking to [customize each attribute](<#customizing-the-dynamodb-table>), you only need the following:

| Configuration | Value | Notes |
| --- | --- | --- |
| Partition key | `id` | Holds the circuit name |
| TTL attribute name | `expiration` | Using AWS Console? This is configurable after table creation |

You **can** use a single DynamoDB table for all your circuits. Already have a single-table (composite key) design you want to reuse? See [Using a composite primary key](<#using-a-composite-primary-key>).

##### DynamoDB IaC example

```
Transform: AWS::Serverless-2016-10-31
Resources:
  CircuitBreakerTable:
    Type: AWS::DynamoDB::Table
    Properties:
      AttributeDefinitions:
        - AttributeName: id
          AttributeType: S
      KeySchema:
        - AttributeName: id
          KeyType: HASH
      TimeToLiveSpecification:
        AttributeName: expiration
        Enabled: true
      BillingMode: PAY_PER_REQUEST

  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: python3.12
      Handler: app.py
      Policies:
        - Statement:
            - Sid: AllowDynamodbReadWrite
              Effect: Allow
              Action:
                - dynamodb:GetItem
                - dynamodb:PutItem
                - dynamodb:UpdateItem
              Resource: !GetAtt CircuitBreakerTable.Arn
      Environment:
        Variables:
          CIRCUIT_BREAKER_TABLE: !Ref CircuitBreakerTable
```

### Circuit breaker in action

The common case is the `@circuit_breaker` decorator wrapping the function that makes the downstream call. With no `config`, sensible defaults apply (open after 5 consecutive failures, probe after 30 seconds, close after 3 probe successes, count any exception as a failure).

```
import os

from aws_lambda_powertools.utilities.circuit_breaker import circuit_breaker
from aws_lambda_powertools.utilities.circuit_breaker.persistence import (
    CircuitBreakerDynamoDBPersistence,
)
from aws_lambda_powertools.utilities.typing import LambdaContext

table = os.getenv("CIRCUIT_BREAKER_TABLE", "")
persistence = CircuitBreakerDynamoDBPersistence(table_name=table)


class PaymentBackend:
    def charge(self, order: dict): ...


payment_api = PaymentBackend()


@circuit_breaker(name="payment-backend", persistence_store=persistence)
def charge(order: dict) -> dict:
    return payment_api.charge(order)  # the protected downstream call


def lambda_handler(event: dict, context: LambdaContext):
    # Circuit closed -> charge() runs and returns the backend response.
    # Circuit open    -> charge() is skipped and CircuitBreakerOpenError is raised,
    #                    because no on_circuit_open callback is registered.
    return charge(event)
```

> [!NOTE]
>
> **Wrap the downstream call, not the whole handler**
>
> The circuit protects a single dependency. If you decorate a handler that parses the event, validates, and calls two backends, a parsing bug would trip a circuit named after a backend that is perfectly healthy. Decorate the handler directly only when the handler **is** the downstream call (a thin pass-through).

### What the decorated function returns

There is no wrapper type to inspect. The contract is:

| Circuit state | Result |
| --- | --- |
| **Closed** | The protected function's return value |
| **Open**, `on_circuit_open` set | Whatever the callback returns |
| **Open**, no callback | Raises `CircuitBreakerOpenError` (with the `CircuitInfo` attached) |

### Protecting async functions

`@circuit_breaker` also wraps `async def` functions. The coroutine is awaited inside the circuit, so failures count towards `failure_threshold`, probes run on the awaited result, and an `on_circuit_open` callback can be a coroutine function too. Call it with `await` as you normally would.

```
import asyncio
import os

import httpx

from aws_lambda_powertools.utilities.circuit_breaker import CircuitBreakerConfig, CircuitInfo, circuit_breaker
from aws_lambda_powertools.utilities.circuit_breaker.persistence import (
    CircuitBreakerDynamoDBPersistence,
)
from aws_lambda_powertools.utilities.typing import LambdaContext

table = os.getenv("CIRCUIT_BREAKER_TABLE", "")
persistence = CircuitBreakerDynamoDBPersistence(table_name=table)
config = CircuitBreakerConfig(failure_threshold=3, recovery_timeout=30)


async def cached_quote(symbol: str, circuit: CircuitInfo) -> dict:
    # Async callbacks are awaited; sync callbacks work too.
    return {"symbol": symbol, "price": None, "circuit": circuit.state.value}


@circuit_breaker(
    name="quotes-api",
    persistence_store=persistence,
    config=config,
    on_circuit_open=cached_quote,
)
async def get_quote(symbol: str) -> dict:
    # The coroutine is awaited inside the circuit, so a failure raised here counts
    # towards failure_threshold just like it does for a regular function.
    async with httpx.AsyncClient(timeout=2) as client:
        response = await client.get(f"https://quotes.example.com/{symbol}")
        response.raise_for_status()
        return response.json()


def lambda_handler(event: dict, context: LambdaContext):
    return asyncio.run(get_quote(event["symbol"]))
```

## Handling an open circuit

### With a callback

Register an `on_circuit_open` callback to decide what happens to a rejected request. The callback receives the same arguments the protected function was called with (positional arguments stay positional, keyword arguments stay keyword), plus a trailing `circuit` keyword argument carrying a `CircuitInfo` snapshot. Its return value becomes the result of the call.

```
import json
import os

import boto3

from aws_lambda_powertools.utilities.circuit_breaker import CircuitInfo, circuit_breaker
from aws_lambda_powertools.utilities.circuit_breaker.persistence import (
    CircuitBreakerDynamoDBPersistence,
)
from aws_lambda_powertools.utilities.typing import LambdaContext

table = os.getenv("CIRCUIT_BREAKER_TABLE", "")
queue_url = os.getenv("OVERFLOW_QUEUE_URL", "")
persistence = CircuitBreakerDynamoDBPersistence(table_name=table)
sqs = boto3.client("sqs")


class PaymentBackend:
    def charge(self, order: dict): ...


payment_api = PaymentBackend()


def buffer_payload(order: dict, circuit: CircuitInfo) -> dict:
    # Circuit is OPEN. The protected call never ran and the payload is yours to handle:
    # buffer it, drop it, or return a cached value. Here we push it to an overflow queue.
    sqs.send_message(QueueUrl=queue_url, MessageBody=json.dumps(order))
    return {"statusCode": 202, "circuit": circuit.name}


@circuit_breaker(
    name="payment-backend",
    persistence_store=persistence,
    on_circuit_open=buffer_payload,
)
def charge(order: dict) -> dict:
    return payment_api.charge(order)


def lambda_handler(event: dict, context: LambdaContext):
    # Circuit closed -> returns the backend response.
    # Circuit open   -> buffer_payload(event, circuit) runs and its return value is returned.
    return charge(event)
```

> [!NOTE]
>
> **Why a callback instead of built-in S3/SQS sinks?**
>
> A callback keeps you in control of where rejected requests go. You pick the destination, the client, and the IAM, and you avoid coupling your function to a sink the utility manages for you.

### Without a callback

If you don't register a callback, an open circuit raises `CircuitBreakerOpenError`. Catch it to decide how to respond. The exception carries a `circuit` attribute (`CircuitInfo`) so you can inspect why the circuit rejected the request.

```
import os

from aws_lambda_powertools.utilities.circuit_breaker import (
    CircuitBreakerConfig,
    CircuitBreakerOpenError,
    circuit_breaker,
)
from aws_lambda_powertools.utilities.circuit_breaker.persistence import (
    CircuitBreakerDynamoDBPersistence,
)
from aws_lambda_powertools.utilities.typing import LambdaContext

table = os.getenv("CIRCUIT_BREAKER_TABLE", "")
persistence = CircuitBreakerDynamoDBPersistence(table_name=table)

config = CircuitBreakerConfig(
    failure_threshold=5,  # consecutive failures before opening
    recovery_timeout=30,  # seconds in OPEN before a half-open probe
    success_threshold=3,  # consecutive probe successes before closing
    # Only these exceptions count as a failure. A ValueError (caller's fault) is
    # re-raised without affecting the circuit.
    handled_exceptions=(TimeoutError, ConnectionError),
)


class PaymentBackend:
    def charge(self, order: dict): ...


payment_api = PaymentBackend()


@circuit_breaker(name="payment-backend", persistence_store=persistence, config=config)
def charge(order: dict) -> dict:
    return payment_api.charge(order)


def lambda_handler(event: dict, context: LambdaContext):
    try:
        return charge(event)
    except CircuitBreakerOpenError as exc:
        # No callback registered, so we decide what to do with the rejected request here.
        circuit_name = exc.circuit.name if exc.circuit else "unknown"
        return {"statusCode": 202, "circuit": circuit_name}
```

## Configuration

All options live on `CircuitBreakerConfig`. Every value has a default, so `CircuitBreakerConfig()` is a valid production configuration.

| Parameter | Default | Description |
| --- | --- | --- |
| **`failure_threshold`** | `5` | Consecutive failures that trip a closed circuit to open |
| **`recovery_timeout`** | `30` | Seconds the circuit stays open before a half-open probe |
| **`success_threshold`** | `3` | Consecutive probe successes required to close a half-open circuit |
| **`handled_exceptions`** | `None` | Allowlist: only these exception types count as failures. Mutually exclusive with the denylist |
| **`ignored_exceptions`** | `None` | Denylist: every exception counts as a failure except these. Mutually exclusive with the allowlist |
| **`local_cache_max_age`** | `5` | Seconds a circuit's state is cached per environment before a read-through |

### Choosing which exceptions count as a failure

By default, **any exception** counts as a failure. But not every error means the downstream is unhealthy: a `400` is the caller's fault, a `503` is not. Scope it from either side:

- **`handled_exceptions`** (allowlist): only these count. Everything else propagates without affecting the circuit.
- **`ignored_exceptions`** (denylist): everything counts except these.

Passing both raises `CircuitBreakerConfigError`. An exception that doesn't count is re-raised to the caller untouched.

## Advanced

### How recovery works

After `recovery_timeout` seconds, the circuit moves to `HALF_OPEN` and elects a **single** execution environment (via a conditional DynamoDB write) to run a probe. If `success_threshold` consecutive probes succeed, the circuit closes; a single failing probe reopens it. This stops a thundering herd of every environment hammering a recovering backend at once.

> [!NOTE]
>
> **Thread safety**
>
> The utility is safe to share across threads: within a multi-threaded environment the probe election picks a single thread, so the single-prober guarantee spans threads as well as environments, and the in-memory failure counter is synchronized. Single-threaded functions (the normal Lambda model) are unaffected.
>
> The same holds for coroutines: concurrent `await`s of the same circuit in one event loop run a single probe at a time, and the others get the open response.
>
> Probe ownership belongs to the thread that won the election. If that thread never runs the circuit again (for example, a thread-per-request worker pool), recovery waits for the probe lease to expire before another thread or environment takes over.

> [!WARNING]
>
> **Make your hooks thread-safe**
>
> If your function runs multiple threads, `on_circuit_open` and `on_transition` callbacks can run concurrently for the same circuit. Make them thread-safe.

### State coordination across environments

The consecutive-failure counter lives in memory per execution environment, so a healthy circuit performs **no writes**. Only when an environment reaches `failure_threshold` does it persist `OPEN`. The shared state is cached locally for `local_cache_max_age` seconds to avoid a read per invocation. A cache miss (cold start or expired entry) forces a read-through before routing.

> [!NOTE]
>
> **Fail-open by design**
>
> If the utility cannot reach the persistence store when reading state, it treats the circuit as **closed**. A circuit breaker should never become the outage it is meant to prevent.

### Observability with metrics

Register an `on_transition` hook to be notified whenever the circuit changes state (open, probe, close, reopen). The hook fires **only on transitions**, never on the per-invocation hot path, so it is a safe place to emit a CloudWatch metric without giving up the write-free healthy path. It receives a single `CircuitTransition` (`circuit_name`, `from_state`, `to_state`, `opened_at`).

```
import os

from aws_lambda_powertools.metrics import MetricUnit, single_metric
from aws_lambda_powertools.utilities.circuit_breaker import (
    CircuitTransition,
    circuit_breaker,
)
from aws_lambda_powertools.utilities.circuit_breaker.persistence import (
    CircuitBreakerDynamoDBPersistence,
)
from aws_lambda_powertools.utilities.typing import LambdaContext

table = os.getenv("CIRCUIT_BREAKER_TABLE", "")
persistence = CircuitBreakerDynamoDBPersistence(table_name=table)


def emit_transition_metric(transition: CircuitTransition) -> None:
    # Fires only when the circuit changes state, so this never runs on the hot path.
    with single_metric(
        namespace="MyApplication",
        name=f"Circuit_{transition.to_state}",
        unit=MetricUnit.Count,
        value=1,
    ) as metric:
        metric.add_dimension(name="circuit", value=transition.circuit_name)


class PaymentBackend:
    def charge(self, order: dict): ...


payment_api = PaymentBackend()


@circuit_breaker(
    name="payment-backend",
    persistence_store=persistence,
    on_transition=emit_transition_metric,
)
def charge(order: dict) -> dict:
    return payment_api.charge(order)


def lambda_handler(event: dict, context: LambdaContext):
    return charge(event)
```

Any exception raised inside the hook is swallowed and logged, so a misbehaving metric call can never break the protected request.

> [!WARNING]
>
> **`failure_count` is a trip-time snapshot, not a running total**
>
> The `failure_count` on `CircuitInfo` is the number of *consecutive* failures the environment that tripped the circuit had counted at the moment it opened. Because the failure counter lives in memory per execution environment (keeping the healthy path write-free), it is **not** a fleet-wide total and reads `0` in states reached without a fresh trip (such as `HALF_OPEN`). For failure **volume**, emit a metric from your own code or the `on_transition` hook rather than reading this field.

### Disabling the circuit breaker

Set **`POWERTOOLS_CIRCUIT_BREAKER_DISABLED`** to a truthy value to bypass the circuit entirely and always call the protected function. This is intended for **development environments only** and emits a warning.

### Customizing the DynamoDB table

`CircuitBreakerDynamoDBPersistence` accepts attribute-name overrides (`key_attr`, `state_attr`, `failure_count_attr`, `opened_at_attr`, `half_open_owner_attr`, `expiry_attr`) and the usual boto3 escape hatches (`boto3_session`, `boto3_client`, `boto_config`) for reusing an existing table layout or client.

#### Using a composite primary key

Use the `sort_key_attr` parameter when your table is configured with a composite primary key *(hash+range key)*, as in a single-table design.

When enabled, the circuit name is saved in the sort key instead, and the partition key defaults to `circuit_breaker#{LAMBDA_FUNCTION_NAME}` — this **namespaces circuits per function** so two functions can use the same circuit name without sharing state. (Without `sort_key_attr`, the default remains partition-key-only with the circuit name as the partition key.)

Set `static_pk_value` to a fixed value (as below) when you instead want **multiple functions to share the same circuit** — for example, every function guarding the same downstream dependency should trip together.

```
import os

from aws_lambda_powertools.utilities.circuit_breaker import circuit_breaker
from aws_lambda_powertools.utilities.circuit_breaker.persistence import (
    CircuitBreakerDynamoDBPersistence,
)
from aws_lambda_powertools.utilities.typing import LambdaContext

table = os.getenv("CIRCUIT_BREAKER_TABLE", "")
persistence = CircuitBreakerDynamoDBPersistence(
    table_name=table,
    key_attr="PK",
    sort_key_attr="SK",
    static_pk_value="CIRCUIT_BREAKER",
)


class PaymentBackend:
    def charge(self, order: dict): ...


payment_api = PaymentBackend()


@circuit_breaker(name="payment-backend", persistence_store=persistence)
def charge(order: dict) -> dict:
    return payment_api.charge(order)  # the protected downstream call


def lambda_handler(event: dict, context: LambdaContext):
    return charge(event)
```

> [!NOTE]
>
> **Click to expand and learn how table items would look like**
>
> The circuit name is stored in the sort key, so several circuits share one partition. Attributes that don't apply to a state are simply absent.
>
> | PK | SK | state | failure\_count | opened\_at | half\_open\_owner | probe\_lease\_expiry | expiration |
> | --- | --- | --- | --- | --- | --- | --- | --- |
> | CIRCUIT\_BREAKER | payment-backend | OPEN | 5 | 1699999999 |  |  | 1700003599 |
> | CIRCUIT\_BREAKER | inventory-api | HALF\_OPEN | 5 | 1699999999 | 9f3c1a2b-env | 1700000030 | 1700003599 |
> | CIRCUIT\_BREAKER | email-service | CLOSED | 0 |  |  |  |  |

## Testing your code

When unit testing the function a circuit protects, set `POWERTOOLS_CIRCUIT_BREAKER_DISABLED=true` to bypass the circuit and persistence layer entirely, so your tests exercise the business logic without needing DynamoDB.

Disabling circuit breaker for tests

```
POWERTOOLS_CIRCUIT_BREAKER_DISABLED=true python -m pytest
```
