@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +21 -0
  3. package/README.md +311 -0
  4. package/dist/chunk-5CXDW7W6.js +202 -0
  5. package/dist/chunk-5CXDW7W6.js.map +1 -0
  6. package/dist/circuit-breaker-BSkcV0W_.d.ts +296 -0
  7. package/dist/fetch.d.ts +58 -0
  8. package/dist/fetch.js +117 -0
  9. package/dist/fetch.js.map +1 -0
  10. package/dist/index.d.ts +19 -0
  11. package/dist/index.js +1065 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/postgres.d.ts +28 -0
  14. package/dist/postgres.js +56 -0
  15. package/dist/postgres.js.map +1 -0
  16. package/dist/redis.d.ts +59 -0
  17. package/dist/redis.js +549 -0
  18. package/dist/redis.js.map +1 -0
  19. package/dist/retry-BFP_k3Hg.d.ts +26 -0
  20. package/dist/testing/index.d.ts +45 -0
  21. package/dist/testing/index.js +101 -0
  22. package/dist/testing/index.js.map +1 -0
  23. package/dist/types-Tf9T76C7.d.ts +187 -0
  24. package/package.json +127 -0
  25. package/src/adapters/fetch/adapter.ts +122 -0
  26. package/src/adapters/fetch/index.ts +15 -0
  27. package/src/adapters/fetch/retry-after.ts +111 -0
  28. package/src/adapters/postgres/adapter.ts +102 -0
  29. package/src/adapters/postgres/index.ts +7 -0
  30. package/src/coordination/redis/bulkhead.ts +61 -0
  31. package/src/coordination/redis/circuit-breaker.ts +270 -0
  32. package/src/coordination/redis/client.ts +78 -0
  33. package/src/coordination/redis/eval-script.ts +71 -0
  34. package/src/coordination/redis/keys.ts +32 -0
  35. package/src/coordination/redis/leases.ts +44 -0
  36. package/src/coordination/redis/scripts.ts +314 -0
  37. package/src/core/bulkhead.ts +336 -0
  38. package/src/core/circuit-breaker.ts +1066 -0
  39. package/src/core/index.ts +36 -0
  40. package/src/core/operation.ts +174 -0
  41. package/src/core/retry.ts +204 -0
  42. package/src/core/runtime.ts +123 -0
  43. package/src/core/scope-state-cache.ts +50 -0
  44. package/src/core/timeout.ts +73 -0
  45. package/src/core/types.ts +230 -0
  46. package/src/fetch.ts +17 -0
  47. package/src/index.ts +49 -0
  48. package/src/postgres.ts +9 -0
  49. 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
+ }