From 70d99526f8ae7c26fcbb822f697366aed129d672 Mon Sep 17 00:00:00 2001 From: unsady <38117114+unsady@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:06:28 +0200 Subject: [PATCH 1/4] docs: describe provider routing design --- docs/provider-routing.md | 255 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 docs/provider-routing.md diff --git a/docs/provider-routing.md b/docs/provider-routing.md new file mode 100644 index 0000000..ac5778c --- /dev/null +++ b/docs/provider-routing.md @@ -0,0 +1,255 @@ +# Provider routing + +Status: design draft. The API sketches are not implemented contracts. + +## Purpose + +Execute an operation through interchangeable providers, such as proxy services. Every provider receives full protection: retries, backoff, attempt timeouts and an independent circuit. A router chooses providers, switches after failures and manages the logical request. + +Provider selection can follow fixed order, explicit priorities, a retained current provider, rotation or observed reliability. These are alternative selection policies over the same protection and execution lifecycle. + +`protect()` remains an independent API. Routing configuration uses provider objects, not manually constructed `protect()` runners. Internal protection mechanisms can be reused without exposing that composition to users. + +## API direction + +```ts +const router = createRouter({ + providers: [ + { id: 'a', resource: clientA }, + { id: 'b', resource: clientB }, + { + id: 'c', + resource: clientC, + protection: { attempts: 2 }, + }, + ], + protection: { + attempts: 3, + timeout: 5_000, + circuit: { threshold: 5, cooldown: 30_000 }, + }, + selection: { + type: 'priority', + priorities: { a: 30, c: 20, b: 10 }, + }, + errorPolicy: decideError, + concurrency: 8, + maxPending: 100, +}) + +const response = await router.execute((client, { signal }) => client.fetch(url, { signal }), { + signal: controller.signal, +}) +``` + +The router is an object with an `execute` method. Each call supplies an operation, which receives the selected provider's resource and a context containing an `AbortSignal`. Its result type is inferred separately for each call; there is no router-specific request type. + +`clientA`, `clientB` and `clientC` are application-owned clients. The callback's `client` is whichever resource the router selects. Resources need a compatible interface; adapters can normalize different SDKs. + +Different operations share provider circuits, selection state and the queue. Use the same router only when those operations should share a provider health assessment. + +Router-level `protection` supplies shared settings, while a provider's `protection` overrides them. Shared settings do not imply shared circuit state. Each provider has its own retry budget; there is no combined attempt budget in this contract. + +Stable, unique IDs identify providers in circuit state, statistics, priorities and error reports. Array order supplies a default order; it is not the only way to express preference. In the example, explicit priorities prefer A, then C, then B, independently of their array positions. + +`selection` groups each policy with its settings. Its type should distinguish policy variants so priority settings cannot be supplied to sticky or round-robin selection: + +```ts +selection: { + type: 'ordered' +} +selection: { + type: 'sticky' +} +selection: { + type: 'round-robin' +} +``` + +Adaptive selection's settings depend on the observation and scoring rules described below. + +## Responsibilities + +### Provider protection + +Own attempt execution, retries, backoff, attempt timeouts and circuit transitions. Track each provider independently. One provider's success must not reset another provider's failure history. + +### Selection policy + +Choose or rank candidates, decide whether to retain a successful provider and determine when to revisit a preferred or less-observed provider. Selection uses protection outcomes and statistics, but does not implement circuit transitions. + +### Router + +Own request admission, traversal, shared concurrency and queueing, external cancellation, result delivery and error reporting. Coordinate candidate selection with atomic circuit admission. + +## Error policy + +Retrying the operation, switching providers and counting a circuit failure are independent decisions. The `errorPolicy` callback determines how to handle operation errors: + +```ts +function decideError(error: unknown) { + if (isTemporaryConflict(error)) { + return { + retry: true, + failover: false, + countForCircuit: false, + } + } + + const providerFailure = isProviderFailure(error) + + return { + retry: providerFailure, + failover: providerFailure, + countForCircuit: providerFailure, + } +} +``` + +The predicates are application rules, not router exports. In this example, temporary conflicts can be retried on the same provider without damaging its circuit. Provider errors permit retries and failover and count toward the circuit after the visit fails. Other errors stop the request without affecting provider health. + +- `retry` permits another attempt within the selected provider's remaining budget. +- `failover` permits switching when no further local retry will be made, either because retries are disabled for the error or the budget is exhausted. +- `countForCircuit` determines whether the visit's final error counts as a provider failure. Intermediate retry errors are not counted separately. + +An individual call can override the router's default `errorPolicy`: + +```ts +await router.execute(operation, { + signal, + errorPolicy: decideWriteError, +}) +``` + +The override replaces the policy for that call, including all provider visits; it is not merged with the default policy's decisions. This lets different operations share provider protection state while controlling whether they can safely be repeated. A read may permit retries after a lost connection, while a write with an unknown result may forbid both retry and failover. + +External cancellation, router admission errors and circuit rejection bypass operation error classification. Circuit rejection skips the candidate; cancellation and admission errors reject directly. + +If `errorPolicy` throws, stop the request without retries or failover. Treat the provider health outcome as neutral, release any logical probe reservation and complete the internal execution lifecycle under the normal callback and slot-release rules. Report both the original operation error and the policy error; the exact error shape remains open. + +## Selection policies + +### Ordered traversal + +Try providers in list order for every request. Apply the selected provider's retries and error policy before moving to the next candidate. Return the first success. + +There is no retained current provider. The next request starts from the beginning again, skipping candidates rejected by their circuits. + +### Explicit priority + +Prefer the highest-priority candidate, with list order as a deterministic tie-breaker. Explicit priorities allow a provider's preference to change without changing its identity or protection state. + +After a preferred provider's cooldown, a real request can act as its half-open probe. While that probe is occupied, other requests continue through lower-priority providers. A successful probe restores normal preference; a classified provider failure opens the circuit again. + +For example, traffic moves from A to B while A's circuit is open. After cooldown, one request checks A. Success returns preference to A; a classified provider failure keeps traffic on another provider. + +Whether priority updates affect an already-running traversal or only subsequent selections must be defined before exposing runtime updates. + +### Sticky selection + +Retain the provider that succeeds and begin subsequent requests there. Switch when the current provider's visit fails with failover permitted or its circuit rejects admission. + +For example, A fails and B succeeds. B remains current even when A's cooldown expires. A becomes eligible for another visit, but cooldown alone does not cause a return to A. + +Concurrent recovery needs a deterministic rule for which successful switch becomes current. A late result must not overwrite a newer selection blindly. + +### Round-robin + +Rotate the starting candidate between requests, skipping circuit-rejected candidates and trying alternatives after final failure when classification permits failover. Rotation must not allow revisiting a provider within the same request. + +Define how concurrent admissions advance rotation and how failed or cancelled requests affect it. + +### Adaptive selection + +Use observed reliability and latency to prefer a suitable provider rather than relying only on fixed order or a retained choice. + +The design must account for: + +- Sample size and confidence before trusting a provider's score. +- Aging observations and recovery from past failures. +- Exploration of less-used providers so their statistics do not remain stale. +- Stability, so small or temporary differences do not cause constant switching. +- The relative cost of errors, latency and retries. + +No scoring formula is selected yet. + +Exploration can use eligible real requests. Background probing would need a separate, explicitly safe health-check operation. Do not duplicate arbitrary user requests merely to gather statistics. + +## Traversal and protection contract + +- Visit each provider at most once per logical request. A visit includes that provider's retries and backoff; individual retry attempts are not separate visits. +- On a visit's final error, select an unvisited candidate only if classification permits failover; otherwise reject with that error. Circuit rejection skips to an unvisited candidate. Return the first successful result. Do not cycle through already-visited providers. +- Count a visit's final error toward its circuit only when classification marks it as a provider failure. Do not count each failed attempt separately. Intermediate attempt errors remain available in statistics even when the visit's final outcome is neutral. +- External cancellation, router admission errors and circuit rejection do not count as provider failures. Final attempt timeouts are classified like other operation errors. +- A final failure need not open a circuit immediately: the configured failure threshold still applies. Selection policy can switch before that threshold is reached. +- Apply circuit changes before reporting the outcome to selection or user hooks. Ignore results from obsolete circuit generations. + +Circuit admission is atomic when a visit starts. An open circuit or an occupied half-open probe skips that candidate without executing its handler. A separate availability check must not be required for correctness. + +Status reads can inform selection and observability, but are advisory. Another request may reserve a probe after a status read. The router must handle admission rejection by selecting another candidate. + +Circuit health has three outcomes: success, failure and neutral. A final error with `countForCircuit: false` is neutral: it neither increments nor resets the failure count. Thus `failure → neutral → failure` represents two consecutive counted failures. Cancellation and policy exceptions are also neutral. + +A half-open probe uses the provider's ordinary retry and timeout settings and the call's effective error policy. Success closes the circuit; a classified provider failure starts another cooldown. A neutral outcome releases the logical probe reservation without closing the circuit or starting another cooldown. The circuit remains half-open and another request may probe immediately. An uncooperative, cancelled callback may still be running. + +## Exhaustion errors + +When all candidates have failed or been skipped, reject with a structured routing error containing the ordered visit history. Each entry identifies the provider and distinguishes: + +- `failed`: the visit ended with an operation error; preserve that cause. +- `skipped`: circuit admission rejected the visit; distinguish an open circuit from an occupied half-open probe. + +This must distinguish all providers failing, a mix of failures and skipped candidates, and no provider being admitted. The exact error class and field names remain open. + +External cancellation, queue overflow and an operation error with `failover: false` reject directly rather than being wrapped as candidate exhaustion. A policy exception reports the operation and policy errors separately from exhaustion. + +## Shared queue and concurrency + +One router queue and concurrency limit govern entire logical requests. Hold one slot across provider visits, retries, backoff and switches. A switch must not enqueue the same request again or reacquire its router slot. + +Provider-specific capacity limits may be useful in addition to the shared limit. Their interaction with candidate selection and waiting remains an open design question; they must not replace the shared request lifecycle. + +## Cancellation and timeout lifecycle + +External cancellation covers queue wait, attempts, backoff and switching. It rejects the public promise promptly and prevents starting another retry or provider. Queued cancellation removes the request without invoking a handler. + +The public promise and the internal execution lifecycle are distinct. Cancellation of an active request must not release its router slot merely because the public promise rejected; the current callback can still be running. + +Preserve the provider's timeout mode: + +- **Immediate:** abort the attempt's signal and end the logical attempt without waiting for the callback. Retries or other providers can start while a detached callback is still running. Release the slot when the logical traversal finishes. +- **Wait:** abort the attempt's signal and wait for the callback to settle before retrying or switching. An uncooperative callback can stall the request or probe indefinitely. + +Callbacks detached by an immediate timeout are not awaited again at the end of traversal. Concurrency limits logical requests, not every physical callback left running after timeout. Only route operations that are safe to repeat. + +An external deadline can bound the entire request across providers; an attempt timeout applies only to its provider attempt. + +## Statistics and observability + +Keep observations associated with provider IDs, independently of array order. Distinguish provider health from router-level outcomes: a successful request through B does not turn A's failed visit into a success. + +Useful observations include final visit successes and failures, timeouts, attempt counts, latency and circuit transitions. Cancellation, circuit rejection and errors excluded from provider health must be distinguishable from resource failures. + +Define whether latency includes retries and backoff, which observations adaptive selection consumes and how samples age. Queue wait should not be mistaken for provider execution latency. + +Expose sufficient state and outcomes for inspection without exposing a mutable circuit or treating a status snapshot as permission to execute. + +## Implementation boundary + +Reuse internal protection mechanisms rather than nesting public `protect()` runners. A nested runner's public promise can reject on cancellation before its callback settles, causing an outer runner to release a concurrency slot prematurely. + +Share the lifecycle model between selection policies. Ordered traversal, sticky, priority, round-robin and adaptive selection must not each reimplement retries, circuit admission or cancellation. + +The existing per-call fallback API is independent. + +## Open decisions + +- Exact resource and operation context types. +- Whether custom policies need a public extension contract. +- Default error policy and the error shape preserving both operation and policy exceptions. +- Deterministic sticky and round-robin behavior under concurrent requests. +- Exact routing error shape for ordered failed and skipped visits. +- Provider-specific capacity limits and their interaction with selection. +- Statistics windows, exploration, confidence and stability rules for adaptive selection. +- Runtime priority changes, provider replacement and preservation or reset of protection state. +- Inheritance rules for nested protection settings and explicitly disabling an inherited circuit. From 689a36834b801a39697ccd69d448b32a47e6eb18 Mon Sep 17 00:00:00 2001 From: unsady <38117114+unsady@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:45:50 +0200 Subject: [PATCH 2/4] feat!: revise protect error handling --- README.md | 53 ++++++++--- src/errors.ts | 7 ++ src/fallback.ts | 16 ++-- src/index.ts | 4 +- src/protect.test.ts | 218 ++++++++++++++++++++++++++++++++++---------- src/protect.ts | 66 +++++++++----- src/retry.ts | 49 ++++++++-- src/types.ts | 12 ++- 8 files changed, 321 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index 6f2cf5c..e11dd26 100644 --- a/README.md +++ b/README.md @@ -26,13 +26,10 @@ const run = protect({ threshold: 5, cooldown: 30_000, }, - onError: (error) => { - console.error('Operation failed', error) - }, }) const url = 'https://api.example.com/users/1' -const response = await run(({ signal }) => fetch(url, { signal })) +const response = await run(() => fetch(url)) ``` `protect()` creates a runner. Pass a callback to run an operation when a concurrency slot is available. Each call returns a promise with the operation's result or error. @@ -47,9 +44,36 @@ Without `backoff`, retries start immediately. To add a delay, provide `backoff.t `backoff.jitter` is an optional fraction from `0` to `1` that randomly reduces the base delay. With a 1,000 millisecond base delay, `jitter: 0.2` produces delays between 800 and 1,000 milliseconds. It defaults to `0`: no jitter. Use `1` for full jitter between zero and the base delay. -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. +By default, 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. + +## Error policy + +Use `errorPolicy` to decide whether an operation error permits another attempt and whether its final failure counts toward the circuit: + +```ts +const run = protect({ + attempts: 3, + errorPolicy: (error) => ({ + retry: isTransient(error), + countForCircuit: isServiceFailure(error), + }), +}) + +await run(writeOperation, { + errorPolicy: (error) => ({ + retry: false, + countForCircuit: isServiceFailure(error), + }), +}) +``` + +The predicates are application-defined. A synchronous policy runs after each failed attempt, including timeout, before retrying. `retry` permits another attempt within the configured limit; `countForCircuit` applies only to the final error. Without a policy, both are `true`. + +A policy in the run options replaces the runner's policy for that call. Errors excluded from the circuit are neutral: they neither increase nor reset its failure count. External cancellation and queue or circuit admission errors bypass the policy. + +If the policy throws, the call rejects with `ErrorPolicyError`, an `AggregateError` whose `errors` contains the operation error followed by the policy error, and whose `cause` is the operation error. No retry or fallback runs. This is a neutral circuit outcome and releases a probe reservation. -`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. +Handle final call errors with `try`/`catch` or `.catch()`. ## Timeout @@ -70,13 +94,13 @@ To limit the entire call, including queue wait and backoff, pass an external sig ## 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. +`circuit: { threshold: 5, cooldown: 30_000 }` opens the circuit after five consecutive counted failures, evaluated when each call ends. 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. +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. A neutral probe outcome, including cancellation or an error excluded by the policy, allows another probe immediately without closing the circuit or starting another cooldown. -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. +External cancellation, `QueueFullError` and `CircuitOpenError` neither count as failures nor reset the count. Final timeouts count as failures unless excluded by the error policy. 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. @@ -85,21 +109,20 @@ Circuit state is shared by all calls on a runner and independent between runners 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 })), +const response = await run(() => fetch(url), { + fallback: () => fetch(backupUrl), }) ``` -`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(error, { signal })` runs once after the operation's final error, including when retries are disabled by the policy, 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, the external signal is aborted or the error policy throws. Disabling retry does not disable fallback; its callback must decide whether recovery is safe. 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. +If fallback throws or rejects, its error becomes the call's rejection. -External cancellation also rejects a call while fallback is running. To stop the backup operation itself, forward the same external signal to its runner. +Fallback receives the external signal, or a non-aborted signal when none was supplied. This is the call's signal, not an attempt signal aborted by timeout. External cancellation also rejects a call while fallback is running. Forward the fallback context's signal to its operation or backup runner for cooperative cancellation. ## Cancellation diff --git a/src/errors.ts b/src/errors.ts index 4f912f0..a9aef53 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -5,6 +5,13 @@ export class CircuitOpenError extends Error { } } +export class ErrorPolicyError extends AggregateError { + constructor(error: unknown, policyError: unknown) { + super([error, policyError], 'The error policy threw', { cause: error }) + this.name = 'ErrorPolicyError' + } +} + export class QueueFullError extends Error { constructor() { super('The pending operation limit has been reached') diff --git a/src/fallback.ts b/src/fallback.ts index 7890b1f..8000d6a 100644 --- a/src/fallback.ts +++ b/src/fallback.ts @@ -1,27 +1,29 @@ +import type { RunContext } from './types.js' + export function recover( - fallback: (error: unknown) => Result, + fallback: (error: unknown, context: RunContext) => Result, error: unknown, - signal: AbortSignal | undefined, + signal: AbortSignal = new AbortController().signal, ): Promise> { return new Promise((resolve, reject) => { - if (signal?.aborted) { + if (signal.aborted) { reject(signal.reason) return } function finish(): void { - signal?.removeEventListener('abort', onAbort) + signal.removeEventListener('abort', onAbort) } function onAbort(): void { finish() - reject(signal?.reason) + reject(signal.reason) } - signal?.addEventListener('abort', onAbort, { once: true }) + signal.addEventListener('abort', onAbort, { once: true }) try { - void Promise.resolve(fallback(error)).then( + void Promise.resolve(fallback(error, { signal })).then( (value) => { finish() resolve(value) diff --git a/src/index.ts b/src/index.ts index 545bcee..bab1af6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,10 @@ -export { CircuitOpenError, QueueFullError, TimeoutError } from './errors.js' +export { CircuitOpenError, ErrorPolicyError, QueueFullError, TimeoutError } from './errors.js' export { protect } from './protect.js' export type { BackoffOptions, CircuitOptions, + ErrorDecision, + ErrorPolicy, ProtectOptions, RunContext, Runner, diff --git a/src/protect.test.ts b/src/protect.test.ts index 0299961..ff41712 100644 --- a/src/protect.test.ts +++ b/src/protect.test.ts @@ -2,9 +2,11 @@ import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'v import { CircuitOpenError, + ErrorPolicyError, protect, QueueFullError, TimeoutError, + type ErrorPolicy, type RunContext, } from './index.js' @@ -52,18 +54,16 @@ it('retries immediately without backoff and returns the successful result', asyn expect(operation).toHaveBeenCalledTimes(3) }) -it('stops at the attempt limit and reports the final error once', async () => { +it('stops at the attempt limit with exponential backoff', async () => { const error = new Error('unavailable') const times: number[] = [] const operation = vi.fn<() => Promise>(() => { times.push(Date.now()) return Promise.reject(error) }) - const onError = vi.fn<(error: unknown) => void>() const run = protect({ attempts: 4, backoff: { type: 'exponential', delay: 10 }, - onError, }) const rejected = run(operation).catch((error: unknown) => error) @@ -72,7 +72,6 @@ it('stops at the attempt limit and reports the final error once', async () => { expect(operation).toHaveBeenCalledTimes(4) expect(times).toEqual([0, 10, 30, 70]) - expect(onError.mock.calls).toEqual([[error]]) }) it.each<{ type: 'fixed' | 'exponential'; secondDelay: number }>([ @@ -105,7 +104,8 @@ it.each<{ type: 'fixed' | 'exponential'; secondDelay: number }>([ }) it('rejects excess calls without queuing by default', async () => { - const run = protect({ concurrency: 1 }) + const policy = vi.fn(() => ({ retry: true, countForCircuit: true })) + const run = protect({ concurrency: 1, errorPolicy: policy }) const blocker = deferred() const running = run(() => blocker.promise) const overflow = vi.fn<() => string>(() => 'saved') @@ -116,6 +116,7 @@ it('rejects excess calls without queuing by default', async () => { blocker.resolve() await running await expect(run(overflow)).resolves.toBe('saved') + expect(policy).not.toHaveBeenCalled() }) it('shares concurrency and pending limits across operations and releases a slot after failure', async () => { @@ -145,7 +146,8 @@ it('shares concurrency and pending limits across operations and releases a slot }) it('skips already aborted operations and frees a pending slot on cancellation', async () => { - const run = protect({ concurrency: 1, maxPending: 1 }) + const policy = vi.fn(() => ({ retry: true, countForCircuit: true })) + const run = protect({ concurrency: 1, maxPending: 1, errorPolicy: policy }) const controller = new AbortController() const reason = new Error('cancelled') const skipped = vi.fn<() => void>() @@ -165,10 +167,12 @@ it('skips already aborted operations and frees a pending slot on cancellation', await running await expect(replacement).resolves.toBe('replacement') expect(skipped).not.toHaveBeenCalled() + expect(policy).not.toHaveBeenCalled() }) it('forwards cancellation without releasing the slot until the operation settles or retrying it', async () => { - const run = protect({ concurrency: 1, maxPending: 1, attempts: 3 }) + const policy = vi.fn(() => ({ retry: true, countForCircuit: true })) + const run = protect({ concurrency: 1, maxPending: 1, attempts: 3, errorPolicy: policy }) const controller = new AbortController() const blocker = deferred() const reason = new Error('cancelled') @@ -190,6 +194,7 @@ it('forwards cancellation without releasing the slot until the operation settles blocker.resolve() await expect(waiting).resolves.toBe('next') expect(operation).toHaveBeenCalledTimes(1) + expect(policy).not.toHaveBeenCalled() }) it('gives each attempt its own timeout, excluding backoff and queue wait', async () => { @@ -238,13 +243,11 @@ it('gives each attempt its own timeout, excluding backoff and queue wait', async }) it('releases the slot on the final timeout and ignores a late callback rejection', async () => { - const onError = vi.fn<(error: unknown) => void>() const run = protect({ concurrency: 1, maxPending: 1, timeout: 100, timeoutMode: 'immediate', - onError, }) const blocker = deferred() const timedOut = run(async () => { @@ -259,18 +262,15 @@ it('releases the slot on the final timeout and ignores a late callback rejection blocker.resolve() await vi.advanceTimersByTimeAsync(0) - expect(onError).toHaveBeenCalledTimes(1) }) it('waits for timed-out callbacks before retrying or releasing the slot in wait mode', async () => { - const onError = vi.fn<(error: unknown) => void>() const run = protect({ concurrency: 1, maxPending: 1, attempts: 2, timeout: 100, timeoutMode: 'wait', - onError, }) const first = deferred() const second = deferred() @@ -291,19 +291,135 @@ it('waits for timed-out callbacks before retrying or releasing the slot in wait await vi.advanceTimersByTimeAsync(100) expect(signals[1]?.reason).toBeInstanceOf(TimeoutError) expect(operation).toHaveBeenCalledTimes(2) - expect(onError).not.toHaveBeenCalled() expect(next).not.toHaveBeenCalled() second.resolve() await expect(timedOut).resolves.toBeInstanceOf(TimeoutError) await expect(waiting).resolves.toBe('next') - expect(onError).toHaveBeenCalledTimes(1) +}) + +describe('error policy', () => { + it('replaces the runner policy per call and classifies the final error independently', async () => { + const error = new Error('provider failed') + const excluded = new Error('invalid input') + const policy = vi.fn(() => ({ retry: false, countForCircuit: true })) + const override = vi.fn((failure) => ({ + retry: failure === error, + countForCircuit: failure === error, + })) + const run = protect({ + attempts: 3, + circuit: { threshold: 1, cooldown: 100 }, + errorPolicy: policy, + }) + const operation = vi + .fn<() => Promise>() + .mockRejectedValueOnce(error) + .mockRejectedValue(excluded) + + await expect(run(operation, { errorPolicy: override })).rejects.toBe(excluded) + expect(operation).toHaveBeenCalledTimes(2) + expect(override.mock.calls).toEqual([[error], [excluded]]) + expect(policy).not.toHaveBeenCalled() + + await expect(run(operation)).rejects.toBe(excluded) + expect(operation).toHaveBeenCalledTimes(3) + expect(policy.mock.calls).toEqual([[excluded]]) + await expect(run(operation)).rejects.toBeInstanceOf(CircuitOpenError) + expect(policy).toHaveBeenCalledTimes(1) + }) + + it('preserves counted failures across neutral errors and permits an immediate replacement probe', async () => { + const error = new Error('provider failed') + const excluded = new Error('invalid input') + const run = protect({ + circuit: { threshold: 2, cooldown: 100 }, + errorPolicy: (failure) => ({ retry: false, countForCircuit: failure === error }), + }) + + function fail(): never { + throw error + } + + function neutral(): never { + throw excluded + } + + await expect(run(fail)).rejects.toBe(error) + await expect(run(neutral)).rejects.toBe(excluded) + await expect(run(fail)).rejects.toBe(error) + await expect(run(() => 'skipped')).rejects.toBeInstanceOf(CircuitOpenError) + await vi.advanceTimersByTimeAsync(100) + await expect(run(neutral)).rejects.toBe(excluded) + + const blocker = deferred() + const probe = run(() => blocker.promise) + + await expect(run(() => 'skipped')).rejects.toBeInstanceOf(CircuitOpenError) + blocker.resolve() + await probe + await expect(run(() => 'saved')).resolves.toBe('saved') + }) + + it('preserves both errors when the policy throws, skips fallback and releases the probe and slot', async () => { + const error = new Error('provider failed') + const policyError = new Error('policy failed') + const run = protect({ + concurrency: 1, + attempts: 3, + circuit: { threshold: 1, cooldown: 100 }, + }) + const operation = vi.fn<() => never>(() => { + throw error + }) + + await expect(run(operation)).rejects.toBe(error) + await vi.advanceTimersByTimeAsync(100) + operation.mockClear() + + const policy = vi.fn(() => { + throw policyError + }) + const fallback = vi.fn<() => string>(() => 'backup') + const failure = await run(operation, { errorPolicy: policy, fallback }).catch( + (error: unknown) => error, + ) + + expect(failure).toBeInstanceOf(ErrorPolicyError) + expect(failure).toMatchObject({ errors: [error, policyError], cause: error }) + expect(operation).toHaveBeenCalledTimes(1) + expect(policy).toHaveBeenCalledTimes(1) + expect(fallback).not.toHaveBeenCalled() + + const blocker = deferred() + const probe = run(() => blocker.promise) + + await expect(run(() => 'skipped')).rejects.toBeInstanceOf(CircuitOpenError) + blocker.resolve() + await probe + await expect(run(() => 'saved')).resolves.toBe('saved') + }) + + it('allows fallback even when retries are disabled', async () => { + const error = new Error('invalid input') + const run = protect({ + attempts: 3, + errorPolicy: () => ({ retry: false, countForCircuit: false }), + }) + const operation = vi.fn<() => never>(() => { + throw error + }) + const fallback = vi.fn<() => string>(() => 'cached') + + await expect(run(operation, { fallback })).resolves.toBe('cached') + expect(operation).toHaveBeenCalledTimes(1) + expect(fallback).toHaveBeenCalledTimes(1) + }) }) describe('fallback', () => { it('runs only after final failure and returns an awaited, correctly typed result', async () => { - const onError = vi.fn<(error: unknown) => void>() - const run = protect({ attempts: 3, onError }) + const run = protect({ attempts: 3 }) const error = new Error('primary failed') const operation = vi.fn<() => Promise>().mockRejectedValue(error) const fallback = vi.fn<(error: unknown) => Promise>(async () => 42) @@ -316,16 +432,14 @@ describe('fallback', () => { expectTypeOf(result).toEqualTypeOf>() await expect(result).resolves.toBe(42) expect(operation).toHaveBeenCalledTimes(3) - expect(fallback.mock.calls).toEqual([[error]]) - expect(onError).not.toHaveBeenCalled() + expect(fallback.mock.calls[0]?.[0]).toBe(error) expectTypeOf(run(() => 'primary')).toEqualTypeOf>() }) it.each(['throw', 'reject'] as const)( 'reports a fallback %s without retrying it', async (mode) => { - const onError = vi.fn<(error: unknown) => void>() - const run = protect({ attempts: 3, onError }) + const run = protect({ attempts: 3 }) const error = new Error('backup failed') const fallback = vi.fn<(error: unknown) => Promise>(() => { if (mode === 'throw') { @@ -344,13 +458,11 @@ describe('fallback', () => { ), ).rejects.toBe(error) expect(fallback).toHaveBeenCalledTimes(1) - expect(onError.mock.calls).toEqual([[error]]) }, ) it('can use a backup runner without closing the primary circuit', async () => { - const onError = vi.fn<(error: unknown) => void>() - const run = protect({ circuit: { threshold: 1, cooldown: 100 }, onError }) + const run = protect({ circuit: { threshold: 1, cooldown: 100 } }) const backup = protect({ attempts: 2, circuit: { threshold: 1, cooldown: 100 } }) const error = new Error('primary failed') const operation = vi @@ -362,23 +474,33 @@ describe('fallback', () => { await expect(run(operation, { fallback })).resolves.toBe('backup') await expect(run(operation, { fallback })).resolves.toBe('backup') expect(operation).toHaveBeenCalledTimes(1) - expect(fallback.mock.calls[0]).toEqual([error]) + expect(fallback.mock.calls[0]?.[0]).toBe(error) expect(fallback.mock.calls[1]?.[0]).toBeInstanceOf(CircuitOpenError) await vi.advanceTimersByTimeAsync(100) await expect(run(operation, { fallback })).resolves.toBe('primary') expect(fallback).toHaveBeenCalledTimes(2) - expect(onError).not.toHaveBeenCalled() }) it('handles final timeout outside the primary concurrency slot and timeout budget', async () => { - const run = protect({ concurrency: 1, timeout: 100 }) + const policy = vi.fn(() => ({ retry: false, countForCircuit: false })) + const run = protect({ + concurrency: 1, + attempts: 3, + timeout: 100, + circuit: { threshold: 1, cooldown: 100 }, + errorPolicy: policy, + }) const primary = deferred() const backup = deferred() - const fallback = vi.fn<(error: unknown) => Promise>(async () => { - await backup.promise - return 42 - }) + let received: AbortSignal | undefined + const fallback = vi.fn<(error: unknown, context: RunContext) => Promise>( + async (_error, { signal }) => { + received = signal + await backup.promise + return 42 + }, + ) const result = run( async () => { await primary.promise @@ -389,6 +511,10 @@ describe('fallback', () => { await vi.advanceTimersByTimeAsync(100) expect(fallback.mock.calls[0]?.[0]).toBeInstanceOf(TimeoutError) + expect(policy.mock.calls[0]?.[0]).toBeInstanceOf(TimeoutError) + expect(policy).toHaveBeenCalledTimes(1) + expect(received).toBeInstanceOf(AbortSignal) + expect(received?.aborted).toBe(false) await expect(run(() => 'primary')).resolves.toBe('primary') await vi.advanceTimersByTimeAsync(1_000) @@ -447,15 +573,18 @@ describe('fallback', () => { }) it('cancels an active fallback and ignores its late rejection', async () => { - const onError = vi.fn<(error: unknown) => void>() - const run = protect({ onError }) + const run = protect() const controller = new AbortController() const blocker = deferred() const reason = new Error('cancelled') - const fallback = vi.fn<(error: unknown) => Promise>(async () => { - await blocker.promise - throw new Error('late') - }) + let received: AbortSignal | undefined + const fallback = vi.fn<(error: unknown, context: RunContext) => Promise>( + async (_error, { signal }) => { + received = signal + await blocker.promise + throw new Error('late') + }, + ) const cancelled = run( () => { throw new Error('primary failed') @@ -465,12 +594,13 @@ describe('fallback', () => { await vi.advanceTimersByTimeAsync(0) expect(fallback).toHaveBeenCalledTimes(1) + expect(received).toBe(controller.signal) controller.abort(reason) await expect(cancelled).resolves.toBe(reason) + expect(received?.reason).toBe(reason) blocker.resolve() await vi.advanceTimersByTimeAsync(0) - expect(onError.mock.calls).toEqual([[reason]]) }) }) @@ -592,16 +722,11 @@ describe('circuit', () => { expect(skipped).not.toHaveBeenCalled() }) - it('counts only the final timeout and ignores detached callbacks and hook errors', async () => { - const hookError = new Error('hook failed') - const onError = vi.fn<(error: unknown) => void>(() => { - throw hookError - }) + it('counts only the final timeout and ignores detached callbacks', async () => { const run = protect({ attempts: 2, timeout: 100, circuit: { threshold: 1, cooldown: 100 }, - onError, }) const blocker = deferred() const timedOut = run(() => blocker.promise).catch((error: unknown) => error) @@ -609,16 +734,13 @@ describe('circuit', () => { await vi.advanceTimersByTimeAsync(100) await expect(run(() => 'available')).resolves.toBe('available') await vi.advanceTimersByTimeAsync(100) - await expect(timedOut).resolves.toBe(hookError) - expect(onError).toHaveBeenCalledTimes(1) - expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(TimeoutError) + await expect(timedOut).resolves.toBeInstanceOf(TimeoutError) blocker.resolve() await vi.advanceTimersByTimeAsync(0) const skipped = vi.fn<() => void>() - await expect(run(skipped)).rejects.toBe(hookError) - expect(onError.mock.calls[1]?.[0]).toBeInstanceOf(CircuitOpenError) + await expect(run(skipped)).rejects.toBeInstanceOf(CircuitOpenError) expect(skipped).not.toHaveBeenCalled() }) diff --git a/src/protect.ts b/src/protect.ts index aa5b991..7fdb85f 100644 --- a/src/protect.ts +++ b/src/protect.ts @@ -10,7 +10,12 @@ interface PendingOperation { } export function protect(options: ProtectOptions = {}): Runner { - const { attempts = 1, timeout, timeoutMode = 'immediate', onError } = options + const { + attempts = 1, + timeout, + timeoutMode = 'immediate', + errorPolicy: defaultErrorPolicy, + } = options const backoff = options.backoff === undefined ? undefined @@ -47,6 +52,9 @@ export function protect(options: ProtectOptions = {}): Runner { runOptions: RunOptions = {}, ): Promise | Awaited> { const { signal, fallback } = runOptions + const errorPolicy = runOptions.errorPolicy ?? defaultErrorPolicy + let recoverable = true + const result = new Promise>((resolve, reject) => { if (signal?.aborted) { reject(signal.reason) @@ -121,26 +129,38 @@ export function protect(options: ProtectOptions = {}): Runner { backoff, timeout, timeoutMode, + errorPolicy, }).then( - (value) => { - if (admission !== undefined && !controller.signal.aborted) { - circuit?.succeed(admission.generation) + (outcome) => { + if (outcome.ok) { + if (admission !== undefined && !controller.signal.aborted) { + circuit?.succeed(admission.generation) + } + + resolve(outcome.value) + } else { + if (admission !== undefined) { + if ( + controller.signal.aborted || + !outcome.countForCircuit || + outcome.error instanceof QueueFullError || + outcome.error instanceof CircuitOpenError + ) { + circuit?.cancel(admission.generation) + } else if (circuit?.fail(admission.generation)) { + rejectQueue() + } + } + + recoverable = outcome.recoverable + reject(outcome.error) } - resolve(value) finish() }, (error: unknown) => { if (admission !== undefined) { - if ( - controller.signal.aborted || - error instanceof QueueFullError || - error instanceof CircuitOpenError - ) { - circuit?.cancel(admission.generation) - } else if (circuit?.fail(admission.generation)) { - rejectQueue() - } + circuit?.cancel(admission.generation) } reject(error) @@ -161,18 +181,16 @@ export function protect(options: ProtectOptions = {}): Runner { } }) - const recovered = - fallback === undefined - ? result - : result.catch((error: unknown) => recover(fallback, error, signal)) - - if (onError === undefined) { - return recovered + if (fallback === undefined) { + return result } - return recovered.catch((error: unknown) => { - onError(error) - throw error + return result.catch((error: unknown) => { + if (!recoverable) { + throw error + } + + return recover(fallback, error, signal) }) } diff --git a/src/retry.ts b/src/retry.ts index bb98bff..ae37412 100644 --- a/src/retry.ts +++ b/src/retry.ts @@ -1,13 +1,24 @@ -import { TimeoutError } from './errors.js' -import type { BackoffOptions, RunContext, TimeoutMode } from './types.js' +import { ErrorPolicyError, TimeoutError } from './errors.js' +import type { + BackoffOptions, + ErrorDecision, + ErrorPolicy, + RunContext, + TimeoutMode, +} from './types.js' interface ExecutionOptions { attempts: number backoff: Required | undefined timeout: number | undefined timeoutMode: TimeoutMode + errorPolicy: ErrorPolicy | undefined } +type ExecutionResult = + | { ok: true; value: Awaited } + | { ok: false; error: unknown; countForCircuit: boolean; recoverable: boolean } + function wait(duration: number, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { signal.throwIfAborted() @@ -80,20 +91,44 @@ async function runAttempt( export async function execute( operation: (context: RunContext) => Result, signal: AbortSignal, - { attempts, backoff, timeout, timeoutMode }: ExecutionOptions, -): Promise> { + { attempts, backoff, timeout, timeoutMode, errorPolicy }: ExecutionOptions, +): Promise> { let attempt = 1 while (true) { signal.throwIfAborted() try { - return await runAttempt(operation, signal, timeout, timeoutMode) + const value = await runAttempt(operation, signal, timeout, timeoutMode) + + return { ok: true, value } } catch (error) { signal.throwIfAborted() - if (attempt >= attempts) { - throw error + let decision: ErrorDecision + + try { + decision = errorPolicy?.(error) ?? { retry: true, countForCircuit: true } + } catch (policyError) { + signal.throwIfAborted() + + return { + ok: false, + error: new ErrorPolicyError(error, policyError), + countForCircuit: false, + recoverable: false, + } + } + + signal.throwIfAborted() + + if (!decision.retry || attempt >= attempts) { + return { + ok: false, + error, + countForCircuit: decision.countForCircuit, + recoverable: true, + } } if (backoff !== undefined) { diff --git a/src/types.ts b/src/types.ts index dd9c819..231a887 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,6 +11,13 @@ export interface CircuitOptions { export type TimeoutMode = 'immediate' | 'wait' +export interface ErrorDecision { + retry: boolean + countForCircuit: boolean +} + +export type ErrorPolicy = (error: unknown) => ErrorDecision + export interface ProtectOptions { attempts?: number backoff?: BackoffOptions @@ -19,7 +26,7 @@ export interface ProtectOptions { concurrency?: number maxPending?: number circuit?: CircuitOptions - onError?: (error: unknown) => void + errorPolicy?: ErrorPolicy } export interface RunContext { @@ -28,7 +35,8 @@ export interface RunContext { export interface RunOptions { signal?: AbortSignal - fallback?: (error: unknown) => Result + errorPolicy?: ErrorPolicy + fallback?: (error: unknown, context: RunContext) => Result } export interface Runner { From 4c15cee3ecf120f7eefda5dca9796733fb48ae76 Mon Sep 17 00:00:00 2001 From: unsady <38117114+unsady@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:45:50 +0200 Subject: [PATCH 3/4] feat: add priority and sticky provider routing --- README.md | 44 ++- docs/provider-routing.md | 40 ++- src/circuit.ts | 2 +- src/errors.ts | 17 +- src/index.ts | 18 +- src/protect.ts | 20 +- src/protection.ts | 25 ++ src/retry.ts | 30 +- src/router.test.ts | 573 +++++++++++++++++++++++++++++++++++++++ src/router.ts | 138 ++++++++++ src/scheduler.ts | 102 +++++++ src/types.ts | 48 +++- 12 files changed, 1000 insertions(+), 57 deletions(-) create mode 100644 src/protection.ts create mode 100644 src/router.test.ts create mode 100644 src/router.ts create mode 100644 src/scheduler.ts diff --git a/README.md b/README.md index e11dd26..91c1590 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,48 @@ const response = await run(() => fetch(url)) Reuse a runner to share concurrency and queue limits across operations. Each runner has independent limits. Without options, operations run once with no timeout or limits. +## Routing + +```ts +import { createRouter } from 'faultsafe' + +const router = createRouter({ + providers: [ + { id: 'a', resource: clientA }, + { id: 'b', resource: clientB }, + { id: 'c', resource: clientC, protection: { attempts: 2 } }, + ], + protection: { + attempts: 3, + timeout: 5_000, + circuit: { threshold: 5, cooldown: 30_000 }, + }, + selection: { + type: 'priority', + priorities: { a: 30, c: 20, b: 10 }, + }, + concurrency: 8, + maxPending: 100, +}) + +const response = await router.execute((client) => client.fetch(url)) +``` + +`clientA`, `clientB` and `clientC` are your interchangeable resources, not library classes. Pass the operation at each call; its result type is inferred independently. Use adapters when clients have different interfaces. Operations share provider circuits, so group only operations with a common health assessment. + +Each provider is visited at most once per request, with its own retries. The first success ends the request. One concurrency slot covers the entire traversal, including retries and backoff; queue and cancellation behavior match `protect()`. + +Supported selection policies: + +- `{ type: 'priority', priorities?: { ... } }` (default): highest priority first. Missing priorities are `0`; ties use registration order. After cooldown, a preferred provider can receive one recovery probe while other requests use alternatives. +- `{ type: 'sticky' }`: keep the successful provider until it fails or rejects circuit admission. Start with the first registered provider, then try other providers in registration order. The first successful switch from a selection generation wins; late results cannot overwrite a newer choice. + +Provider `protection` inherits shared settings. Nested `backoff` and `circuit` objects replace the inherited object entirely; `circuit: false` disables an inherited breaker. Provider IDs must be non-empty and unique. Explicit priority keys must identify registered providers, and values must be finite. Router concurrency must be a positive integer or `Infinity`, and `maxPending` a non-negative integer. + +A router's `errorPolicy`, or its replacement in `execute` options, returns `retry`, `failover` and `countForCircuit`. All three default to `true`. `failover` permits switching after local retries finish or are disabled. Circuit rejection skips a provider without running the policy. An error with `failover: false` rejects directly, and a policy exception rejects with `ErrorPolicyError` without switching. + +When every candidate fails or is skipped, `RoutingError.visits` contains the ordered history: `{ providerId, status: 'failed', cause }` or `{ providerId, status: 'skipped', reason }`. Skip reasons are `'circuit-open'` and `'probe-busy'`. Its `errors` array contains operation failures only. Cancellation and queue overflow reject directly rather than becoming routing errors. + ## Retry `attempts: 3` allows up to three attempts, including the initial call. By default, `attempts` is `1`: no retries. @@ -94,7 +136,7 @@ To limit the entire call, including queue wait and backoff, pass an external sig ## Circuit breaker -`circuit: { threshold: 5, cooldown: 30_000 }` opens the circuit after five consecutive counted failures, evaluated when each call ends. Success resets the count. Both settings are required; `cooldown` is in milliseconds. Without `circuit`, the breaker is disabled. +`circuit: { threshold: 5, cooldown: 30_000 }` opens the circuit after five consecutive counted failures, evaluated when each call ends. Success resets the count. Both settings are required; `cooldown` is in milliseconds. Without `circuit`, or with `circuit: false`, 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. diff --git a/docs/provider-routing.md b/docs/provider-routing.md index ac5778c..937dcbe 100644 --- a/docs/provider-routing.md +++ b/docs/provider-routing.md @@ -1,6 +1,6 @@ # Provider routing -Status: design draft. The API sketches are not implemented contracts. +`createRouter()` implements priority and sticky selection. Round-robin, adaptive selection, statistics and runtime reconfiguration below remain design proposals. ## Purpose @@ -37,9 +37,7 @@ const router = createRouter({ maxPending: 100, }) -const response = await router.execute((client, { signal }) => client.fetch(url, { signal }), { - signal: controller.signal, -}) +const response = await router.execute((client) => client.fetch(url)) ``` The router is an object with an `execute` method. Each call supplies an operation, which receives the selected provider's resource and a context containing an `AbortSignal`. Its result type is inferred separately for each call; there is no router-specific request type. @@ -48,24 +46,23 @@ The router is an object with an `execute` method. Each call supplies an operatio Different operations share provider circuits, selection state and the queue. Use the same router only when those operations should share a provider health assessment. -Router-level `protection` supplies shared settings, while a provider's `protection` overrides them. Shared settings do not imply shared circuit state. Each provider has its own retry budget; there is no combined attempt budget in this contract. +Router-level `protection` supplies shared settings, while a provider's `protection` overrides defined fields. Nested `backoff` and `circuit` objects replace inherited objects entirely; `circuit: false` disables an inherited circuit. Shared settings do not imply shared circuit state. Each provider has its own retry budget; there is no combined attempt budget in this contract. Stable, unique IDs identify providers in circuit state, statistics, priorities and error reports. Array order supplies a default order; it is not the only way to express preference. In the example, explicit priorities prefer A, then C, then B, independently of their array positions. -`selection` groups each policy with its settings. Its type should distinguish policy variants so priority settings cannot be supplied to sticky or round-robin selection: +`selection` groups each policy with its settings. The implemented type distinguishes priority and sticky variants: ```ts selection: { - type: 'ordered' + type: 'priority' } selection: { type: 'sticky' } -selection: { - type: 'round-robin' -} ``` +Priority without explicit priorities implements registration-order traversal. Round-robin remains a proposed policy. + Adaptive selection's settings depend on the observation and scoring rules described below. ## Responsibilities @@ -116,16 +113,17 @@ An individual call can override the router's default `errorPolicy`: ```ts await router.execute(operation, { - signal, errorPolicy: decideWriteError, }) ``` The override replaces the policy for that call, including all provider visits; it is not merged with the default policy's decisions. This lets different operations share provider protection state while controlling whether they can safely be repeated. A read may permit retries after a lost connection, while a write with an unknown result may forbid both retry and failover. +Without an error policy, `retry`, `failover` and `countForCircuit` are all `true`. + External cancellation, router admission errors and circuit rejection bypass operation error classification. Circuit rejection skips the candidate; cancellation and admission errors reject directly. -If `errorPolicy` throws, stop the request without retries or failover. Treat the provider health outcome as neutral, release any logical probe reservation and complete the internal execution lifecycle under the normal callback and slot-release rules. Report both the original operation error and the policy error; the exact error shape remains open. +If `errorPolicy` throws, stop the request without retries or failover. Treat the provider health outcome as neutral, release any logical probe reservation and complete the internal execution lifecycle under the normal callback and slot-release rules. Reject with `ErrorPolicyError`: its `errors` array contains the operation error followed by the policy error, and its `cause` is the operation error. ## Selection policies @@ -137,7 +135,7 @@ There is no retained current provider. The next request starts from the beginnin ### Explicit priority -Prefer the highest-priority candidate, with list order as a deterministic tie-breaker. Explicit priorities allow a provider's preference to change without changing its identity or protection state. +Prefer the highest-priority candidate, with list order as a deterministic tie-breaker. Missing priorities are `0`. Priority keys must identify registered providers and values must be finite. Explicit priorities allow a provider's preference to change without changing its identity or protection state. After a preferred provider's cooldown, a real request can act as its half-open probe. While that probe is occupied, other requests continue through lower-priority providers. A successful probe restores normal preference; a classified provider failure opens the circuit again. @@ -151,7 +149,7 @@ Retain the provider that succeeds and begin subsequent requests there. Switch wh For example, A fails and B succeeds. B remains current even when A's cooldown expires. A becomes eligible for another visit, but cooldown alone does not cause a return to A. -Concurrent recovery needs a deterministic rule for which successful switch becomes current. A late result must not overwrite a newer selection blindly. +The first registered provider is initially current. Each admitted request captures the current selection generation and tries the current provider followed by the remaining providers in registration order. The first successful switch from that generation updates the current provider and increments the generation. Late successes still return to their callers, but cannot overwrite a newer selection. ### Round-robin @@ -179,7 +177,7 @@ Exploration can use eligible real requests. Background probing would need a sepa - Visit each provider at most once per logical request. A visit includes that provider's retries and backoff; individual retry attempts are not separate visits. - On a visit's final error, select an unvisited candidate only if classification permits failover; otherwise reject with that error. Circuit rejection skips to an unvisited candidate. Return the first successful result. Do not cycle through already-visited providers. -- Count a visit's final error toward its circuit only when classification marks it as a provider failure. Do not count each failed attempt separately. Intermediate attempt errors remain available in statistics even when the visit's final outcome is neutral. +- Count a visit's final error toward its circuit only when classification marks it as a provider failure. Do not count each failed attempt separately. Proposed statistics should retain intermediate attempt errors even when the visit's final outcome is neutral. - External cancellation, router admission errors and circuit rejection do not count as provider failures. Final attempt timeouts are classified like other operation errors. - A final failure need not open a circuit immediately: the configured failure threshold still applies. Selection policy can switch before that threshold is reached. - Apply circuit changes before reporting the outcome to selection or user hooks. Ignore results from obsolete circuit generations. @@ -194,12 +192,12 @@ A half-open probe uses the provider's ordinary retry and timeout settings and th ## Exhaustion errors -When all candidates have failed or been skipped, reject with a structured routing error containing the ordered visit history. Each entry identifies the provider and distinguishes: +When all candidates have failed or been skipped, reject with `RoutingError`, an `AggregateError` with an ordered `visits` history. Each entry identifies the provider and distinguishes: - `failed`: the visit ended with an operation error; preserve that cause. - `skipped`: circuit admission rejected the visit; distinguish an open circuit from an occupied half-open probe. -This must distinguish all providers failing, a mix of failures and skipped candidates, and no provider being admitted. The exact error class and field names remain open. +This must distinguish all providers failing, a mix of failures and skipped candidates, and no provider being admitted. Entries contain `providerId`, `status` and either `cause` for a failed visit or `reason` for a skipped visit. Skip reasons are `'circuit-open'` and `'probe-busy'`. `RoutingError.errors` contains only the causes of failed visits. External cancellation, queue overflow and an operation error with `failover: false` reject directly rather than being wrapped as candidate exhaustion. A policy exception reports the operation and policy errors separately from exhaustion. @@ -224,7 +222,7 @@ Callbacks detached by an immediate timeout are not awaited again at the end of t An external deadline can bound the entire request across providers; an attempt timeout applies only to its provider attempt. -## Statistics and observability +## Statistics and observability (proposed) Keep observations associated with provider IDs, independently of array order. Distinguish provider health from router-level outcomes: a successful request through B does not turn A's failed visit into a success. @@ -244,12 +242,8 @@ The existing per-call fallback API is independent. ## Open decisions -- Exact resource and operation context types. - Whether custom policies need a public extension contract. -- Default error policy and the error shape preserving both operation and policy exceptions. -- Deterministic sticky and round-robin behavior under concurrent requests. -- Exact routing error shape for ordered failed and skipped visits. +- Deterministic round-robin behavior under concurrent requests. - Provider-specific capacity limits and their interaction with selection. - Statistics windows, exploration, confidence and stability rules for adaptive selection. - Runtime priority changes, provider replacement and preservation or reset of protection state. -- Inheritance rules for nested protection settings and explicitly disabling an inherited circuit. diff --git a/src/circuit.ts b/src/circuit.ts index 8c0b89e..9629944 100644 --- a/src/circuit.ts +++ b/src/circuit.ts @@ -20,7 +20,7 @@ export class Circuit { } if (this.state === 'half-open' && this.probing) { - throw new CircuitOpenError() + throw new CircuitOpenError('probe-busy') } } diff --git a/src/errors.ts b/src/errors.ts index a9aef53..bcce218 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,5 +1,7 @@ +import type { Visit } from './types.js' + export class CircuitOpenError extends Error { - constructor() { + constructor(readonly reason: 'circuit-open' | 'probe-busy' = 'circuit-open') { super('The circuit is open') this.name = 'CircuitOpenError' } @@ -19,6 +21,19 @@ export class QueueFullError extends Error { } } +export class RoutingError extends AggregateError { + readonly visits: readonly Visit[] + + constructor(visits: readonly Visit[]) { + super( + visits.flatMap((visit) => (visit.status === 'failed' ? [visit.cause] : [])), + 'No provider succeeded', + ) + this.name = 'RoutingError' + this.visits = visits.map((visit) => ({ ...visit })) + } +} + export class TimeoutError extends Error { constructor(readonly timeout: number) { super(`The attempt exceeded its ${timeout} millisecond timeout`) diff --git a/src/index.ts b/src/index.ts index bab1af6..0828e61 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,29 @@ -export { CircuitOpenError, ErrorPolicyError, QueueFullError, TimeoutError } from './errors.js' +export { + CircuitOpenError, + ErrorPolicyError, + QueueFullError, + RoutingError, + TimeoutError, +} from './errors.js' export { protect } from './protect.js' +export { createRouter } from './router.js' export type { BackoffOptions, CircuitOptions, ErrorDecision, ErrorPolicy, + ExecuteOptions, + ProtectionOptions, + Provider, ProtectOptions, + Router, + RouterErrorDecision, + RouterErrorPolicy, + RouterOptions, RunContext, Runner, RunOptions, + Selection, TimeoutMode, + Visit, } from './types.js' diff --git a/src/protect.ts b/src/protect.ts index 7fdb85f..7bb2477 100644 --- a/src/protect.ts +++ b/src/protect.ts @@ -1,6 +1,6 @@ -import { Circuit } from './circuit.js' import { CircuitOpenError, QueueFullError } from './errors.js' import { recover } from './fallback.js' +import { configureProtection } from './protection.js' import { execute } from './retry.js' import type { ProtectOptions, RunContext, Runner, RunOptions } from './types.js' @@ -10,19 +10,10 @@ interface PendingOperation { } export function protect(options: ProtectOptions = {}): Runner { - const { - attempts = 1, - timeout, - timeoutMode = 'immediate', - errorPolicy: defaultErrorPolicy, - } = options - const backoff = - options.backoff === undefined - ? undefined - : { ...options.backoff, jitter: options.backoff.jitter ?? 0 } + const { circuit, execution } = configureProtection(options) + const defaultErrorPolicy = options.errorPolicy const concurrency = options.concurrency ?? Infinity const maxPending = options.maxPending ?? 0 - const circuit = options.circuit === undefined ? undefined : new Circuit({ ...options.circuit }) const pending = new Set() let active = 0 @@ -125,10 +116,7 @@ export function protect(options: ProtectOptions = {}): Runner { } void execute(operation, controller.signal, { - attempts, - backoff, - timeout, - timeoutMode, + ...execution, errorPolicy, }).then( (outcome) => { diff --git a/src/protection.ts b/src/protection.ts new file mode 100644 index 0000000..1f4abac --- /dev/null +++ b/src/protection.ts @@ -0,0 +1,25 @@ +import { Circuit } from './circuit.js' +import type { ExecutionOptions } from './retry.js' +import type { ProtectionOptions } from './types.js' + +export function configureProtection( + options: ProtectionOptions = {}, + defaults: ProtectionOptions = {}, +): { + circuit: Circuit | undefined + execution: ExecutionOptions +} { + const circuit = options.circuit ?? defaults.circuit + const backoff = options.backoff ?? defaults.backoff + + return { + circuit: circuit ? new Circuit({ ...circuit }) : undefined, + execution: { + attempts: options.attempts ?? defaults.attempts ?? 1, + backoff: backoff === undefined ? undefined : { ...backoff, jitter: backoff.jitter ?? 0 }, + timeout: options.timeout ?? defaults.timeout, + timeoutMode: options.timeoutMode ?? defaults.timeoutMode ?? 'immediate', + errorPolicy: undefined, + }, + } +} diff --git a/src/retry.ts b/src/retry.ts index ae37412..ffd1df5 100644 --- a/src/retry.ts +++ b/src/retry.ts @@ -1,23 +1,27 @@ import { ErrorPolicyError, TimeoutError } from './errors.js' -import type { - BackoffOptions, - ErrorDecision, - ErrorPolicy, - RunContext, - TimeoutMode, -} from './types.js' - -interface ExecutionOptions { +import type { BackoffOptions, ErrorDecision, RunContext, TimeoutMode } from './types.js' + +interface ExecutionDecision extends ErrorDecision { + failover?: boolean +} + +export interface ExecutionOptions { attempts: number backoff: Required | undefined timeout: number | undefined timeoutMode: TimeoutMode - errorPolicy: ErrorPolicy | undefined + errorPolicy: ((error: unknown) => ExecutionDecision) | undefined } type ExecutionResult = | { ok: true; value: Awaited } - | { ok: false; error: unknown; countForCircuit: boolean; recoverable: boolean } + | { + ok: false + error: unknown + countForCircuit: boolean + recoverable: boolean + failover: boolean + } function wait(duration: number, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { @@ -105,7 +109,7 @@ export async function execute( } catch (error) { signal.throwIfAborted() - let decision: ErrorDecision + let decision: ExecutionDecision try { decision = errorPolicy?.(error) ?? { retry: true, countForCircuit: true } @@ -117,6 +121,7 @@ export async function execute( error: new ErrorPolicyError(error, policyError), countForCircuit: false, recoverable: false, + failover: false, } } @@ -128,6 +133,7 @@ export async function execute( error, countForCircuit: decision.countForCircuit, recoverable: true, + failover: decision.failover ?? true, } } diff --git a/src/router.test.ts b/src/router.test.ts new file mode 100644 index 0000000..dcad49c --- /dev/null +++ b/src/router.test.ts @@ -0,0 +1,573 @@ +import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' + +import { + createRouter, + ErrorPolicyError, + QueueFullError, + RoutingError, + TimeoutError, + type RouterErrorPolicy, + type RouterOptions, +} from './index.js' + +function deferred() { + let resolve!: () => void + const promise = new Promise((done) => { + resolve = done + }) + + return { promise, resolve } +} + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(0) +}) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + +const providers = [ + { id: 'a', resource: 'a' }, + { id: 'b', resource: 'b' }, + { id: 'c', resource: 'c' }, +] + +it('uses descending priority, exhausts each local budget and stops at the first success', async () => { + const router = createRouter({ + providers: [ + { id: 'a', resource: 'a' }, + { id: 'b', resource: 'b' }, + { id: 'c', resource: 'c', protection: { attempts: 1 } }, + ], + protection: { attempts: 2 }, + selection: { type: 'priority', priorities: { a: 30, c: 20, b: 10 } }, + }) + const visits: string[] = [] + const result = router.execute((resource) => { + visits.push(resource) + + if (resource === 'a') { + throw new Error('unavailable') + } + + return resource + }) + + expectTypeOf(result).toEqualTypeOf>() + await expect(result).resolves.toBe('c') + expect(visits).toEqual(['a', 'a', 'c']) +}) + +it('uses registration order by default and breaks equal priorities with that order', async () => { + const router = createRouter({ providers }) + const tied = createRouter({ + providers, + selection: { type: 'priority', priorities: { a: 1, b: 1 } }, + }) + + await expect(router.execute((resource) => resource)).resolves.toBe('a') + await expect(tied.execute((resource) => resource)).resolves.toBe('a') +}) + +it('inherits protection, replaces nested settings and can disable an inherited circuit', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5) + + const router = createRouter({ + providers: [ + { + id: 'a', + resource: 'a', + protection: { attempts: 2, backoff: { type: 'fixed', delay: 10 }, circuit: false }, + }, + { id: 'b', resource: 'b' }, + ], + protection: { + attempts: 3, + backoff: { type: 'fixed', delay: 100, jitter: 1 }, + circuit: { threshold: 1, cooldown: 100 }, + }, + }) + const visits: [string, number][] = [] + let failures = 0 + + function operation(resource: string): string { + visits.push([resource, Date.now()]) + + if (resource === 'a' || failures++ === 0) { + throw new Error('unavailable') + } + + return resource + } + + const first = router.execute(operation) + + await vi.advanceTimersByTimeAsync(60) + await expect(first).resolves.toBe('b') + + const second = router.execute(operation) + + await vi.advanceTimersByTimeAsync(10) + await expect(second).resolves.toBe('b') + expect(visits).toEqual([ + ['a', 0], + ['a', 10], + ['b', 10], + ['b', 60], + ['a', 60], + ['a', 70], + ['b', 70], + ]) +}) + +it('returns to the preferred provider after cooldown and admits only one recovery probe', async () => { + const router = createRouter({ + providers, + protection: { circuit: { threshold: 1, cooldown: 100 } }, + }) + const visits: string[] = [] + const error = new Error('unavailable') + + function operation(resource: string): string { + visits.push(resource) + + if (resource === 'a') { + throw error + } + + return resource + } + + await expect(router.execute(operation)).resolves.toBe('b') + await expect(router.execute(operation)).resolves.toBe('b') + expect(visits).toEqual(['a', 'b', 'b']) + await vi.advanceTimersByTimeAsync(100) + + const blocker = deferred() + const probe = router.execute(async (resource) => { + await blocker.promise + return resource + }) + + await expect(router.execute((resource) => resource)).resolves.toBe('b') + blocker.resolve() + await expect(probe).resolves.toBe('a') + await expect(router.execute((resource) => resource)).resolves.toBe('a') +}) + +it('keeps a sticky provider after cooldown and switches when that provider fails', async () => { + const router = createRouter({ + providers, + selection: { type: 'sticky' }, + protection: { circuit: { threshold: 1, cooldown: 100 } }, + }) + const available = new Set(['b', 'c']) + const visits: string[] = [] + + function operation(resource: string): string { + visits.push(resource) + + if (!available.has(resource)) { + throw new Error('unavailable') + } + + return resource + } + + await expect(router.execute(operation)).resolves.toBe('b') + await vi.advanceTimersByTimeAsync(100) + available.add('a') + await expect(router.execute(operation)).resolves.toBe('b') + available.delete('b') + await expect(router.execute(operation)).resolves.toBe('a') + await expect(router.execute(operation)).resolves.toBe('a') + expect(visits).toEqual(['a', 'b', 'b', 'b', 'a', 'a']) +}) + +it('does not let a late sticky transition overwrite the first successful transition', async () => { + const router = createRouter({ providers, selection: { type: 'sticky' } }) + const blocker = deferred() + const slow = router.execute(async (resource) => { + if (resource === 'a') { + throw new Error('unavailable') + } + + await blocker.promise + return resource + }) + + await vi.advanceTimersByTimeAsync(0) + await expect( + router.execute((resource) => { + if (resource !== 'c') { + throw new Error('unavailable') + } + + return resource + }), + ).resolves.toBe('c') + + blocker.resolve() + await expect(slow).resolves.toBe('b') + await expect(router.execute((resource) => resource)).resolves.toBe('c') +}) + +it('reports failed and skipped visits in order, distinguishing open circuits and occupied probes', async () => { + const router = createRouter({ + providers, + protection: { circuit: { threshold: 1, cooldown: 100 } }, + }) + const errors = new Map(providers.map(({ resource }) => [resource, new Error(resource)])) + + function fail(resource: string): never { + throw errors.get(resource) + } + + const failed = await router.execute(fail).catch((error: unknown) => error) + + expect(failed).toBeInstanceOf(RoutingError) + expect(failed).toMatchObject({ + errors: [...errors.values()], + visits: providers.map(({ id }) => ({ + providerId: id, + status: 'failed', + cause: errors.get(id), + })), + }) + await vi.advanceTimersByTimeAsync(100) + + const blocker = deferred() + const probe = router.execute(() => blocker.promise) + const mixed = await router.execute(fail).catch((error: unknown) => error) + + expect(mixed).toMatchObject({ + errors: [errors.get('b'), errors.get('c')], + visits: [ + { providerId: 'a', status: 'skipped', reason: 'probe-busy' }, + { providerId: 'b', status: 'failed', cause: errors.get('b') }, + { providerId: 'c', status: 'failed', cause: errors.get('c') }, + ], + }) + + const skipped = await router.execute(fail).catch((error: unknown) => error) + + expect(skipped).toMatchObject({ + errors: [], + visits: [ + { providerId: 'a', status: 'skipped', reason: 'probe-busy' }, + { providerId: 'b', status: 'skipped', reason: 'circuit-open' }, + { providerId: 'c', status: 'skipped', reason: 'circuit-open' }, + ], + }) + blocker.resolve() + await probe +}) + +it('replaces the default error policy per call and stops directly when failover is forbidden', async () => { + const policy = vi.fn(() => ({ + retry: true, + failover: true, + countForCircuit: true, + })) + const router = createRouter({ providers, protection: { attempts: 3 }, errorPolicy: policy }) + const temporary = new Error('temporary') + const terminal = new Error('invalid input') + const override = vi.fn((error) => ({ + retry: error === temporary, + failover: error === temporary, + countForCircuit: error === temporary, + })) + const resources: string[] = [] + const operation = vi.fn<(resource: string) => Promise>((resource) => { + resources.push(resource) + return Promise.reject(resources.length === 1 ? temporary : terminal) + }) + + await expect(router.execute(operation, { errorPolicy: override })).rejects.toBe(terminal) + expect(resources).toEqual(['a', 'a']) + expect(override.mock.calls).toEqual([[temporary], [terminal]]) + expect(policy).not.toHaveBeenCalled() +}) + +it('preserves failures across neutral outcomes and immediately releases a neutral half-open probe', async () => { + const error = new Error('provider failed') + const neutral = new Error('invalid input') + const router = createRouter({ + providers: [{ id: 'a', resource: 'a' }], + protection: { circuit: { threshold: 2, cooldown: 100 } }, + errorPolicy: (failure) => ({ + retry: false, + failover: false, + countForCircuit: failure === error, + }), + }) + + function fail(): never { + throw error + } + + function excluded(): never { + throw neutral + } + + await expect(router.execute(fail)).rejects.toBe(error) + await expect(router.execute(excluded)).rejects.toBe(neutral) + await expect(router.execute(fail)).rejects.toBe(error) + await expect(router.execute(() => 'skipped')).rejects.toBeInstanceOf(RoutingError) + await vi.advanceTimersByTimeAsync(100) + await expect(router.execute(excluded)).rejects.toBe(neutral) + + const blocker = deferred() + const probe = router.execute(() => blocker.promise) + + await expect(router.execute(() => 'skipped')).rejects.toMatchObject({ + visits: [{ providerId: 'a', status: 'skipped', reason: 'probe-busy' }], + }) + blocker.resolve() + await probe + await expect(router.execute(() => 'saved')).resolves.toBe('saved') +}) + +it('preserves policy exceptions, does not fail over and releases the probe and concurrency slot', async () => { + const router = createRouter({ + providers, + concurrency: 2, + protection: { attempts: 3, circuit: { threshold: 1, cooldown: 100 } }, + }) + const error = new Error('provider failed') + const policyError = new Error('policy failed') + + await expect( + router.execute(() => { + throw error + }), + ).rejects.toBeInstanceOf(RoutingError) + await vi.advanceTimersByTimeAsync(100) + + const operation = vi.fn<() => never>(() => { + throw error + }) + const failure = await router + .execute(operation, { + errorPolicy: () => { + throw policyError + }, + }) + .catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(ErrorPolicyError) + expect(failure).toMatchObject({ errors: [error, policyError], cause: error }) + expect(operation).toHaveBeenCalledTimes(1) + + const blocker = deferred() + const probe = router.execute(() => blocker.promise) + const peer = await router + .execute(() => { + throw error + }) + .catch((error: unknown) => error) + + expect(peer).toMatchObject({ + visits: [ + { providerId: 'a', status: 'skipped', reason: 'probe-busy' }, + { providerId: 'b', status: 'failed', cause: error }, + { providerId: 'c', status: 'failed', cause: error }, + ], + }) + blocker.resolve() + await probe + await expect(router.execute((resource) => resource)).resolves.toBe('a') +}) + +it('cancels queued and active requests without releasing an active slot before its callback settles', async () => { + const policy = vi.fn(() => ({ + retry: true, + failover: true, + countForCircuit: true, + })) + const router = createRouter({ providers, concurrency: 1, maxPending: 1, errorPolicy: policy }) + const active = new AbortController() + const queued = new AbortController() + const reason = new Error('cancelled') + const blocker = deferred() + let received: AbortSignal | undefined + const skipped = vi.fn<() => string>(() => 'skipped') + + await expect(router.execute(skipped, { signal: AbortSignal.abort(reason) })).rejects.toBe(reason) + + const running = router + .execute( + (_resource, { signal }) => { + received = signal + return blocker.promise + }, + { signal: active.signal }, + ) + .catch((error: unknown) => error) + const waiting = router + .execute(skipped, { signal: queued.signal }) + .catch((error: unknown) => error) + + await expect(router.execute(skipped)).rejects.toBeInstanceOf(QueueFullError) + queued.abort(reason) + await expect(waiting).resolves.toBe(reason) + + const replacement = vi.fn<(resource: string) => string>((resource) => resource) + const next = router.execute(replacement) + + active.abort(reason) + await expect(running).resolves.toBe(reason) + expect(received?.reason).toBe(reason) + expect(replacement).not.toHaveBeenCalled() + + blocker.resolve() + await expect(next).resolves.toBe('a') + expect(skipped).not.toHaveBeenCalled() + expect(policy).not.toHaveBeenCalled() +}) + +it('cancels backoff and prevents further retries or visits', async () => { + const router = createRouter({ + providers, + concurrency: 1, + maxPending: 1, + protection: { attempts: 3, backoff: { type: 'fixed', delay: 1_000 } }, + }) + const controller = new AbortController() + const reason = new Error('cancelled') + const operation = vi.fn<() => never>(() => { + throw new Error('unavailable') + }) + const cancelled = router + .execute(operation, { signal: controller.signal }) + .catch((error: unknown) => error) + const next = router.execute((resource) => resource) + + await vi.advanceTimersByTimeAsync(0) + controller.abort(reason) + await expect(cancelled).resolves.toBe(reason) + await expect(next).resolves.toBe('a') + await vi.runAllTimersAsync() + expect(operation).toHaveBeenCalledTimes(1) +}) + +it('holds one slot across timeout, backoff and failover, ignoring detached callback outcomes', async () => { + const router = createRouter({ + providers: [ + { id: 'a', resource: 'a' }, + { id: 'b', resource: 'b', protection: { attempts: 1, timeout: 50 } }, + ], + concurrency: 1, + maxPending: 1, + protection: { + attempts: 2, + timeout: 100, + backoff: { type: 'fixed', delay: 200 }, + circuit: { threshold: 1, cooldown: 100 }, + }, + }) + const blocker = deferred() + const visits: string[] = [] + let received: AbortSignal | undefined + const running = router.execute(async (resource, { signal }) => { + visits.push(resource) + + if (resource === 'a') { + if (visits.length === 1) { + received = signal + await blocker.promise + } + + throw new Error('late or unavailable') + } + + await new Promise((resolve) => setTimeout(resolve, 40)) + return resource + }) + const next = vi.fn<(resource: string) => string>((resource) => resource) + const waiting = router.execute(next) + + await vi.advanceTimersByTimeAsync(299) + expect(visits).toEqual(['a']) + expect(received?.reason).toBeInstanceOf(TimeoutError) + expect(next).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(visits).toEqual(['a', 'a', 'b']) + expect(next).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(40) + await expect(running).resolves.toBe('b') + await expect(waiting).resolves.toBe('b') + + blocker.resolve() + await vi.advanceTimersByTimeAsync(59) + await expect(router.execute((resource) => resource)).resolves.toBe('b') + await vi.advanceTimersByTimeAsync(1) + await expect(router.execute((resource) => resource)).resolves.toBe('a') +}) + +it('waits for a timed-out callback before failing over and releasing its slot', async () => { + const router = createRouter({ + providers, + concurrency: 1, + maxPending: 1, + protection: { timeout: 100, timeoutMode: 'wait', circuit: { threshold: 1, cooldown: 100 } }, + }) + const blocker = deferred() + const visits: string[] = [] + let received: AbortSignal | undefined + const running = router.execute((resource, { signal }) => { + visits.push(resource) + + if (resource === 'a') { + received = signal + return blocker.promise.then(() => resource) + } + + return resource + }) + const next = vi.fn<(resource: string) => string>((resource) => resource) + const waiting = router.execute(next) + + await vi.advanceTimersByTimeAsync(100) + expect(received?.reason).toBeInstanceOf(TimeoutError) + expect(visits).toEqual(['a']) + expect(next).not.toHaveBeenCalled() + + blocker.resolve() + await expect(running).resolves.toBe('b') + await expect(waiting).resolves.toBe('b') + expect(visits).toEqual(['a', 'b']) +}) + +describe('configuration', () => { + it.each<{ name: string; options: Partial> }>([ + { name: 'empty providers', options: { providers: [] } }, + { + name: 'duplicate IDs', + options: { + providers: [ + { id: 'a', resource: 'a' }, + { id: 'a', resource: 'b' }, + ], + }, + }, + { name: 'empty IDs', options: { providers: [{ id: '', resource: 'a' }] } }, + { + name: 'unknown priority IDs', + options: { selection: { type: 'priority', priorities: { unknown: 1 } } }, + }, + { + name: 'non-finite priorities', + options: { selection: { type: 'priority', priorities: { a: Infinity } } }, + }, + { name: 'zero concurrency', options: { concurrency: 0 } }, + { name: 'negative queue limits', options: { maxPending: -1 } }, + ])('rejects $name', ({ options }) => { + expect(() => createRouter({ providers, ...options })).toThrow( + /provider|priorities|concurrency|maxPending/i, + ) + }) +}) diff --git a/src/router.ts b/src/router.ts new file mode 100644 index 0000000..17032cd --- /dev/null +++ b/src/router.ts @@ -0,0 +1,138 @@ +import { CircuitOpenError, RoutingError } from './errors.js' +import { configureProtection } from './protection.js' +import { execute } from './retry.js' +import { Scheduler } from './scheduler.js' +import type { ExecuteOptions, Router, RouterOptions, RunContext, Visit } from './types.js' + +export function createRouter( + options: RouterOptions, +): Router { + const selection = options.selection ?? { type: 'priority' } + const ids = new Set() + const providers = options.providers.map((provider) => { + if (provider.id.length === 0 || ids.has(provider.id)) { + throw new TypeError('Provider IDs must be non-empty and unique') + } + + ids.add(provider.id) + + return { + id: provider.id, + resource: provider.resource, + ...configureProtection(provider.protection, options.protection), + priority: selection.type === 'priority' ? (selection.priorities?.[provider.id] ?? 0) : 0, + } + }) + const first = providers[0] + + if (first === undefined) { + throw new TypeError('At least one provider is required') + } + + if (selection.type === 'priority') { + for (const [id, priority] of Object.entries(selection.priorities ?? {})) { + if (!ids.has(id) || !Number.isFinite(priority)) { + throw new TypeError('Priorities must reference registered providers and be finite') + } + } + + providers.sort((left, right) => right.priority - left.priority) + } else if (selection.type !== 'sticky') { + throw new TypeError('Unknown selection policy') + } + + const sticky = selection.type === 'sticky' + const defaultErrorPolicy = options.errorPolicy + const scheduler = new Scheduler(options.concurrency ?? Infinity, options.maxPending ?? 0) + let current = first + let generation = 0 + + function run( + operation: (resource: Resource, context: RunContext) => Result, + executeOptions: ExecuteOptions = {}, + ): Promise> { + const errorPolicy = executeOptions.errorPolicy ?? defaultErrorPolicy + + return scheduler.run(async (signal): Promise> => { + const started = generation + const candidates = sticky + ? [current, ...providers.filter((provider) => provider !== current)] + : providers + const visits: Visit[] = [] + + for (const provider of candidates) { + signal.throwIfAborted() + + let admission: { generation: number; probe: boolean } | undefined + + try { + admission = provider.circuit?.enter() + } catch (error) { + if (!(error instanceof CircuitOpenError)) { + throw error + } + + visits.push({ providerId: provider.id, status: 'skipped', reason: error.reason }) + continue + } + + function cancel(): void { + if (admission !== undefined) { + provider.circuit?.cancel(admission.generation) + } + } + + signal.addEventListener('abort', cancel, { once: true }) + + try { + const outcome = await execute( + (context) => operation(provider.resource, context), + signal, + { + ...provider.execution, + errorPolicy, + }, + ) + + signal.throwIfAborted() + + if (outcome.ok) { + if (admission !== undefined) { + provider.circuit?.succeed(admission.generation) + } + + if (sticky && started === generation && current !== provider) { + current = provider + generation += 1 + } + + return outcome.value + } + + if (admission !== undefined) { + if (outcome.countForCircuit) { + provider.circuit?.fail(admission.generation) + } else { + cancel() + } + } + + if (!outcome.recoverable || !outcome.failover) { + throw outcome.error + } + + visits.push({ providerId: provider.id, status: 'failed', cause: outcome.error }) + } catch (error) { + cancel() + throw error + } finally { + signal.removeEventListener('abort', cancel) + } + } + + throw new RoutingError(visits) + }, executeOptions.signal) + } + + return { execute: run } +} diff --git a/src/scheduler.ts b/src/scheduler.ts new file mode 100644 index 0000000..feab830 --- /dev/null +++ b/src/scheduler.ts @@ -0,0 +1,102 @@ +import { QueueFullError } from './errors.js' + +interface PendingOperation { + start: () => void +} + +export class Scheduler { + private active = 0 + private readonly pending = new Set() + + constructor( + private readonly concurrency: number, + private readonly maxPending: number, + ) { + if (concurrency !== Infinity && (!Number.isInteger(concurrency) || concurrency < 1)) { + throw new RangeError('concurrency must be a positive integer or Infinity') + } + + if (!Number.isInteger(maxPending) || maxPending < 0) { + throw new RangeError('maxPending must be a non-negative integer') + } + } + + run( + operation: (signal: AbortSignal) => Promise, + signal: AbortSignal = new AbortController().signal, + ): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(signal.reason) + return + } + + function cancelActive(): void { + reject(signal.reason) + } + + const cancelPending = (): void => { + this.pending.delete(entry) + signal.removeEventListener('abort', cancelPending) + reject(signal.reason) + } + + const start = (): void => { + signal.removeEventListener('abort', cancelPending) + + if (signal.aborted) { + reject(signal.reason) + return + } + + this.active += 1 + signal.addEventListener('abort', cancelActive, { once: true }) + + const finish = (): void => { + signal.removeEventListener('abort', cancelActive) + this.active -= 1 + this.drain() + } + + try { + void operation(signal).then( + (value) => { + resolve(value) + finish() + }, + (error: unknown) => { + reject(error) + finish() + }, + ) + } catch (error) { + reject(error) + finish() + } + } + const entry = { start } + + if (this.active < this.concurrency && this.pending.size === 0) { + start() + } else if (this.pending.size >= this.maxPending) { + reject(new QueueFullError()) + } else { + this.pending.add(entry) + signal.addEventListener('abort', cancelPending, { once: true }) + } + }) + } + + private drain(): void { + while (this.active < this.concurrency) { + const next = this.pending.values().next().value + + if (next === undefined) { + return + } + + this.pending.delete(next) + next.start() + } + } +} diff --git a/src/types.ts b/src/types.ts index 231a887..8b23186 100644 --- a/src/types.ts +++ b/src/types.ts @@ -18,14 +18,17 @@ export interface ErrorDecision { export type ErrorPolicy = (error: unknown) => ErrorDecision -export interface ProtectOptions { +export interface ProtectionOptions { attempts?: number backoff?: BackoffOptions timeout?: number timeoutMode?: TimeoutMode + circuit?: CircuitOptions | false +} + +export interface ProtectOptions extends ProtectionOptions { concurrency?: number maxPending?: number - circuit?: CircuitOptions errorPolicy?: ErrorPolicy } @@ -39,6 +42,47 @@ export interface RunOptions { fallback?: (error: unknown, context: RunContext) => Result } +export interface RouterErrorDecision extends ErrorDecision { + failover: boolean +} + +export type RouterErrorPolicy = (error: unknown) => RouterErrorDecision + +export interface Provider { + id: Id + resource: Resource + protection?: ProtectionOptions +} + +export type Selection = + | { type: 'priority'; priorities?: Readonly>> } + | { type: 'sticky' } + +export interface RouterOptions { + providers: readonly Provider[] + protection?: ProtectionOptions + selection?: Selection> + errorPolicy?: RouterErrorPolicy + concurrency?: number + maxPending?: number +} + +export interface ExecuteOptions { + signal?: AbortSignal + errorPolicy?: RouterErrorPolicy +} + +export interface Router { + execute( + operation: (resource: Resource, context: RunContext) => Result, + options?: ExecuteOptions, + ): Promise> +} + +export type Visit = + | { providerId: string; status: 'failed'; cause: unknown } + | { providerId: string; status: 'skipped'; reason: 'circuit-open' | 'probe-busy' } + export interface Runner { ( operation: (context: RunContext) => Result, From f7f3547b8a51e769ee11d0f210c399e78c7337a7 Mon Sep 17 00:00:00 2001 From: unsady <38117114+unsady@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:58:16 +0200 Subject: [PATCH 4/4] refactor: centralize protection and scheduling lifecycle --- src/protect.test.ts | 35 +++++++++ src/protect.ts | 180 ++++++++------------------------------------ src/protection.ts | 109 ++++++++++++++++++++++----- src/retry.ts | 2 +- src/router.test.ts | 19 +++++ src/router.ts | 109 ++++++++++----------------- src/scheduler.ts | 22 +++--- src/selection.ts | 26 +++++++ 8 files changed, 257 insertions(+), 245 deletions(-) create mode 100644 src/selection.ts diff --git a/src/protect.test.ts b/src/protect.test.ts index ff41712..30dcc75 100644 --- a/src/protect.test.ts +++ b/src/protect.test.ts @@ -119,6 +119,19 @@ it('rejects excess calls without queuing by default', async () => { expect(policy).not.toHaveBeenCalled() }) +it('supports an unbounded pending limit', async () => { + const run = protect({ concurrency: 1, maxPending: Infinity }) + const blocker = deferred() + const active = run(() => blocker.promise) + const first = run(() => 'first') + const second = run(() => 'second') + + blocker.resolve() + await active + await expect(first).resolves.toBe('first') + await expect(second).resolves.toBe('second') +}) + it('shares concurrency and pending limits across operations and releases a slot after failure', async () => { const run = protect({ concurrency: 2, maxPending: 1 }) const first = deferred() @@ -722,6 +735,28 @@ describe('circuit', () => { expect(skipped).not.toHaveBeenCalled() }) + it('keeps operation-thrown admission errors neutral without resetting prior failures', async () => { + const run = protect({ circuit: { threshold: 2, cooldown: 100 } }) + const error = new Error('unavailable') + + function fail(): never { + throw error + } + + await expect(run(fail)).rejects.toBe(error) + + for (const admissionError of [new QueueFullError(), new CircuitOpenError()]) { + await expect( + run(() => { + throw admissionError + }), + ).rejects.toBe(admissionError) + } + + await expect(run(fail)).rejects.toBe(error) + await expect(run(() => 'skipped')).rejects.toBeInstanceOf(CircuitOpenError) + }) + it('counts only the final timeout and ignores detached callbacks', async () => { const run = protect({ attempts: 2, diff --git a/src/protect.ts b/src/protect.ts index 7bb2477..a4545d1 100644 --- a/src/protect.ts +++ b/src/protect.ts @@ -1,42 +1,15 @@ import { CircuitOpenError, QueueFullError } from './errors.js' import { recover } from './fallback.js' -import { configureProtection } from './protection.js' -import { execute } from './retry.js' -import type { ProtectOptions, RunContext, Runner, RunOptions } from './types.js' - -interface PendingOperation { - start: () => void - reject: (error: unknown) => void -} +import { Protection } from './protection.js' +import { Scheduler } from './scheduler.js' +import type { ErrorPolicy, ProtectOptions, RunContext, Runner, RunOptions } from './types.js' export function protect(options: ProtectOptions = {}): Runner { - const { circuit, execution } = configureProtection(options) + const scheduler = new Scheduler(options.concurrency ?? Infinity, options.maxPending ?? 0) + const protection = new Protection(options, {}, () => { + scheduler.rejectPending(new CircuitOpenError()) + }) const defaultErrorPolicy = options.errorPolicy - const concurrency = options.concurrency ?? Infinity - const maxPending = options.maxPending ?? 0 - const pending = new Set() - let active = 0 - - function rejectQueue(): void { - const error = new CircuitOpenError() - - for (const entry of pending) { - entry.reject(error) - } - } - - function drain(): void { - while (active < concurrency) { - const next = pending.values().next().value - - if (next === undefined) { - return - } - - pending.delete(next) - next.start() - } - } function run( operation: (context: RunContext) => Result, @@ -45,129 +18,40 @@ export function protect(options: ProtectOptions = {}): Runner { const { signal, fallback } = runOptions const errorPolicy = runOptions.errorPolicy ?? defaultErrorPolicy let recoverable = true + let result: Promise> - const result = new Promise>((resolve, reject) => { - if (signal?.aborted) { - reject(signal.reason) - return - } - - try { - circuit?.check() - } catch (error) { - reject(error) - return - } - - function rejectPending(error: unknown): void { - pending.delete(entry) - signal?.removeEventListener('abort', cancelPending) - reject(error) - } + const policy: ErrorPolicy = (error) => { + const decision = errorPolicy?.(error) ?? { retry: true, countForCircuit: true } - function cancelPending(): void { - rejectPending(signal?.reason) + return { + ...decision, + countForCircuit: + decision.countForCircuit && + !(error instanceof QueueFullError) && + !(error instanceof CircuitOpenError), } + } - function start(): void { - signal?.removeEventListener('abort', cancelPending) - - let admission: { generation: number; probe: boolean } | undefined - - try { - admission = circuit?.enter() - } catch (error) { - rejectPending(error) - return - } - - active += 1 - - if (admission?.probe) { - rejectQueue() - } - - const controller = new AbortController() - - function forwardAbort(): void { - controller.abort(signal?.reason) - } - - function onAbort(): void { - if (admission !== undefined) { - circuit?.cancel(admission.generation) - } + try { + signal?.throwIfAborted() + protection.check() + result = scheduler.run(async (signal): Promise> => { + const outcome = await protection.execute(operation, signal, policy) - reject(controller.signal.reason) + if ('skipped' in outcome) { + throw outcome.skipped } - function finish(): void { - signal?.removeEventListener('abort', forwardAbort) - controller.signal.removeEventListener('abort', onAbort) - active -= 1 - drain() - } - - controller.signal.addEventListener('abort', onAbort, { once: true }) - signal?.addEventListener('abort', forwardAbort, { once: true }) - - if (signal?.aborted) { - forwardAbort() + if (!outcome.ok) { + recoverable = outcome.recoverable + throw outcome.error } - void execute(operation, controller.signal, { - ...execution, - errorPolicy, - }).then( - (outcome) => { - if (outcome.ok) { - if (admission !== undefined && !controller.signal.aborted) { - circuit?.succeed(admission.generation) - } - - resolve(outcome.value) - } else { - if (admission !== undefined) { - if ( - controller.signal.aborted || - !outcome.countForCircuit || - outcome.error instanceof QueueFullError || - outcome.error instanceof CircuitOpenError - ) { - circuit?.cancel(admission.generation) - } else if (circuit?.fail(admission.generation)) { - rejectQueue() - } - } - - recoverable = outcome.recoverable - reject(outcome.error) - } - - finish() - }, - (error: unknown) => { - if (admission !== undefined) { - circuit?.cancel(admission.generation) - } - - reject(error) - finish() - }, - ) - } - - const entry: PendingOperation = { start, reject: rejectPending } - - if (active < concurrency && pending.size === 0) { - start() - } else if (pending.size >= maxPending) { - reject(new QueueFullError()) - } else { - pending.add(entry) - signal?.addEventListener('abort', cancelPending, { once: true }) - } - }) + return outcome.value + }, signal) + } catch (error) { + result = Promise.reject(error) + } if (fallback === undefined) { return result diff --git a/src/protection.ts b/src/protection.ts index 1f4abac..607c3cf 100644 --- a/src/protection.ts +++ b/src/protection.ts @@ -1,25 +1,96 @@ import { Circuit } from './circuit.js' -import type { ExecutionOptions } from './retry.js' -import type { ProtectionOptions } from './types.js' - -export function configureProtection( - options: ProtectionOptions = {}, - defaults: ProtectionOptions = {}, -): { - circuit: Circuit | undefined - execution: ExecutionOptions -} { - const circuit = options.circuit ?? defaults.circuit +import { CircuitOpenError } from './errors.js' +import { execute as executeAttempts, type ExecutionOptions, type ExecutionResult } from './retry.js' +import type { ProtectionOptions, RunContext } from './types.js' + +function configureExecution( + options: ProtectionOptions, + defaults: ProtectionOptions, +): ExecutionOptions { const backoff = options.backoff ?? defaults.backoff return { - circuit: circuit ? new Circuit({ ...circuit }) : undefined, - execution: { - attempts: options.attempts ?? defaults.attempts ?? 1, - backoff: backoff === undefined ? undefined : { ...backoff, jitter: backoff.jitter ?? 0 }, - timeout: options.timeout ?? defaults.timeout, - timeoutMode: options.timeoutMode ?? defaults.timeoutMode ?? 'immediate', - errorPolicy: undefined, - }, + attempts: options.attempts ?? defaults.attempts ?? 1, + backoff: backoff === undefined ? undefined : { ...backoff, jitter: backoff.jitter ?? 0 }, + timeout: options.timeout ?? defaults.timeout, + timeoutMode: options.timeoutMode ?? defaults.timeoutMode ?? 'immediate', + errorPolicy: undefined, + } +} + +type ProtectedResult = ExecutionResult | { skipped: CircuitOpenError } + +export class Protection { + private readonly circuit: Circuit | undefined + private readonly execution: ExecutionOptions + + constructor( + options: ProtectionOptions = {}, + defaults: ProtectionOptions = {}, + private readonly onUnavailable?: () => void, + ) { + const circuit = options.circuit ?? defaults.circuit + + this.circuit = circuit ? new Circuit({ ...circuit }) : undefined + this.execution = configureExecution(options, defaults) + } + + check(): void { + this.circuit?.check() + } + + async execute( + operation: (context: RunContext) => Result, + signal: AbortSignal, + errorPolicy: ExecutionOptions['errorPolicy'], + ): Promise> { + signal.throwIfAborted() + + let admission: ReturnType | undefined + + try { + admission = this.circuit?.enter() + } catch (error) { + if (!(error instanceof CircuitOpenError)) { + throw error + } + + return { skipped: error } + } + + const cancel = (): void => { + if (admission !== undefined) { + this.circuit?.cancel(admission.generation) + } + } + + signal.addEventListener('abort', cancel, { once: true }) + + try { + if (admission?.probe) { + this.onUnavailable?.() + } + + const outcome = await executeAttempts(operation, signal, { ...this.execution, errorPolicy }) + + signal.throwIfAborted() + + if (admission !== undefined) { + if (outcome.ok) { + this.circuit?.succeed(admission.generation) + } else if (!outcome.countForCircuit) { + cancel() + } else if (this.circuit?.fail(admission.generation)) { + this.onUnavailable?.() + } + } + + return outcome + } catch (error) { + cancel() + throw error + } finally { + signal.removeEventListener('abort', cancel) + } } } diff --git a/src/retry.ts b/src/retry.ts index ffd1df5..e60d286 100644 --- a/src/retry.ts +++ b/src/retry.ts @@ -13,7 +13,7 @@ export interface ExecutionOptions { errorPolicy: ((error: unknown) => ExecutionDecision) | undefined } -type ExecutionResult = +export type ExecutionResult = | { ok: true; value: Awaited } | { ok: false diff --git a/src/router.test.ts b/src/router.test.ts index dcad49c..6ee0e30 100644 --- a/src/router.test.ts +++ b/src/router.test.ts @@ -266,6 +266,25 @@ it('reports failed and skipped visits in order, distinguishing open circuits and await probe }) +it('distinguishes resource-thrown admission errors from router admission rejections', async () => { + const router = createRouter({ + providers: [{ id: 'a', resource: 'a' }], + protection: { circuit: { threshold: 1, cooldown: 100 } }, + }) + const error = new QueueFullError() + + await expect( + router.execute(() => { + throw error + }), + ).rejects.toMatchObject({ + visits: [{ providerId: 'a', status: 'failed', cause: error }], + }) + await expect(router.execute(() => 'skipped')).rejects.toMatchObject({ + visits: [{ providerId: 'a', status: 'skipped', reason: 'circuit-open' }], + }) +}) + it('replaces the default error policy per call and stops directly when failover is forbidden', async () => { const policy = vi.fn(() => ({ retry: true, diff --git a/src/router.ts b/src/router.ts index 17032cd..e4efe43 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,7 +1,7 @@ -import { CircuitOpenError, RoutingError } from './errors.js' -import { configureProtection } from './protection.js' -import { execute } from './retry.js' +import { RoutingError } from './errors.js' +import { Protection } from './protection.js' import { Scheduler } from './scheduler.js' +import { StickySelection } from './selection.js' import type { ExecuteOptions, Router, RouterOptions, RunContext, Visit } from './types.js' export function createRouter( @@ -19,7 +19,7 @@ export function createRouter( return { id: provider.id, resource: provider.resource, - ...configureProtection(provider.protection, options.protection), + protection: new Protection(provider.protection, options.protection), priority: selection.type === 'priority' ? (selection.priorities?.[provider.id] ?? 0) : 0, } }) @@ -41,11 +41,21 @@ export function createRouter( throw new TypeError('Unknown selection policy') } - const sticky = selection.type === 'sticky' + const concurrency = options.concurrency ?? Infinity + const maxPending = options.maxPending ?? 0 + + if (concurrency !== Infinity && (!Number.isInteger(concurrency) || concurrency < 1)) { + throw new RangeError('concurrency must be a positive integer or Infinity') + } + + if (!Number.isInteger(maxPending) || maxPending < 0) { + throw new RangeError('maxPending must be a non-negative integer') + } + + const sticky = + selection.type === 'sticky' ? new StickySelection(first, providers.slice(1)) : undefined const defaultErrorPolicy = options.errorPolicy - const scheduler = new Scheduler(options.concurrency ?? Infinity, options.maxPending ?? 0) - let current = first - let generation = 0 + const scheduler = new Scheduler(concurrency, maxPending) function run( operation: (resource: Resource, context: RunContext) => Result, @@ -54,80 +64,43 @@ export function createRouter( const errorPolicy = executeOptions.errorPolicy ?? defaultErrorPolicy return scheduler.run(async (signal): Promise> => { - const started = generation - const candidates = sticky - ? [current, ...providers.filter((provider) => provider !== current)] - : providers + const choice = sticky?.begin() + const candidates = choice?.candidates ?? providers const visits: Visit[] = [] for (const provider of candidates) { signal.throwIfAborted() - let admission: { generation: number; probe: boolean } | undefined + const outcome = await provider.protection.execute( + (context) => operation(provider.resource, context), + signal, + errorPolicy, + ) - try { - admission = provider.circuit?.enter() - } catch (error) { - if (!(error instanceof CircuitOpenError)) { - throw error - } + signal.throwIfAborted() - visits.push({ providerId: provider.id, status: 'skipped', reason: error.reason }) + if ('skipped' in outcome) { + visits.push({ + providerId: provider.id, + status: 'skipped', + reason: outcome.skipped.reason, + }) continue } - function cancel(): void { - if (admission !== undefined) { - provider.circuit?.cancel(admission.generation) - } - } - - signal.addEventListener('abort', cancel, { once: true }) - - try { - const outcome = await execute( - (context) => operation(provider.resource, context), - signal, - { - ...provider.execution, - errorPolicy, - }, - ) - - signal.throwIfAborted() - - if (outcome.ok) { - if (admission !== undefined) { - provider.circuit?.succeed(admission.generation) - } - - if (sticky && started === generation && current !== provider) { - current = provider - generation += 1 - } - - return outcome.value + if (outcome.ok) { + if (choice !== undefined) { + sticky?.succeed(choice.generation, provider) } - if (admission !== undefined) { - if (outcome.countForCircuit) { - provider.circuit?.fail(admission.generation) - } else { - cancel() - } - } - - if (!outcome.recoverable || !outcome.failover) { - throw outcome.error - } + return outcome.value + } - visits.push({ providerId: provider.id, status: 'failed', cause: outcome.error }) - } catch (error) { - cancel() - throw error - } finally { - signal.removeEventListener('abort', cancel) + if (!outcome.recoverable || !outcome.failover) { + throw outcome.error } + + visits.push({ providerId: provider.id, status: 'failed', cause: outcome.error }) } throw new RoutingError(visits) diff --git a/src/scheduler.ts b/src/scheduler.ts index feab830..d8f8531 100644 --- a/src/scheduler.ts +++ b/src/scheduler.ts @@ -2,6 +2,7 @@ import { QueueFullError } from './errors.js' interface PendingOperation { start: () => void + reject: (error: unknown) => void } export class Scheduler { @@ -11,13 +12,11 @@ export class Scheduler { constructor( private readonly concurrency: number, private readonly maxPending: number, - ) { - if (concurrency !== Infinity && (!Number.isInteger(concurrency) || concurrency < 1)) { - throw new RangeError('concurrency must be a positive integer or Infinity') - } + ) {} - if (!Number.isInteger(maxPending) || maxPending < 0) { - throw new RangeError('maxPending must be a non-negative integer') + rejectPending(error: unknown): void { + for (const entry of this.pending) { + entry.reject(error) } } @@ -35,10 +34,14 @@ export class Scheduler { reject(signal.reason) } - const cancelPending = (): void => { + const rejectPending = (error: unknown): void => { this.pending.delete(entry) signal.removeEventListener('abort', cancelPending) - reject(signal.reason) + reject(error) + } + + function cancelPending(): void { + rejectPending(signal.reason) } const start = (): void => { @@ -74,7 +77,8 @@ export class Scheduler { finish() } } - const entry = { start } + + const entry = { start, reject: rejectPending } if (this.active < this.concurrency && this.pending.size === 0) { start() diff --git a/src/selection.ts b/src/selection.ts new file mode 100644 index 0000000..530adce --- /dev/null +++ b/src/selection.ts @@ -0,0 +1,26 @@ +export class StickySelection { + private readonly providers: readonly Provider[] + private current: Provider + private generation = 0 + + constructor(first: Provider, others: readonly Provider[]) { + this.providers = [first, ...others] + this.current = first + } + + begin(): { generation: number; candidates: readonly Provider[] } { + return { + generation: this.generation, + candidates: [this.current, ...this.providers.filter((provider) => provider !== this.current)], + } + } + + succeed(generation: number, provider: Provider): void { + if (generation !== this.generation || provider === this.current) { + return + } + + this.current = provider + this.generation += 1 + } +}