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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ 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.
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. Both `protect()` and router require concurrency to be a positive integer or `Infinity`, and `maxPending` a non-negative integer or `Infinity`.

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.

Expand Down Expand Up @@ -132,7 +132,7 @@ To limit the entire call, including queue wait and backoff, pass an external sig

`concurrency` defaults to `Infinity`: no limit. A slot stays occupied for the entire operation, including retries and backoff. The limit counts active calls, not callbacks left running after an immediate timeout.

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

## Circuit breaker

Expand Down
8 changes: 8 additions & 0 deletions src/protect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ afterEach(() => {
vi.restoreAllMocks()
})

it.each([0, -1, 1.5, NaN, -Infinity])('rejects invalid concurrency %s', (concurrency) => {
expect(() => protect({ concurrency })).toThrow(RangeError)
})

it.each([-1, 1.5, NaN, -Infinity])('rejects invalid pending limit %s', (maxPending) => {
expect(() => protect({ maxPending })).toThrow(RangeError)
})

it('returns operation results and does not retry by default', async () => {
const run = protect()
const error = new Error('failed')
Expand Down
27 changes: 27 additions & 0 deletions src/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,21 @@ it('uses registration order by default and breaks equal priorities with that ord
await expect(tied.execute((resource) => resource)).resolves.toBe('a')
})

it.each(['toString', 'constructor', '__proto__'])(
'ignores inherited priorities for provider %s',
async (id) => {
const router = createRouter({
providers: [
{ id, resource: id },
{ id: 'preferred', resource: 'preferred' },
],
selection: { type: 'priority', priorities: { preferred: 10 } },
})

await expect(router.execute((resource) => resource)).resolves.toBe('preferred')
},
)

it('inherits protection, replaces nested settings and can disable an inherited circuit', async () => {
vi.spyOn(Math, 'random').mockReturnValue(0.5)

Expand Down Expand Up @@ -561,6 +576,18 @@ it('waits for a timed-out callback before failing over and releasing its slot',
expect(visits).toEqual(['a', 'b'])
})

it('supports an unbounded pending limit', async () => {
const router = createRouter({ providers, concurrency: 1, maxPending: Infinity })
const blocker = deferred()
const active = router.execute(() => blocker.promise)
const first = router.execute(() => 'first')
const second = router.execute(() => 'second')

blocker.resolve()
await active
await expect(Promise.all([first, second])).resolves.toEqual(['first', 'second'])
})

describe('configuration', () => {
it.each<{ name: string; options: Partial<RouterOptions<string>> }>([
{ name: 'empty providers', options: { providers: [] } },
Expand Down
21 changes: 7 additions & 14 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export function createRouter<Resource, const Id extends string = string>(
options: RouterOptions<Resource, Id>,
): Router<Resource> {
const selection = options.selection ?? { type: 'priority' }
const priorities = selection.type === 'priority' ? selection.priorities : undefined
const ids = new Set<string>()
const providers = options.providers.map((provider) => {
if (provider.id.length === 0 || ids.has(provider.id)) {
Expand All @@ -20,7 +21,10 @@ export function createRouter<Resource, const Id extends string = string>(
id: provider.id,
resource: provider.resource,
protection: new Protection(provider.protection, options.protection),
priority: selection.type === 'priority' ? (selection.priorities?.[provider.id] ?? 0) : 0,
priority:
priorities !== undefined && Object.hasOwn(priorities, provider.id)
? (priorities[provider.id] ?? 0)
: 0,
}
})
const first = providers[0]
Expand All @@ -30,7 +34,7 @@ export function createRouter<Resource, const Id extends string = string>(
}

if (selection.type === 'priority') {
for (const [id, priority] of Object.entries(selection.priorities ?? {})) {
for (const [id, priority] of Object.entries(priorities ?? {})) {
if (!ids.has(id) || !Number.isFinite(priority)) {
throw new TypeError('Priorities must reference registered providers and be finite')
}
Expand All @@ -41,21 +45,10 @@ export function createRouter<Resource, const Id extends string = string>(
throw new TypeError('Unknown selection policy')
}

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(concurrency, maxPending)
const scheduler = new Scheduler(options.concurrency ?? Infinity, options.maxPending ?? 0)

function run<Result>(
operation: (resource: Resource, context: RunContext) => Result,
Expand Down
10 changes: 9 additions & 1 deletion src/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,15 @@ 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 (maxPending !== Infinity && (!Number.isInteger(maxPending) || maxPending < 0)) {
throw new RangeError('maxPending must be a non-negative integer or Infinity')
}
}

rejectPending(error: unknown): void {
for (const entry of this.pending) {
Expand Down
Loading