Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions docs/docs/api/Dispatcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -1202,6 +1202,144 @@ const client = new Client("http://example.com").compose(
- Handles case-insensitive encoding names
- Supports streaming decompression without buffering

##### `circuitBreaker`

The `circuitBreaker` interceptor implements the [circuit breaker pattern](https://martinfowler.com/bliki/CircuitBreaker.html) to prevent cascading failures when upstream services are unavailable or responding with errors.

The circuit breaker has three states:
- **Closed** - Requests flow normally. Failures are counted, and when the threshold is reached, the circuit opens.
- **Open** - All requests fail immediately with `CircuitBreakerError` without contacting the upstream service.
- **Half-Open** - After the timeout period, a limited number of requests are allowed through to test if the service has recovered.

**Options**

- `threshold` - Number of consecutive failures before opening the circuit. Default: `5`.
- `timeout` - How long (in milliseconds) the circuit stays open before transitioning to half-open. Default: `30000` (30 seconds).
- `successThreshold` - Number of successful requests in half-open state needed to close the circuit. Default: `1`.
- `maxHalfOpenRequests` - Maximum number of concurrent requests allowed in half-open state. Default: `1`.
- `statusCodes` - Array or Set of HTTP status codes that count as failures. Default: `[500, 502, 503, 504]`.
- `errorCodes` - Array or Set of error codes that count as failures. Default: `['UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT', 'UND_ERR_SOCKET', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'ENETUNREACH', 'EHOSTUNREACH', 'EAI_AGAIN']`.
- `getKey` - Function to extract a circuit key from request options. Default: uses origin only. Signature: `(opts: DispatchOptions) => string | null | undefined`. Return `null` or `undefined` to bypass the circuit breaker for a specific request.
- `storage` - Custom `CircuitBreakerStorage` instance for storing circuit states. Useful for sharing state across multiple dispatchers.
- `onStateChange` - Callback invoked when a circuit changes state. Signature: `(key: string, newState: 'open' | 'half-open' | 'closed', previousState: string) => void`.

**Example - Basic Circuit Breaker**

```js
const { Client, interceptors } = require("undici");
const { circuitBreaker } = interceptors;

const client = new Client("http://example.com").compose(
circuitBreaker({
threshold: 5,
timeout: 30000
})
);

try {
const response = await client.request({ path: "/", method: "GET" });
} catch (err) {
if (err.code === "UND_ERR_CIRCUIT_BREAKER") {
console.log("Circuit is open, service unavailable");
}
}
```

**Example - Route-Level Circuit Breakers**

Use the `getKey` option to create separate circuits for different routes:

```js
const { Agent, interceptors } = require("undici");
const { circuitBreaker } = interceptors;

const client = new Agent().compose(
circuitBreaker({
threshold: 3,
timeout: 10000,
getKey: (opts) => `${opts.origin}${opts.path}`
})
);

// /api/users and /api/products have independent circuits
await client.request({ origin: "http://example.com", path: "/api/users", method: "GET" });
await client.request({ origin: "http://example.com", path: "/api/products", method: "GET" });
```

**Example - Bypassing Circuit Breaker for Health Checks**

Return `null` from `getKey` to bypass the circuit breaker for specific requests:

```js
const { Agent, interceptors } = require("undici");
const { circuitBreaker } = interceptors;

const client = new Agent().compose(
circuitBreaker({
threshold: 5,
getKey: (opts) => {
// Bypass circuit breaker for health check endpoints
if (opts.path === '/health' || opts.path === '/ready') {
return null;
}
return opts.origin;
}
})
);

// Health checks always go through, even when circuit is open
await client.request({ origin: "http://example.com", path: "/health", method: "GET" });
```

**Example - Custom Status Codes**

Configure the circuit breaker to trip on rate limiting:

```js
const { Client, interceptors } = require("undici");
const { circuitBreaker } = interceptors;

const client = new Client("http://example.com").compose(
circuitBreaker({
threshold: 3,
statusCodes: [429, 500, 502, 503, 504]
})
);
```

**Example - State Change Monitoring**

```js
const { Client, interceptors } = require("undici");
const { circuitBreaker } = interceptors;

const client = new Client("http://example.com").compose(
circuitBreaker({
threshold: 5,
onStateChange: (key, newState, prevState) => {
console.log(`Circuit ${key}: ${prevState} -> ${newState}`);
}
})
);
```

**Error Handling**

When the circuit is open or half-open (with max requests reached), requests will fail with a `CircuitBreakerError`:

```js
const { errors } = require("undici");

try {
await client.request({ path: "/", method: "GET" });
} catch (err) {
if (err instanceof errors.CircuitBreakerError) {
console.log(`Circuit breaker triggered: ${err.state}`); // 'open' or 'half-open'
console.log(`Circuit key: ${err.key}`);
}
}
```

##### `Cache Interceptor`

The `cache` interceptor implements client-side response caching as described in
Expand Down
22 changes: 22 additions & 0 deletions docs/docs/api/Errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { errors } from 'undici'
| `InformationalError` | `UND_ERR_INFO` | expected error with reason |
| `ResponseExceededMaxSizeError` | `UND_ERR_RES_EXCEEDED_MAX_SIZE` | response body exceed the max size allowed |
| `SecureProxyConnectionError` | `UND_ERR_PRX_TLS` | tls connection to a proxy failed |
| `CircuitBreakerError` | `UND_ERR_CIRCUIT_BREAKER` | circuit breaker is open or half-open, request rejected |

Be aware of the possible difference between the global dispatcher version and the actual undici version you might be using. We recommend to avoid the check `instanceof errors.UndiciError` and seek for the `error.code === '<error_code>'` instead to avoid inconsistencies.
### `SocketError`
Expand All @@ -46,3 +47,24 @@ interface SocketInfo {
```

Be aware that in some cases the `.socket` property can be `null`.

### `CircuitBreakerError`

The `CircuitBreakerError` is thrown when a request is rejected by the circuit breaker interceptor. It has the following properties:

- `state` - The current state of the circuit breaker when the error was thrown. Either `'open'` or `'half-open'`.
- `key` - The circuit key identifying which circuit rejected the request (e.g., the origin URL).

```js
const { errors } = require('undici')

try {
await client.request({ path: '/', method: 'GET' })
} catch (err) {
if (err instanceof errors.CircuitBreakerError) {
console.log(err.code) // 'UND_ERR_CIRCUIT_BREAKER'
console.log(err.state) // 'open' or 'half-open'
console.log(err.key) // e.g., 'http://example.com'
}
}
```
3 changes: 2 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ module.exports.interceptors = {
dump: require('./lib/interceptor/dump'),
dns: require('./lib/interceptor/dns'),
cache: require('./lib/interceptor/cache'),
decompress: require('./lib/interceptor/decompress')
decompress: require('./lib/interceptor/decompress'),
circuitBreaker: require('./lib/interceptor/circuit-breaker')
}

module.exports.cacheStores = {
Expand Down
23 changes: 22 additions & 1 deletion lib/core/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,26 @@ class MaxOriginsReachedError extends UndiciError {
}
}

const kCircuitBreakerError = Symbol.for('undici.error.UND_ERR_CIRCUIT_BREAKER')
class CircuitBreakerError extends UndiciError {
constructor (message, { state, key }) {
super(message)
this.name = 'CircuitBreakerError'
this.message = message || 'Circuit breaker is open'
this.code = 'UND_ERR_CIRCUIT_BREAKER'
this.state = state
this.key = key
}

static [Symbol.hasInstance] (instance) {
return instance && instance[kCircuitBreakerError] === true
}

get [kCircuitBreakerError] () {
return true
}
}

module.exports = {
AbortError,
HTTPParserError,
Expand All @@ -444,5 +464,6 @@ module.exports = {
RequestRetryError,
ResponseError,
SecureProxyConnectionError,
MaxOriginsReachedError
MaxOriginsReachedError,
CircuitBreakerError
}
Loading