@gkoos/caracal 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +311 -0
- package/dist/chunk-5CXDW7W6.js +202 -0
- package/dist/chunk-5CXDW7W6.js.map +1 -0
- package/dist/circuit-breaker-BSkcV0W_.d.ts +296 -0
- package/dist/fetch.d.ts +58 -0
- package/dist/fetch.js +117 -0
- package/dist/fetch.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +1065 -0
- package/dist/index.js.map +1 -0
- package/dist/postgres.d.ts +28 -0
- package/dist/postgres.js +56 -0
- package/dist/postgres.js.map +1 -0
- package/dist/redis.d.ts +59 -0
- package/dist/redis.js +549 -0
- package/dist/redis.js.map +1 -0
- package/dist/retry-BFP_k3Hg.d.ts +26 -0
- package/dist/testing/index.d.ts +45 -0
- package/dist/testing/index.js +101 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/types-Tf9T76C7.d.ts +187 -0
- package/package.json +127 -0
- package/src/adapters/fetch/adapter.ts +122 -0
- package/src/adapters/fetch/index.ts +15 -0
- package/src/adapters/fetch/retry-after.ts +111 -0
- package/src/adapters/postgres/adapter.ts +102 -0
- package/src/adapters/postgres/index.ts +7 -0
- package/src/coordination/redis/bulkhead.ts +61 -0
- package/src/coordination/redis/circuit-breaker.ts +270 -0
- package/src/coordination/redis/client.ts +78 -0
- package/src/coordination/redis/eval-script.ts +71 -0
- package/src/coordination/redis/keys.ts +32 -0
- package/src/coordination/redis/leases.ts +44 -0
- package/src/coordination/redis/scripts.ts +314 -0
- package/src/core/bulkhead.ts +336 -0
- package/src/core/circuit-breaker.ts +1066 -0
- package/src/core/index.ts +36 -0
- package/src/core/operation.ts +174 -0
- package/src/core/retry.ts +204 -0
- package/src/core/runtime.ts +123 -0
- package/src/core/scope-state-cache.ts +50 -0
- package/src/core/timeout.ts +73 -0
- package/src/core/types.ts +230 -0
- package/src/fetch.ts +17 -0
- package/src/index.ts +49 -0
- package/src/postgres.ts +9 -0
- package/src/redis.ts +8 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
AdmitProbeResult,
|
|
3
|
+
BreakerClassifier,
|
|
4
|
+
BreakerCoordinator,
|
|
5
|
+
BreakerIdentity,
|
|
6
|
+
BreakerOutcome,
|
|
7
|
+
BreakerSnapshot,
|
|
8
|
+
BreakerState,
|
|
9
|
+
DistributedBreakerOptions,
|
|
10
|
+
LocalBreakerOptions,
|
|
11
|
+
ObserveResult,
|
|
12
|
+
SettleProbeResult,
|
|
13
|
+
} from "./circuit-breaker.js"
|
|
14
|
+
export { CircuitOpenError, circuitBreaker } from "./circuit-breaker.js"
|
|
15
|
+
export { operation } from "./operation.js"
|
|
16
|
+
export type { RetryContext, RetryDelay, RetryOptions } from "./retry.js"
|
|
17
|
+
export { retry } from "./retry.js"
|
|
18
|
+
export type { TimeoutOptions } from "./timeout.js"
|
|
19
|
+
export { TimeoutError, timeout } from "./timeout.js"
|
|
20
|
+
export type {
|
|
21
|
+
Adapter,
|
|
22
|
+
Classification,
|
|
23
|
+
EventSink,
|
|
24
|
+
EventSinks,
|
|
25
|
+
ExecutionContext,
|
|
26
|
+
ExecutionMetadata,
|
|
27
|
+
Next,
|
|
28
|
+
Operation,
|
|
29
|
+
OperationCapabilities,
|
|
30
|
+
OperationEvent,
|
|
31
|
+
OperationExecuteOptions,
|
|
32
|
+
OperationOptions,
|
|
33
|
+
Outcome,
|
|
34
|
+
OutcomeClassifier,
|
|
35
|
+
Policy,
|
|
36
|
+
} from "./types.js"
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
admissionSignal,
|
|
5
|
+
createClassifier,
|
|
6
|
+
createExecutionContext,
|
|
7
|
+
} from "./runtime.js"
|
|
8
|
+
import type {
|
|
9
|
+
Adapter,
|
|
10
|
+
EventSink,
|
|
11
|
+
EventSinks,
|
|
12
|
+
ExecutionMetadata,
|
|
13
|
+
Next,
|
|
14
|
+
Operation,
|
|
15
|
+
OperationCapabilities,
|
|
16
|
+
OperationEvent,
|
|
17
|
+
OperationExecuteOptions,
|
|
18
|
+
OperationOptions,
|
|
19
|
+
Outcome,
|
|
20
|
+
Policy,
|
|
21
|
+
} from "./types.js"
|
|
22
|
+
|
|
23
|
+
const summarizeSuccess = (): Outcome<undefined> => ({
|
|
24
|
+
status: "success",
|
|
25
|
+
value: undefined,
|
|
26
|
+
})
|
|
27
|
+
const summarizeFailure = (error: unknown): Outcome<undefined> => ({
|
|
28
|
+
status: "failure",
|
|
29
|
+
error,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
function immutableCapabilities(
|
|
33
|
+
capabilities: OperationCapabilities,
|
|
34
|
+
): OperationCapabilities {
|
|
35
|
+
return Object.freeze({ ...capabilities })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function immutableMetadata(
|
|
39
|
+
metadata: Readonly<Record<string, unknown>> | undefined,
|
|
40
|
+
): ExecutionMetadata {
|
|
41
|
+
return Object.freeze({ ...(metadata ?? {}) })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeSinks(events: EventSinks | undefined): readonly EventSink[] {
|
|
45
|
+
if (events === undefined) {
|
|
46
|
+
return []
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return "emit" in events ? [events] : events
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function emit(sinks: readonly EventSink[], event: OperationEvent): void {
|
|
53
|
+
for (const sink of sinks) {
|
|
54
|
+
try {
|
|
55
|
+
sink.emit(event)
|
|
56
|
+
} catch {
|
|
57
|
+
// Observability must not modify resilience execution.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function validateName(name: string, kind: "operation" | "policy"): void {
|
|
63
|
+
if (name.trim().length === 0) {
|
|
64
|
+
throw new Error(`${kind} name must not be empty`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createPipeline<Result>(
|
|
69
|
+
policies: readonly Policy[],
|
|
70
|
+
adapter: Next<Result>,
|
|
71
|
+
): Next<Result> {
|
|
72
|
+
return policies.reduceRight<Next<Result>>(
|
|
73
|
+
(next, policy) => async (context) => policy.execute(context, next),
|
|
74
|
+
adapter,
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function invokeAdapter<Args, Result>(
|
|
79
|
+
adapter: Adapter<Args, Result>,
|
|
80
|
+
args: Args,
|
|
81
|
+
sinks: readonly EventSink[],
|
|
82
|
+
): Next<Result> {
|
|
83
|
+
return async (context) => {
|
|
84
|
+
admissionSignal(context)?.throwIfAborted()
|
|
85
|
+
emit(sinks, { type: "attempt.started", at: Date.now(), context })
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const value = await adapter.execute(args, context)
|
|
89
|
+
const outcome: Outcome<Result> = { status: "success", value }
|
|
90
|
+
const classification = context.classify(outcome)
|
|
91
|
+
emit(sinks, {
|
|
92
|
+
type: "attempt.settled",
|
|
93
|
+
at: Date.now(),
|
|
94
|
+
context,
|
|
95
|
+
outcome: summarizeSuccess(),
|
|
96
|
+
classification,
|
|
97
|
+
})
|
|
98
|
+
return value
|
|
99
|
+
} catch (error) {
|
|
100
|
+
const outcome: Outcome<Result> = { status: "failure", error }
|
|
101
|
+
const classification = context.classify(outcome)
|
|
102
|
+
emit(sinks, {
|
|
103
|
+
type: "attempt.settled",
|
|
104
|
+
at: Date.now(),
|
|
105
|
+
context,
|
|
106
|
+
outcome: summarizeFailure(error),
|
|
107
|
+
classification,
|
|
108
|
+
})
|
|
109
|
+
throw error
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Creates a named, protocol-agnostic operation. */
|
|
115
|
+
export function operation<Args, Result>(
|
|
116
|
+
options: OperationOptions<Args, Result>,
|
|
117
|
+
): Operation<Args, Result> {
|
|
118
|
+
validateName(options.name, "operation")
|
|
119
|
+
for (const policy of options.policies ?? []) {
|
|
120
|
+
validateName(policy.name, "policy")
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const policies = Object.freeze([...(options.policies ?? [])])
|
|
124
|
+
const sinks = Object.freeze(normalizeSinks(options.events))
|
|
125
|
+
|
|
126
|
+
return Object.freeze({
|
|
127
|
+
name: options.name,
|
|
128
|
+
async execute(
|
|
129
|
+
args: Args,
|
|
130
|
+
executeOptions: OperationExecuteOptions = {},
|
|
131
|
+
): Promise<Result> {
|
|
132
|
+
const capabilities = options.adapter.capabilities(args)
|
|
133
|
+
const context = createExecutionContext(
|
|
134
|
+
{
|
|
135
|
+
operationName: options.name,
|
|
136
|
+
executionId: executeOptions.executionId ?? randomUUID(),
|
|
137
|
+
signal: executeOptions.signal,
|
|
138
|
+
metadata: immutableMetadata(executeOptions.metadata),
|
|
139
|
+
capabilities: immutableCapabilities(capabilities),
|
|
140
|
+
classify: createClassifier(options.adapter.classify),
|
|
141
|
+
},
|
|
142
|
+
sinks,
|
|
143
|
+
)
|
|
144
|
+
const adapter = createPipeline(
|
|
145
|
+
policies.filter((policy) => policy.phase === "attempt"),
|
|
146
|
+
invokeAdapter(options.adapter, args, sinks),
|
|
147
|
+
)
|
|
148
|
+
const pipeline = createPipeline(
|
|
149
|
+
policies.filter((policy) => policy.phase !== "attempt"),
|
|
150
|
+
adapter,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
emit(sinks, { type: "execution.started", at: Date.now(), context })
|
|
154
|
+
try {
|
|
155
|
+
const value = await pipeline(context)
|
|
156
|
+
emit(sinks, {
|
|
157
|
+
type: "execution.settled",
|
|
158
|
+
at: Date.now(),
|
|
159
|
+
context,
|
|
160
|
+
outcome: summarizeSuccess(),
|
|
161
|
+
})
|
|
162
|
+
return value
|
|
163
|
+
} catch (error) {
|
|
164
|
+
emit(sinks, {
|
|
165
|
+
type: "execution.settled",
|
|
166
|
+
at: Date.now(),
|
|
167
|
+
context,
|
|
168
|
+
outcome: summarizeFailure(error),
|
|
169
|
+
})
|
|
170
|
+
throw error
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
})
|
|
174
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { admissionSignal, emitRuntimeEvent, nextAttempt } from "./runtime.js"
|
|
2
|
+
import type {
|
|
3
|
+
Classification,
|
|
4
|
+
ExecutionContext,
|
|
5
|
+
ExecutionMetadata,
|
|
6
|
+
Next,
|
|
7
|
+
OperationCapabilities,
|
|
8
|
+
Outcome,
|
|
9
|
+
Policy,
|
|
10
|
+
} from "./types.js"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Settled-attempt information passed to a custom `delay` function.
|
|
14
|
+
*
|
|
15
|
+
* `result` and `error` are conveniences over `outcome`; the core never
|
|
16
|
+
* interprets them, so an adapter-specific helper can pace retries from
|
|
17
|
+
* protocol feedback (for example an HTTP `Retry-After` header) without
|
|
18
|
+
* leaking protocol types into the core.
|
|
19
|
+
*/
|
|
20
|
+
export interface RetryContext {
|
|
21
|
+
readonly outcome: Outcome<unknown>
|
|
22
|
+
readonly result: unknown
|
|
23
|
+
readonly error: unknown
|
|
24
|
+
readonly capabilities: OperationCapabilities
|
|
25
|
+
readonly metadata: ExecutionMetadata
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type RetryDelay =
|
|
29
|
+
| number
|
|
30
|
+
| ((attempt: number, context: RetryContext) => number)
|
|
31
|
+
|
|
32
|
+
export interface RetryOptions {
|
|
33
|
+
readonly maxAttempts: number
|
|
34
|
+
readonly delay?: RetryDelay
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
38
|
+
if (!signal?.aborted) {
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
throw (
|
|
43
|
+
signal.reason ?? new DOMException("The operation was aborted", "AbortError")
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function retryContext(
|
|
48
|
+
context: ExecutionContext,
|
|
49
|
+
outcome: Outcome<unknown>,
|
|
50
|
+
): RetryContext {
|
|
51
|
+
return {
|
|
52
|
+
outcome,
|
|
53
|
+
result: outcome.status === "success" ? outcome.value : undefined,
|
|
54
|
+
error: outcome.status === "failure" ? outcome.error : undefined,
|
|
55
|
+
capabilities: context.capabilities,
|
|
56
|
+
metadata: context.metadata,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function delayFor(
|
|
61
|
+
options: RetryOptions,
|
|
62
|
+
context: ExecutionContext,
|
|
63
|
+
attempt: number,
|
|
64
|
+
outcome: Outcome<unknown>,
|
|
65
|
+
): number {
|
|
66
|
+
const configured = options.delay
|
|
67
|
+
const delay =
|
|
68
|
+
typeof configured === "function"
|
|
69
|
+
? configured(attempt, retryContext(context, outcome))
|
|
70
|
+
: (configured ?? 0)
|
|
71
|
+
if (!Number.isFinite(delay) || delay < 0) {
|
|
72
|
+
throw new RangeError("retry delay must be a finite non-negative number")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return delay
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function wait(delayMs: number, signal: AbortSignal | undefined): Promise<void> {
|
|
79
|
+
if (delayMs === 0) {
|
|
80
|
+
throwIfAborted(signal)
|
|
81
|
+
return Promise.resolve()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
const timer = setTimeout(done, delayMs)
|
|
86
|
+
|
|
87
|
+
function done(): void {
|
|
88
|
+
signal?.removeEventListener("abort", aborted)
|
|
89
|
+
resolve()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function aborted(): void {
|
|
93
|
+
clearTimeout(timer)
|
|
94
|
+
reject(
|
|
95
|
+
signal?.reason ??
|
|
96
|
+
new DOMException("The operation was aborted", "AbortError"),
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (signal?.aborted) {
|
|
101
|
+
aborted()
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
signal?.addEventListener("abort", aborted, { once: true })
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function summarized<Result>(outcome: Outcome<Result>): Outcome<undefined> {
|
|
110
|
+
return outcome.status === "success"
|
|
111
|
+
? { status: "success", value: undefined }
|
|
112
|
+
: { status: "failure", error: outcome.error }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function classification<Result>(
|
|
116
|
+
context: ExecutionContext,
|
|
117
|
+
outcome: Outcome<Result>,
|
|
118
|
+
): Classification {
|
|
119
|
+
return context.classify(outcome)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Retries adapter-classified failures within a single logical invocation. */
|
|
123
|
+
export function retry(options: RetryOptions): Policy {
|
|
124
|
+
if (!Number.isInteger(options.maxAttempts) || options.maxAttempts < 1) {
|
|
125
|
+
throw new RangeError("maxAttempts must be a positive integer")
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return Object.freeze({
|
|
129
|
+
name: "retry",
|
|
130
|
+
async execute<Result>(
|
|
131
|
+
initialContext: ExecutionContext,
|
|
132
|
+
next: Next<Result>,
|
|
133
|
+
): Promise<Result> {
|
|
134
|
+
let context = initialContext
|
|
135
|
+
|
|
136
|
+
for (;;) {
|
|
137
|
+
throwIfAborted(admissionSignal(context))
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const value = await next(context)
|
|
141
|
+
const outcome: Outcome<Result> = { status: "success", value }
|
|
142
|
+
const outcomeClassification = classification(context, outcome)
|
|
143
|
+
if (
|
|
144
|
+
outcomeClassification !== "retryable" ||
|
|
145
|
+
context.capabilities.replay !== "safe"
|
|
146
|
+
) {
|
|
147
|
+
return value
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (context.attempt >= options.maxAttempts) {
|
|
151
|
+
emitRuntimeEvent(context, {
|
|
152
|
+
type: "retry.exhausted",
|
|
153
|
+
outcome: summarized(outcome),
|
|
154
|
+
classification: outcomeClassification,
|
|
155
|
+
})
|
|
156
|
+
return value
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const delayMs = delayFor(options, context, context.attempt, outcome)
|
|
160
|
+
emitRuntimeEvent(context, {
|
|
161
|
+
type: "retry.scheduled",
|
|
162
|
+
nextAttempt: context.attempt + 1,
|
|
163
|
+
delayMs,
|
|
164
|
+
outcome: summarized(outcome),
|
|
165
|
+
classification: outcomeClassification,
|
|
166
|
+
})
|
|
167
|
+
await wait(delayMs, admissionSignal(context))
|
|
168
|
+
context = nextAttempt(context)
|
|
169
|
+
} catch (error) {
|
|
170
|
+
const outcome: Outcome<Result> = { status: "failure", error }
|
|
171
|
+
const outcomeClassification = classification(context, outcome)
|
|
172
|
+
|
|
173
|
+
if (
|
|
174
|
+
admissionSignal(context)?.aborted ||
|
|
175
|
+
context.capabilities.replay !== "safe" ||
|
|
176
|
+
outcomeClassification !== "retryable"
|
|
177
|
+
) {
|
|
178
|
+
throw error
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (context.attempt >= options.maxAttempts) {
|
|
182
|
+
emitRuntimeEvent(context, {
|
|
183
|
+
type: "retry.exhausted",
|
|
184
|
+
outcome: summarized(outcome),
|
|
185
|
+
classification: outcomeClassification,
|
|
186
|
+
})
|
|
187
|
+
throw error
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const delayMs = delayFor(options, context, context.attempt, outcome)
|
|
191
|
+
emitRuntimeEvent(context, {
|
|
192
|
+
type: "retry.scheduled",
|
|
193
|
+
nextAttempt: context.attempt + 1,
|
|
194
|
+
delayMs,
|
|
195
|
+
outcome: summarized(outcome),
|
|
196
|
+
classification: outcomeClassification,
|
|
197
|
+
})
|
|
198
|
+
await wait(delayMs, admissionSignal(context))
|
|
199
|
+
context = nextAttempt(context)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
})
|
|
204
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Classification,
|
|
3
|
+
EventSink,
|
|
4
|
+
ExecutionContext,
|
|
5
|
+
OperationEvent,
|
|
6
|
+
Outcome,
|
|
7
|
+
OutcomeClassifier,
|
|
8
|
+
} from "./types.js"
|
|
9
|
+
|
|
10
|
+
const eventSinks = Symbol("caracal.eventSinks")
|
|
11
|
+
const admissionSignals = new WeakMap<ExecutionContext, AbortSignal>()
|
|
12
|
+
export function admissionSignal(
|
|
13
|
+
context: ExecutionContext,
|
|
14
|
+
): AbortSignal | undefined {
|
|
15
|
+
const admission = admissionSignals.get(context)
|
|
16
|
+
return admission && context.signal
|
|
17
|
+
? AbortSignal.any([admission, context.signal])
|
|
18
|
+
: (admission ?? context.signal)
|
|
19
|
+
}
|
|
20
|
+
export function withAdmissionSignal(
|
|
21
|
+
context: ExecutionContext,
|
|
22
|
+
signal: AbortSignal,
|
|
23
|
+
): ExecutionContext {
|
|
24
|
+
const derived = attachRuntime(
|
|
25
|
+
{ ...context },
|
|
26
|
+
runtimeContext(context)[eventSinks] ?? [],
|
|
27
|
+
)
|
|
28
|
+
const previous = admissionSignal(context)
|
|
29
|
+
admissionSignals.set(
|
|
30
|
+
derived,
|
|
31
|
+
previous ? AbortSignal.any([previous, signal]) : signal,
|
|
32
|
+
)
|
|
33
|
+
return derived
|
|
34
|
+
}
|
|
35
|
+
function inheritAdmission(
|
|
36
|
+
source: ExecutionContext,
|
|
37
|
+
target: ExecutionContext,
|
|
38
|
+
): ExecutionContext {
|
|
39
|
+
const signal = admissionSignals.get(source)
|
|
40
|
+
if (signal) admissionSignals.set(target, signal)
|
|
41
|
+
return target
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type EventWithoutRuntimeFields = OperationEvent extends infer Event
|
|
45
|
+
? Event extends OperationEvent
|
|
46
|
+
? Omit<Event, "at" | "context">
|
|
47
|
+
: never
|
|
48
|
+
: never
|
|
49
|
+
|
|
50
|
+
type RuntimeExecutionContext = ExecutionContext & {
|
|
51
|
+
readonly [eventSinks]: readonly EventSink[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function runtimeContext(context: ExecutionContext): RuntimeExecutionContext {
|
|
55
|
+
return context as RuntimeExecutionContext
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function attachRuntime(
|
|
59
|
+
values: ExecutionContext,
|
|
60
|
+
sinks: readonly EventSink[],
|
|
61
|
+
): ExecutionContext {
|
|
62
|
+
const context = values as RuntimeExecutionContext
|
|
63
|
+
Object.defineProperty(context, eventSinks, { value: sinks })
|
|
64
|
+
return Object.freeze(context)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createExecutionContext(
|
|
68
|
+
values: Omit<ExecutionContext, "attempt">,
|
|
69
|
+
sinks: readonly EventSink[],
|
|
70
|
+
): ExecutionContext {
|
|
71
|
+
return attachRuntime({ attempt: 1, ...values }, sinks)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function nextAttempt(context: ExecutionContext): ExecutionContext {
|
|
75
|
+
return inheritAdmission(
|
|
76
|
+
context,
|
|
77
|
+
attachRuntime(
|
|
78
|
+
{ ...context, attempt: context.attempt + 1 },
|
|
79
|
+
runtimeContext(context)[eventSinks] ?? [],
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function withSignal(
|
|
85
|
+
context: ExecutionContext,
|
|
86
|
+
signal: AbortSignal | undefined,
|
|
87
|
+
): ExecutionContext {
|
|
88
|
+
return inheritAdmission(
|
|
89
|
+
context,
|
|
90
|
+
attachRuntime(
|
|
91
|
+
{ ...context, signal },
|
|
92
|
+
runtimeContext(context)[eventSinks] ?? [],
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function emitRuntimeEvent(
|
|
98
|
+
context: ExecutionContext,
|
|
99
|
+
event: EventWithoutRuntimeFields,
|
|
100
|
+
): void {
|
|
101
|
+
const sinks = runtimeContext(context)[eventSinks] ?? []
|
|
102
|
+
const fullEvent = { ...event, at: Date.now(), context } as OperationEvent
|
|
103
|
+
|
|
104
|
+
for (const sink of sinks) {
|
|
105
|
+
try {
|
|
106
|
+
sink.emit(fullEvent)
|
|
107
|
+
} catch {
|
|
108
|
+
// Observability must not modify resilience execution.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function createClassifier<Result>(
|
|
114
|
+
classify: ((outcome: Outcome<Result>) => Classification) | undefined,
|
|
115
|
+
): OutcomeClassifier {
|
|
116
|
+
return (outcome) => {
|
|
117
|
+
if (classify === undefined) {
|
|
118
|
+
return outcome.status === "success" ? "success" : "failure"
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return classify(outcome as Outcome<Result>)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process record of the scopes whose last known distributed state was
|
|
3
|
+
* non-closed.
|
|
4
|
+
*
|
|
5
|
+
* It exists for one decision only: when `readState()` fails, a scope last seen
|
|
6
|
+
* OPEN or HALF_OPEN must fail closed, because admitting work to a known-open
|
|
7
|
+
* breaker removes the protection it exists to provide.
|
|
8
|
+
*
|
|
9
|
+
* CLOSED states are deliberately not retained. A missing entry and a CLOSED
|
|
10
|
+
* entry are treated identically by that decision, so storing CLOSED would grow
|
|
11
|
+
* this map with every scope ever observed while changing nothing.
|
|
12
|
+
*
|
|
13
|
+
* Entries are keyed by `(operation, scope)` to match the coordinator identity
|
|
14
|
+
* `(name, operation, scope)`: one policy instance shared by several operations
|
|
15
|
+
* must not read another operation's last known state.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Non-closed breaker state retained in process memory. */
|
|
19
|
+
export type RetainedScopeState = "open" | "half-open"
|
|
20
|
+
|
|
21
|
+
export interface ScopeStateCache {
|
|
22
|
+
remember(operation: string, scope: string, state: RetainedScopeState): void
|
|
23
|
+
forget(operation: string, scope: string): void
|
|
24
|
+
read(operation: string, scope: string): RetainedScopeState | undefined
|
|
25
|
+
/** Number of retained scopes. Internal; used by tests. */
|
|
26
|
+
size(): number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createScopeStateCache(): ScopeStateCache {
|
|
30
|
+
const retained = new Map<string, RetainedScopeState>()
|
|
31
|
+
// JSON encoding keeps the parts unambiguous even if a name contains the
|
|
32
|
+
// separator or other special characters.
|
|
33
|
+
const keyFor = (operation: string, scope: string) =>
|
|
34
|
+
JSON.stringify([operation, scope])
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
remember(operation, scope, state) {
|
|
38
|
+
retained.set(keyFor(operation, scope), state)
|
|
39
|
+
},
|
|
40
|
+
forget(operation, scope) {
|
|
41
|
+
retained.delete(keyFor(operation, scope))
|
|
42
|
+
},
|
|
43
|
+
read(operation, scope) {
|
|
44
|
+
return retained.get(keyFor(operation, scope))
|
|
45
|
+
},
|
|
46
|
+
size() {
|
|
47
|
+
return retained.size
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { emitRuntimeEvent, withAdmissionSignal, withSignal } from "./runtime.js"
|
|
2
|
+
import type { ExecutionContext, Next, Policy } from "./types.js"
|
|
3
|
+
|
|
4
|
+
export class TimeoutError extends Error {
|
|
5
|
+
readonly timeoutMs: number
|
|
6
|
+
|
|
7
|
+
constructor(timeoutMs: number) {
|
|
8
|
+
super(`Operation timed out after ${timeoutMs}ms`)
|
|
9
|
+
this.name = "TimeoutError"
|
|
10
|
+
this.timeoutMs = timeoutMs
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface TimeoutOptions {
|
|
15
|
+
readonly ms: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function combineSignals(
|
|
19
|
+
external: AbortSignal | undefined,
|
|
20
|
+
timeoutController: AbortController,
|
|
21
|
+
): AbortSignal {
|
|
22
|
+
if (external === undefined) {
|
|
23
|
+
return timeoutController.signal
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return AbortSignal.any([external, timeoutController.signal])
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Bounds caller wait time and requests cancellation only when the adapter supports it. */
|
|
30
|
+
export function timeout(options: TimeoutOptions): Policy {
|
|
31
|
+
if (!Number.isFinite(options.ms) || options.ms <= 0) {
|
|
32
|
+
throw new RangeError("timeout ms must be a finite positive number")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return Object.freeze({
|
|
36
|
+
name: "timeout",
|
|
37
|
+
async execute<Result>(
|
|
38
|
+
context: ExecutionContext,
|
|
39
|
+
next: Next<Result>,
|
|
40
|
+
): Promise<Result> {
|
|
41
|
+
const timeoutError = new TimeoutError(options.ms)
|
|
42
|
+
const supportsAbort = context.capabilities.abort === "supported"
|
|
43
|
+
const controller = new AbortController()
|
|
44
|
+
const attemptContext = withAdmissionSignal(
|
|
45
|
+
supportsAbort
|
|
46
|
+
? withSignal(context, combineSignals(context.signal, controller))
|
|
47
|
+
: context,
|
|
48
|
+
controller.signal,
|
|
49
|
+
)
|
|
50
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
51
|
+
|
|
52
|
+
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
|
53
|
+
timer = setTimeout(() => {
|
|
54
|
+
controller.abort(timeoutError)
|
|
55
|
+
emitRuntimeEvent(context, {
|
|
56
|
+
type: "timeout.triggered",
|
|
57
|
+
timeoutMs: options.ms,
|
|
58
|
+
abortRequested: supportsAbort,
|
|
59
|
+
})
|
|
60
|
+
reject(timeoutError)
|
|
61
|
+
}, options.ms)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
return await Promise.race([next(attemptContext), timeoutPromise])
|
|
66
|
+
} finally {
|
|
67
|
+
if (timer !== undefined) {
|
|
68
|
+
clearTimeout(timer)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
}
|