Skip to content
Merged
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
39 changes: 38 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ const run = protect({
timeoutMode: 'immediate',
concurrency: 8,
maxPending: 100,
circuit: {
threshold: 5,
cooldown: 30_000,
},
onError: (error) => {
console.error('Operation failed', error)
},
Expand All @@ -45,7 +49,7 @@ Without `backoff`, retries start immediately. To add a delay, provide `backoff.t

Thrown errors and rejected promises trigger retries. `fetch()` resolves for HTTP error responses; throw in your callback to retry them. Only retry operations that are safe to repeat.

`onError(error)` is called once when a call rejects, including cancellation, timeout and queue overflow. If the hook throws, its error replaces the original rejection.
`onError(error)` is called once when a call rejects, including cancellation, timeout, queue overflow and circuit rejection. If the hook throws, its error replaces the original rejection.

## Timeout

Expand All @@ -64,6 +68,39 @@ To limit the entire call, including queue wait and backoff, pass an external sig

`maxPending` defaults to `0`: no queue. When all concurrency slots are occupied, new calls reject with `QueueFullError` without invoking their callbacks. Set `maxPending > 0` to enable a queue; the limit counts only waiting calls, excluding active ones.

## Circuit breaker

`circuit: { threshold: 5, cooldown: 30_000 }` opens the circuit after five consecutive failed calls, counted after all retries. Success resets the count. Both settings are required; `cooldown` is in milliseconds. Without `circuit`, the breaker is disabled.

While open, new and queued calls reject with `CircuitOpenError` without invoking their callbacks. Already active calls are not cancelled, but their later results do not affect the current circuit state.

After cooldown, one probe call is allowed, using the runner's normal retry and timeout settings. Other calls reject until the probe finishes. Success closes the circuit; failure opens it for another full cooldown. Cancelling a probe allows another probe without closing the circuit.

External cancellation, `QueueFullError` and `CircuitOpenError` neither count as failures nor reset the count. Final timeouts count as failures. With `timeoutMode: 'wait'`, a callback that never settles can also block the probe indefinitely.

Circuit state is shared by all calls on a runner and independent between runners. Use a separate runner for each protected resource.

## Fallback

Pass `fallback` in the run options to use a backup operation after the primary call fails:

```ts
const backup = protect({ attempts: 2, timeout: 5_000 })
const backupUrl = 'https://backup.example.com/users/1'

const response = await run(({ signal }) => fetch(url, { signal }), {
fallback: () => backup(({ signal }) => fetch(backupUrl, { signal })),
})
```

`fallback(error)` runs once after retries are exhausted, or immediately when the circuit or queue rejects the call. It can return a value or promise; the returned value becomes the result of `run`. It is not invoked when the primary succeeds or the external signal is aborted.

Fallback does not use the primary runner's retries, timeout or concurrency slot. Use a separate runner to protect the backup operation. A successful fallback does not close the primary circuit or reset its failure count.

If fallback throws or rejects, its error becomes the call's rejection. `onError` reports only the final rejection; it is not called when fallback succeeds.

External cancellation also rejects a call while fallback is running. To stop the backup operation itself, forward the same external signal to its runner.

## Cancellation

```ts
Expand Down
79 changes: 79 additions & 0 deletions src/circuit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { CircuitOpenError } from './errors.js'
import type { CircuitOptions } from './types.js'

export class Circuit {
private state: 'closed' | 'open' | 'half-open' = 'closed'
private failures = 0
private generation = 0
private until = 0
private probing = false

constructor(private readonly options: CircuitOptions) {}

check(): void {
if (this.state === 'open') {
if (Date.now() < this.until) {
throw new CircuitOpenError()
}

this.state = 'half-open'
}

if (this.state === 'half-open' && this.probing) {
throw new CircuitOpenError()
}
}

enter(): { generation: number; probe: boolean } {
this.check()

const probe = this.state === 'half-open'

if (probe) {
this.probing = true
}

return { generation: this.generation, probe }
}

succeed(generation: number): void {
if (generation !== this.generation) {
return
}

if (this.state === 'half-open') {
this.state = 'closed'
this.probing = false
this.generation += 1
}

this.failures = 0
}

fail(generation: number): boolean {
if (generation !== this.generation) {
return false
}

this.failures += 1

if (this.state !== 'half-open' && this.failures < this.options.threshold) {
return false
}

this.state = 'open'
this.until = Date.now() + this.options.cooldown
this.generation += 1
this.probing = false
return true
}

cancel(generation: number): void {
if (generation !== this.generation || this.state !== 'half-open') {
return
}

this.probing = false
this.generation += 1
}
}
7 changes: 7 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
export class CircuitOpenError extends Error {
constructor() {
super('The circuit is open')
this.name = 'CircuitOpenError'
}
}

export class QueueFullError extends Error {
constructor() {
super('The pending operation limit has been reached')
Expand Down
39 changes: 39 additions & 0 deletions src/fallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
export function recover<Result>(
fallback: (error: unknown) => Result,
error: unknown,
signal: AbortSignal | undefined,
): Promise<Awaited<Result>> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason)
return
}

function finish(): void {
signal?.removeEventListener('abort', onAbort)
}

function onAbort(): void {
finish()
reject(signal?.reason)
}

signal?.addEventListener('abort', onAbort, { once: true })

try {
void Promise.resolve(fallback(error)).then(
(value) => {
finish()
resolve(value)
},
(error: unknown) => {
finish()
reject(error)
},
)
} catch (error) {
finish()
reject(error)
}
})
}
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
export { QueueFullError, TimeoutError } from './errors.js'
export { CircuitOpenError, QueueFullError, TimeoutError } from './errors.js'
export { protect } from './protect.js'
export type {
BackoffOptions,
CircuitOptions,
ProtectOptions,
RunContext,
Runner,
Expand Down
Loading
Loading