circuitbreaker-lambda

March 12, 2026 · View on GitHub

npm version license node TypeScript

Circuit breaker for AWS Lambda with distributed state. Unlike in-memory circuit breakers, circuitbreaker-lambda shares state across Lambda invocations and concurrent instances using DynamoDB, with support for custom state backends. Designed to fail open -- if the state provider is unavailable, requests pass through rather than failing.

PathRuntimesHowWhen to use
npm packageNode.js 20+Import library or use Middy middlewareProgrammatic control, Node.js only
Lambda LayerAny managed runtimeAdd layer + env varAny runtime, HTTP-based API

Both paths share the same DynamoDB state schema -- functions using either approach share circuit state.

Read the blog post for the motivation, design decisions, and a full walkthrough.

graph LR
  Event["Event
  (API GW, SQS,
  EventBridge, etc.)"]

  subgraph Lambda["AWS Lambda"]
    Handler --> CB["Circuit Breaker"]
    CB -- "CLOSED / HALF-OPEN" --> Downstream["Downstream
    service"]
    CB -- "OPEN" --> Fallback["Fallback
    function"]
    CB <--> DynamoDB["DynamoDB
    (shared state)"]
  end

  Event --> Handler
  Downstream --> Handler
  Fallback --> Handler

Table of Contents

Getting Started: npm Package

1. Install

npm install circuitbreaker-lambda

Requirements: Node.js >= 20

2. Create a DynamoDB table

aws dynamodb create-table \
  --table-name circuitbreaker-table \
  --attribute-definitions AttributeName=id,AttributeType=S \
  --key-schema AttributeName=id,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST

3. Set the environment variable

Add to your Lambda function:

CIRCUITBREAKER_TABLE=circuitbreaker-table

4. Add IAM permissions

Grant your Lambda execution role GetItem and UpdateItem on the table:

{
  "Effect": "Allow",
  "Action": ["dynamodb:GetItem", "dynamodb:UpdateItem"],
  "Resource": "arn:aws:dynamodb:*:*:table/circuitbreaker-table"
}

5. Wrap your calls

import { CircuitBreaker } from "circuitbreaker-lambda";

const circuitBreaker = new CircuitBreaker(unreliableFunction, {
  failureThreshold: 5,
  successThreshold: 2,
  timeout: 10000,
  fallback: fallbackFunction,
});

export const handler = async () => {
  const result = await circuitBreaker.fire();
  return { statusCode: 200, body: JSON.stringify(result) };
};

CommonJS:

const { CircuitBreaker } = require("circuitbreaker-lambda");

const circuitBreaker = new CircuitBreaker(unreliableFunction, options);

Using Middy?

If you use Middy (v4+), use the middleware variant instead of wrapping your calls. The middleware protects the entire handler -- when the circuit is OPEN, the handler is skipped:

import middy from "@middy/core";
import { circuitBreakerMiddleware } from "circuitbreaker-lambda/middy";

export const handler = middy(async (event) => {
  const data = await callDownstreamService();
  return { statusCode: 200, body: JSON.stringify(data) };
}).use(
  circuitBreakerMiddleware({
    circuitId: "downstream-api",
    failureThreshold: 3,
    fallback: async () => ({ statusCode: 503, body: "Service unavailable" }),
  })
);

The middleware uses before/after/onError hooks: before checks the circuit and short-circuits with the fallback if OPEN, after records success, onError records failure and rethrows.

Getting Started: Lambda Layer

The Lambda Layer enables circuit breaker protection with any Lambda runtime -- Node.js, Python, Java, .NET, Ruby. It runs a Rust-based extension as a local HTTP sidecar that your function calls.

1. Download the layer zip

Get circuitbreaker-lambda-layer-x86_64.zip or circuitbreaker-lambda-layer-aarch64.zip from the latest GitHub release.

2. Publish the layer to your account

aws lambda publish-layer-version \
  --layer-name circuitbreaker-lambda \
  --zip-file fileb://circuitbreaker-lambda-layer-aarch64.zip \
  --compatible-architectures arm64 \
  --region eu-west-1

3. Add the layer and environment variable

Add the layer ARN to your function and set CIRCUITBREAKER_TABLE. The extension binds its HTTP server and registers with the Lambda Extensions API during INIT -- Lambda won't invoke your handler until the extension is ready.

4. Call the local HTTP API from your function

The extension listens on http://127.0.0.1:4243 (configurable via CIRCUITBREAKER_PORT).

Node.js:

const resp = await fetch("http://127.0.0.1:4243/circuit/my-service");
const { allowed, state } = await resp.json();
if (!allowed) return { statusCode: 503, body: "Circuit OPEN" };

try {
  const result = await callDownstream();
  await fetch("http://127.0.0.1:4243/circuit/my-service/success", { method: "POST" });
  return { statusCode: 200, body: JSON.stringify(result) };
} catch (err) {
  await fetch("http://127.0.0.1:4243/circuit/my-service/failure", { method: "POST" });
  return { statusCode: 500, body: err.message };
}

Python:

import json, urllib.request

check = json.loads(urllib.request.urlopen("http://127.0.0.1:4243/circuit/my-service").read())
if not check["allowed"]:
    return {"statusCode": 503, "body": "Circuit OPEN"}

# ... call downstream, then POST /circuit/my-service/success or /failure

Layer HTTP API

EndpointMethodDescription
/circuit/{id}GETCheck circuit state. Returns {"allowed": true, "state": "CLOSED"}
/circuit/{id}/successPOSTRecord success. Returns {"state": "CLOSED"}
/circuit/{id}/failurePOSTRecord failure. Returns {"state": "OPEN"}
/healthGETHealth check

{id} must be 1-256 characters, using only alphanumeric characters, hyphens, underscores, dots, and colons (e.g., my-service, payments.api, tenant:123).

Supported Runtimes

The layer works with all supported Lambda runtimes:

  • Node.js: nodejs24.x, nodejs22.x, nodejs20.x
  • Python: python3.14, python3.13, python3.12, python3.11, python3.10
  • Java: java25, java21, java17, java11, java8.al2
  • .NET: dotnet10, dotnet8
  • Ruby: ruby3.4, ruby3.3, ruby3.2
  • Custom: provided.al2023, provided.al2 (Go, Rust, C++, etc.)

Both x86_64 and arm64 architectures are supported.

Memory and cold starts: Lambda allocates CPU proportionally to memory. At the default 128MB, cold starts are CPU-starved (AWS SDK initialization, TLS handshake, credential resolution). At 512MB, the layer adds ~350ms to cold start and warm invocations have near-zero overhead. The npm package adds ~50ms to cold start at 512MB. Both paths benefit from higher memory settings.

Circuit Breaker States

StateDescription
CLOSEDNormal operation. All calls pass through.
OPENRequests fail immediately (or use fallback). Timeout uses exponential backoff.
HALF-OPENA single probe request passes through. One failure immediately reopens.

State Transitions

  • CLOSED -> OPEN: failureCount >= failureThreshold (within windowDuration)
  • OPEN -> HALF-OPEN: Timeout expires (nextAttempt <= Date.now())
  • HALF-OPEN -> OPEN: Any single failure (with exponential backoff on timeout)
  • HALF-OPEN -> CLOSED: successCount >= successThreshold

Configuration

Both the npm package and Lambda Layer support the same circuit breaker settings. The npm package uses constructor options; the layer uses environment variables.

Settingnpm optionLayer env varDefaultDescription
Failure thresholdfailureThresholdCIRCUITBREAKER_FAILURE_THRESHOLD5Failed attempts before circuit opens
Success thresholdsuccessThresholdCIRCUITBREAKER_SUCCESS_THRESHOLD2Successful attempts in HALF-OPEN before closing
TimeouttimeoutCIRCUITBREAKER_TIMEOUT_MS10000Initial OPEN duration in ms
Max timeoutmaxTimeoutCIRCUITBREAKER_MAX_TIMEOUT_MS60000Max exponential backoff cap in ms
Window durationwindowDurationCIRCUITBREAKER_WINDOW_DURATION_MS60000Failure count reset window in ms

npm-Only Options

OptionTypeDefaultDescription
fallbackfunction | nullnullAsync fallback function for graceful degradation
stateProviderStateProviderDynamoDBProviderCustom state backend
circuitIdstringAWS_LAMBDA_FUNCTION_NAMEUnique identifier for this circuit (required)
cacheTtlMsnumber0Warm invocation cache TTL in ms (0 = disabled)
tableNamestringCIRCUITBREAKER_TABLEDynamoDB table name (only with default provider)

Layer-Only Options

VariableDefaultDescription
CIRCUITBREAKER_PORT4243HTTP server port

Environment Variables

VariableUsed byDescription
CIRCUITBREAKER_TABLEBothDynamoDB table name
CIRCUITBREAKER_PORTLayerHTTP server port (default: 4243)
CIRCUITBREAKER_FAILURE_THRESHOLDLayerFailures before circuit opens (default: 5)
CIRCUITBREAKER_SUCCESS_THRESHOLDLayerSuccesses in HALF-OPEN before closing (default: 2)
CIRCUITBREAKER_TIMEOUT_MSLayerInitial OPEN timeout in ms (default: 10000)
CIRCUITBREAKER_MAX_TIMEOUT_MSLayerMax exponential backoff cap (default: 60000)
CIRCUITBREAKER_WINDOW_DURATION_MSLayerFailure count reset window (default: 60000)

Fail-Open Design

The circuit breaker is designed to never make things worse. If the state provider (DynamoDB or custom) is unavailable:

  • Read failures: The circuit assumes CLOSED and lets the request through.
  • Write failures: The response is returned normally; state loss is logged but does not affect the caller.

All provider errors are logged as structured JSON warnings:

{"source":"circuitbreaker-lambda","level":"warn","action":"getState","error":"Error: connection timeout"}

Concurrent Instances and Distributed State

Circuit breaker state in DynamoDB uses unconditional writes (last writer wins). This means concurrent Lambda instances can race when updating state:

  • HALF-OPEN state probe races: If two instances both read HALF-OPEN state and succeed, each increments successCount against its own in-memory copy. The second write overwrites the first, potentially delaying the CLOSED transition.
  • Failure count races: Two instances both reading failureCount: 4 and failing will both write failureCount: 5 and open the circuit. This is safe (idempotent).

In practice, these races are benign -- the circuit may be slightly slower to close than expected, but it will never miss an open. For stricter guarantees, implement a custom StateProvider with DynamoDB conditional writes or transactions.

State Schema

State records use the following schema, shared between the npm package and the Lambda Layer extension:

FieldTypeDescription
idstringPartition key (circuit identifier)
circuitStatestringCLOSED, OPEN, or HALF-OPEN
failureCountnumberCurrent failure count
successCountnumberCurrent success count (in HALF-OPEN state)
nextAttemptnumberEpoch ms when OPEN circuit transitions to HALF-OPEN
lastFailureTimenumberEpoch ms of last failure (for window-based reset)
consecutiveOpensnumberConsecutive HALF-OPEN->OPEN transitions (for exponential backoff)
stateTimestampnumberEpoch ms of last state write
schemaVersionnumberSchema version (currently 1)

Advanced Usage

Custom State Provider

The default DynamoDBProvider works for most cases. For testing or custom backends, implement the StateProvider interface:

import { CircuitBreaker, MemoryProvider } from "circuitbreaker-lambda";

// For testing (no DynamoDB needed)
const breaker = new CircuitBreaker(myFunction, {
  stateProvider: new MemoryProvider(),
  circuitId: "my-circuit",
});

Bring your own backend (Redis, MemoryDB, etc.):

import { CircuitBreaker } from "circuitbreaker-lambda";
import type { StateProvider, CircuitBreakerState } from "circuitbreaker-lambda";

class RedisProvider implements StateProvider {
  async getState(circuitId: string): Promise<CircuitBreakerState | undefined> { /* ... */ }
  async saveState(circuitId: string, state: CircuitBreakerState): Promise<void> { /* ... */ }
}

const breaker = new CircuitBreaker(myFunction, {
  stateProvider: new RedisProvider(),
  circuitId: "my-circuit",
});

Low-Level API

For custom integration patterns (middleware, decorators, etc.), use check(), recordSuccess(), and recordFailure() instead of fire(). Pass null as the request function:

const breaker = new CircuitBreaker(null, { circuitId: "my-api", stateProvider: provider });

const allowed = await breaker.check();
if (!allowed) {
  return fallbackResponse;
}
try {
  const result = await callDownstream();
  await breaker.recordSuccess();
  return result;
} catch (err) {
  await breaker.recordFailure();
  throw err;
}

recordSuccess() and recordFailure() must be called after check() in the same invocation -- they operate on the state loaded by check().

Warm Invocation Caching

Opt-in caching reduces DynamoDB reads on warm Lambda invocations by serving state from an instance-level cache within the TTL window:

const breaker = new CircuitBreaker(myFunction, {
  cacheTtlMs: 200, // serve cached state for 200ms before re-fetching
});

Note: Caching trades consistency for latency. Concurrent Lambda instances may serve stale state for up to cacheTtlMs. In the worst case, stale cached state can suppress the CLOSED->OPEN transition if multiple instances read cached failureCount and write back independently. Default is 0 (disabled). Only enable caching if you've measured the DynamoDB overhead and need to reduce it.

Examples

Both the npm package and Lambda Layer include examples with a toggleable downstream service that lets you walk through the full circuit breaker lifecycle.

npm Package Example

cd examples/sam
npm install
sam build
sam deploy --guided
EndpointDescription
GET /Call downstream through the circuit breaker
GET /statusShow current circuit state and downstream health
POST /toggleFlip downstream between healthy and unhealthy

Walkthrough:

# 1. Requests succeed (circuit CLOSED)
curl $API_URL/

# 2. Make downstream unhealthy
curl -X POST $API_URL/toggle

# 3. Repeat — watch failures accumulate, then circuit opens with fallback
curl $API_URL/
curl $API_URL/
curl $API_URL/

# 4. Check circuit state
curl $API_URL/status

# 5. Make downstream healthy again
curl -X POST $API_URL/toggle

# 6. Wait 15 seconds for the timeout, then call again
#    Circuit transitions HALF-OPEN -> CLOSED as requests succeed
curl $API_URL/
curl $API_URL/

Lambda Layer Example

cd examples/layer
sam build
sam deploy --guided

To build the layer from source instead of downloading, see layer/build.sh.

Same workflow as above, with Node.js endpoints at /node, /node/status, /node/toggle and Python at /python, /python/status, /python/toggle.

Minimal Examples

If you just want to see the bare minimum code, without the toggle/status test infrastructure:

  • examples/minimal-npm/ — npm package protecting a real API call (22 lines of handler code)
  • examples/minimal-layer/ — Lambda Layer protecting the same API call (25 lines of handler code)

Acknowledgments

Inspired by Michael Nygard's book Release It!, Martin Fowler's article on the circuit breaker, and Mark Michon's post on building a Node.js circuit breaker.

Changelog

See CHANGELOG.md for a detailed list of changes.

License

MIT