@procedures/client 0.2.2 → 0.3.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 +71 -0
- package/README.md +61 -38
- package/dist/.tsbuildinfo +1 -1
- package/dist/adapters/http/client/transport.d.ts.map +1 -1
- package/dist/adapters/http/client/transport.js +35 -3
- package/dist/adapters/http/client/transport.js.map +1 -1
- package/dist/adapters/http/client/types.d.ts +3 -2
- package/dist/adapters/http/client/types.d.ts.map +1 -1
- package/dist/adapters/http/server/transport.d.ts +2 -2
- package/dist/adapters/http/server/transport.d.ts.map +1 -1
- package/dist/adapters/http/server/transport.js +53 -20
- package/dist/adapters/http/server/transport.js.map +1 -1
- package/dist/adapters/http/shared/headers.d.ts +1 -1
- package/dist/adapters/http/shared/headers.d.ts.map +1 -1
- package/dist/adapters/http/shared/headers.js +7 -6
- package/dist/adapters/http/shared/headers.js.map +1 -1
- package/dist/adapters/local/client/transport.d.ts +3 -0
- package/dist/adapters/local/client/transport.d.ts.map +1 -1
- package/dist/adapters/local/client/transport.js +63 -4
- package/dist/adapters/local/client/transport.js.map +1 -1
- package/dist/adapters/local/client/types.d.ts +15 -0
- package/dist/adapters/local/client/types.d.ts.map +1 -1
- package/dist/adapters/local/client/types.js.map +1 -1
- package/dist/adapters/mock/client/transport.d.ts +3 -2
- package/dist/adapters/mock/client/transport.d.ts.map +1 -1
- package/dist/adapters/mock/client/transport.js +8 -5
- package/dist/adapters/mock/client/transport.js.map +1 -1
- package/dist/adapters/mock/client/types.d.ts +7 -6
- package/dist/adapters/mock/client/types.d.ts.map +1 -1
- package/dist/adapters/port/transport.d.ts +62 -0
- package/dist/adapters/port/transport.d.ts.map +1 -0
- package/dist/adapters/port/transport.js +94 -0
- package/dist/adapters/port/transport.js.map +1 -0
- package/dist/adapters/websocket/client/index.d.ts +1 -1
- package/dist/adapters/websocket/client/index.d.ts.map +1 -1
- package/dist/adapters/websocket/client/index.js.map +1 -1
- package/dist/adapters/websocket/client/transport.d.ts +47 -7
- package/dist/adapters/websocket/client/transport.d.ts.map +1 -1
- package/dist/adapters/websocket/client/transport.js +213 -67
- package/dist/adapters/websocket/client/transport.js.map +1 -1
- package/dist/adapters/websocket/client/types.d.ts +70 -21
- package/dist/adapters/websocket/client/types.d.ts.map +1 -1
- package/dist/adapters/websocket/client/types.js.map +1 -1
- package/dist/adapters/websocket/server/transport.d.ts +65 -24
- package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
- package/dist/adapters/websocket/server/transport.js +192 -263
- package/dist/adapters/websocket/server/transport.js.map +1 -1
- package/dist/adapters/websocket/server/types.d.ts +20 -1
- package/dist/adapters/websocket/server/types.d.ts.map +1 -1
- package/dist/adapters/websocket/shared/rpc.d.ts +129 -0
- package/dist/adapters/websocket/shared/rpc.d.ts.map +1 -0
- package/dist/adapters/websocket/shared/rpc.js +425 -0
- package/dist/adapters/websocket/shared/rpc.js.map +1 -0
- package/dist/browser.d.ts +29 -7
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +26 -3
- package/dist/browser.js.map +1 -1
- package/dist/client/call-types.d.ts +10 -10
- package/dist/client/call-types.d.ts.map +1 -1
- package/dist/client/call-types.js +1 -6
- package/dist/client/call-types.js.map +1 -1
- package/dist/client/client.d.ts +6 -1
- package/dist/client/client.d.ts.map +1 -1
- package/dist/client/client.js +23 -9
- package/dist/client/client.js.map +1 -1
- package/dist/client/deadline.d.ts +38 -0
- package/dist/client/deadline.d.ts.map +1 -0
- package/dist/client/deadline.js +60 -0
- package/dist/client/deadline.js.map +1 -0
- package/dist/client/errors/factory.d.ts.map +1 -1
- package/dist/client/errors/factory.js +11 -7
- package/dist/client/errors/factory.js.map +1 -1
- package/dist/client/index.d.ts +9 -2
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +8 -2
- package/dist/client/index.js.map +1 -1
- package/dist/client/middleware/cache.d.ts +19 -5
- package/dist/client/middleware/cache.d.ts.map +1 -1
- package/dist/client/middleware/cache.js +106 -18
- package/dist/client/middleware/cache.js.map +1 -1
- package/dist/client/middleware/circuit-breaker.d.ts +28 -10
- package/dist/client/middleware/circuit-breaker.d.ts.map +1 -1
- package/dist/client/middleware/circuit-breaker.js +35 -24
- package/dist/client/middleware/circuit-breaker.js.map +1 -1
- package/dist/client/middleware/contexts.d.ts +16 -10
- package/dist/client/middleware/contexts.d.ts.map +1 -1
- package/dist/client/middleware/contexts.js.map +1 -1
- package/dist/client/middleware/items.d.ts +31 -2
- package/dist/client/middleware/items.d.ts.map +1 -1
- package/dist/client/middleware/items.js +86 -6
- package/dist/client/middleware/items.js.map +1 -1
- package/dist/client/middleware/lru.d.ts +2 -0
- package/dist/client/middleware/lru.d.ts.map +1 -1
- package/dist/client/middleware/lru.js +11 -0
- package/dist/client/middleware/lru.js.map +1 -1
- package/dist/client/middleware/rate-limit.d.ts +7 -6
- package/dist/client/middleware/rate-limit.d.ts.map +1 -1
- package/dist/client/middleware/rate-limit.js +9 -6
- package/dist/client/middleware/rate-limit.js.map +1 -1
- package/dist/client/middleware/resilience.d.ts +61 -0
- package/dist/client/middleware/resilience.d.ts.map +1 -0
- package/dist/client/middleware/resilience.js +59 -0
- package/dist/client/middleware/resilience.js.map +1 -0
- package/dist/client/middleware/retry.d.ts +45 -11
- package/dist/client/middleware/retry.d.ts.map +1 -1
- package/dist/client/middleware/retry.js +102 -22
- package/dist/client/middleware/retry.js.map +1 -1
- package/dist/client/middleware/timeout.d.ts +15 -12
- package/dist/client/middleware/timeout.d.ts.map +1 -1
- package/dist/client/middleware/timeout.js +46 -37
- package/dist/client/middleware/timeout.js.map +1 -1
- package/dist/client/middleware/tracing.d.ts.map +1 -1
- package/dist/client/middleware/tracing.js +11 -3
- package/dist/client/middleware/tracing.js.map +1 -1
- package/dist/client/types.d.ts +9 -1
- package/dist/client/types.d.ts.map +1 -1
- package/dist/client/types.js.map +1 -1
- package/dist/clock.d.ts +19 -0
- package/dist/clock.d.ts.map +1 -0
- package/dist/clock.js +18 -0
- package/dist/clock.js.map +1 -0
- package/dist/duration.d.ts +37 -0
- package/dist/duration.d.ts.map +1 -0
- package/dist/duration.js +44 -0
- package/dist/duration.js.map +1 -0
- package/dist/machines.d.ts +90 -0
- package/dist/machines.d.ts.map +1 -0
- package/dist/machines.js +127 -0
- package/dist/machines.js.map +1 -0
- package/dist/procedures/capability.d.ts +3 -6
- package/dist/procedures/capability.d.ts.map +1 -1
- package/dist/procedures/capability.js +11 -9
- package/dist/procedures/capability.js.map +1 -1
- package/dist/procedures/collection/index.d.ts +3 -3
- package/dist/procedures/collection/index.d.ts.map +1 -1
- package/dist/procedures/collection/index.js +1 -1
- package/dist/procedures/collection/index.js.map +1 -1
- package/dist/procedures/collection/procedures.d.ts.map +1 -1
- package/dist/procedures/collection/procedures.js +49 -1
- package/dist/procedures/collection/procedures.js.map +1 -1
- package/dist/procedures/collection/schemas.d.ts +33 -0
- package/dist/procedures/collection/schemas.d.ts.map +1 -1
- package/dist/procedures/collection/schemas.js +68 -0
- package/dist/procedures/collection/schemas.js.map +1 -1
- package/dist/procedures/collection/storage.d.ts +37 -0
- package/dist/procedures/collection/storage.d.ts.map +1 -1
- package/dist/procedures/core/array.d.ts.map +1 -1
- package/dist/procedures/core/array.js +26 -15
- package/dist/procedures/core/array.js.map +1 -1
- package/dist/procedures/core/index.d.ts.map +1 -1
- package/dist/procedures/core/index.js +8 -0
- package/dist/procedures/core/index.js.map +1 -1
- package/dist/procedures/core/meta.d.ts.map +1 -1
- package/dist/procedures/core/meta.js +2 -1
- package/dist/procedures/core/meta.js.map +1 -1
- package/dist/procedures/core/object.d.ts.map +1 -1
- package/dist/procedures/core/object.js +42 -13
- package/dist/procedures/core/object.js.map +1 -1
- package/dist/procedures/core/operands.d.ts.map +1 -1
- package/dist/procedures/core/operands.js +6 -1
- package/dist/procedures/core/operands.js.map +1 -1
- package/dist/procedures/core/string.d.ts.map +1 -1
- package/dist/procedures/core/string.js +4 -2
- package/dist/procedures/core/string.js.map +1 -1
- package/dist/procedures/define-procedure.d.ts +19 -7
- package/dist/procedures/define-procedure.d.ts.map +1 -1
- package/dist/procedures/define-procedure.js +66 -21
- package/dist/procedures/define-procedure.js.map +1 -1
- package/dist/procedures/effects.d.ts +16 -0
- package/dist/procedures/effects.d.ts.map +1 -1
- package/dist/procedures/effects.js +33 -0
- package/dist/procedures/effects.js.map +1 -1
- package/dist/procedures/idempotency.d.ts +73 -0
- package/dist/procedures/idempotency.d.ts.map +1 -0
- package/dist/procedures/idempotency.js +131 -0
- package/dist/procedures/idempotency.js.map +1 -0
- package/dist/procedures/index.d.ts +7 -3
- package/dist/procedures/index.d.ts.map +1 -1
- package/dist/procedures/index.js +6 -2
- package/dist/procedures/index.js.map +1 -1
- package/dist/procedures/interceptors.d.ts +17 -4
- package/dist/procedures/interceptors.d.ts.map +1 -1
- package/dist/procedures/interceptors.js +18 -7
- package/dist/procedures/interceptors.js.map +1 -1
- package/dist/procedures/invoke.d.ts +15 -3
- package/dist/procedures/invoke.d.ts.map +1 -1
- package/dist/procedures/invoke.js +8 -3
- package/dist/procedures/invoke.js.map +1 -1
- package/dist/procedures/manifest.d.ts +83 -0
- package/dist/procedures/manifest.d.ts.map +1 -0
- package/dist/procedures/manifest.js +90 -0
- package/dist/procedures/manifest.js.map +1 -0
- package/dist/procedures/runtime-programs.d.ts +19 -0
- package/dist/procedures/runtime-programs.d.ts.map +1 -0
- package/dist/procedures/runtime-programs.js +24 -0
- package/dist/procedures/runtime-programs.js.map +1 -0
- package/dist/procedures/types.d.ts +20 -1
- package/dist/procedures/types.d.ts.map +1 -1
- package/dist/procedures/types.js.map +1 -1
- package/dist/protocol.d.ts +40 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +58 -0
- package/dist/protocol.js.map +1 -0
- package/dist/server/host-policy.d.ts +4 -3
- package/dist/server/host-policy.d.ts.map +1 -1
- package/dist/server/host-policy.js +6 -3
- package/dist/server/host-policy.js.map +1 -1
- package/dist/server/host-procedures.d.ts +31 -0
- package/dist/server/host-procedures.d.ts.map +1 -0
- package/dist/server/host-procedures.js +75 -0
- package/dist/server/host-procedures.js.map +1 -0
- package/dist/server/index.d.ts +3 -3
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/procedure-server.d.ts +110 -4
- package/dist/server/procedure-server.d.ts.map +1 -1
- package/dist/server/procedure-server.js +269 -17
- package/dist/server/procedure-server.js.map +1 -1
- package/dist/server/request-auth.d.ts +19 -0
- package/dist/server/request-auth.d.ts.map +1 -0
- package/dist/server/request-auth.js +32 -0
- package/dist/server/request-auth.js.map +1 -0
- package/dist/server/types.d.ts +32 -0
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/types.js.map +1 -1
- package/dist/timers.d.ts +60 -0
- package/dist/timers.d.ts.map +1 -0
- package/dist/timers.js +33 -0
- package/dist/timers.js.map +1 -0
- package/dist/tracing/traceparent.d.ts +26 -0
- package/dist/tracing/traceparent.d.ts.map +1 -0
- package/dist/tracing/traceparent.js +53 -0
- package/dist/tracing/traceparent.js.map +1 -0
- package/package.json +7 -1
- package/src/adapters/http/client/transport.ts +31 -3
- package/src/adapters/http/client/types.ts +3 -2
- package/src/adapters/http/server/transport.ts +60 -24
- package/src/adapters/http/shared/headers.ts +7 -6
- package/src/adapters/local/client/transport.ts +70 -5
- package/src/adapters/local/client/types.ts +18 -0
- package/src/adapters/mock/client/transport.ts +9 -6
- package/src/adapters/mock/client/types.ts +7 -6
- package/src/adapters/port/transport.ts +124 -0
- package/src/adapters/websocket/client/index.ts +1 -0
- package/src/adapters/websocket/client/transport.ts +232 -73
- package/src/adapters/websocket/client/types.ts +80 -21
- package/src/adapters/websocket/server/transport.ts +199 -301
- package/src/adapters/websocket/server/types.ts +20 -1
- package/src/adapters/websocket/shared/rpc.ts +453 -0
- package/src/browser.ts +51 -3
- package/src/client/call-types.ts +10 -14
- package/src/client/client.ts +28 -9
- package/src/client/deadline.ts +64 -0
- package/src/client/errors/factory.ts +12 -8
- package/src/client/index.ts +21 -3
- package/src/client/middleware/cache.ts +120 -23
- package/src/client/middleware/circuit-breaker.ts +65 -35
- package/src/client/middleware/contexts.ts +17 -10
- package/src/client/middleware/items.ts +101 -6
- package/src/client/middleware/lru.ts +12 -0
- package/src/client/middleware/rate-limit.ts +9 -7
- package/src/client/middleware/resilience.ts +81 -0
- package/src/client/middleware/retry.ts +146 -34
- package/src/client/middleware/timeout.ts +55 -46
- package/src/client/middleware/tracing.ts +13 -4
- package/src/client/types.ts +9 -1
- package/src/clock.ts +21 -0
- package/src/duration.ts +56 -0
- package/src/machines.ts +186 -0
- package/src/procedures/capability.ts +11 -9
- package/src/procedures/collection/index.ts +9 -1
- package/src/procedures/collection/procedures.ts +57 -0
- package/src/procedures/collection/schemas.ts +110 -0
- package/src/procedures/collection/storage.ts +38 -0
- package/src/procedures/core/array.ts +27 -16
- package/src/procedures/core/index.ts +8 -0
- package/src/procedures/core/meta.ts +2 -1
- package/src/procedures/core/object.ts +48 -14
- package/src/procedures/core/operands.ts +7 -0
- package/src/procedures/core/string.ts +4 -2
- package/src/procedures/define-procedure.ts +82 -24
- package/src/procedures/effects.ts +35 -0
- package/src/procedures/idempotency.ts +152 -0
- package/src/procedures/index.ts +18 -2
- package/src/procedures/interceptors.ts +28 -11
- package/src/procedures/invoke.ts +19 -5
- package/src/procedures/manifest.ts +158 -0
- package/src/procedures/runtime-programs.ts +27 -0
- package/src/procedures/types.ts +20 -1
- package/src/protocol.ts +97 -0
- package/src/server/host-policy.ts +8 -5
- package/src/server/host-procedures.ts +102 -0
- package/src/server/index.ts +5 -2
- package/src/server/procedure-server.ts +324 -10
- package/src/server/request-auth.ts +35 -0
- package/src/server/types.ts +37 -0
- package/src/timers.ts +82 -0
- package/src/tracing/traceparent.ts +61 -0
|
@@ -5,9 +5,14 @@
|
|
|
5
5
|
* Works with any transport!
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { ClientRunner, ClientContext, ResponseItem, TypedClientMiddleware } from "../types.js";
|
|
8
|
+
import type { ClientRunner, ClientContext, Method, ResponseItem, TypedClientMiddleware } from "../types.js";
|
|
9
9
|
import type { RetryContext } from "./contexts.js";
|
|
10
|
-
import { abortedItem, sleep, thrownItem } from "./items.js";
|
|
10
|
+
import { abortedItem, deliveryOf, retryAfterOf, sleep, thrownItem } from "./items.js";
|
|
11
|
+
import { realClock, type Clock } from "../../clock.js";
|
|
12
|
+
import { backoffDelay } from "@mark1russell7/service/backoff";
|
|
13
|
+
import { IDEMPOTENCY_KEY_METADATA, idempotencyKeyOf, newIdempotencyKey } from "../../procedures/idempotency.js";
|
|
14
|
+
import { isDuration, optionalMs, type Duration } from "../../duration.js";
|
|
15
|
+
import { TIMER_DEFAULTS } from "../../timers.js";
|
|
11
16
|
|
|
12
17
|
/**
|
|
13
18
|
* Retry middleware options.
|
|
@@ -20,11 +25,17 @@ export interface RetryOptions {
|
|
|
20
25
|
maxRetries?: number;
|
|
21
26
|
|
|
22
27
|
/**
|
|
23
|
-
*
|
|
28
|
+
* The clock of the backoff and of the budget (roadmap R35). The default is the clock of the
|
|
29
|
+
* platform. A test gives a fake clock.
|
|
30
|
+
*/
|
|
31
|
+
clock?: Clock;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Base delay for exponential backoff, for example `ms(500)`.
|
|
24
35
|
* Actual delay will be: baseDelay * 2^attempt + jitter
|
|
25
|
-
* @default
|
|
36
|
+
* @default 1 second
|
|
26
37
|
*/
|
|
27
|
-
retryDelay?:
|
|
38
|
+
retryDelay?: Duration;
|
|
28
39
|
|
|
29
40
|
/**
|
|
30
41
|
* The factor of the delay from one attempt to the next.
|
|
@@ -33,21 +44,47 @@ export interface RetryOptions {
|
|
|
33
44
|
backoffMultiplier?: number;
|
|
34
45
|
|
|
35
46
|
/**
|
|
36
|
-
* The longest delay between two attempts
|
|
47
|
+
* The longest delay between two attempts.
|
|
37
48
|
* @default no limit
|
|
38
49
|
*/
|
|
39
|
-
maxDelay?:
|
|
50
|
+
maxDelay?: Duration;
|
|
40
51
|
|
|
41
52
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
53
|
+
* The random part of each delay, from 0 to 1. With 1 (full jitter), the delay is a random time
|
|
54
|
+
* from 0 to the backoff, so the clients that failed together do not retry together. 0 gives the
|
|
55
|
+
* backoff exactly.
|
|
56
|
+
* @default 1 (full jitter; roadmap R12: before, 0.1 gave a delay within 10 percent of the backoff)
|
|
45
57
|
*/
|
|
46
58
|
jitter?: number;
|
|
47
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The retry budget of this middleware: each call adds `ratio` of a retry to the budget, and each
|
|
62
|
+
* retry takes one. When the budget is empty, an error goes to the caller without a retry, so a
|
|
63
|
+
* failing service gets at most `ratio` more requests, not `maxRetries` times more. Besides the
|
|
64
|
+
* budget, `minPerSecond` retries a second are always allowed. `false` turns the budget off.
|
|
65
|
+
* @default { ratio: 0.2, minPerSecond: 10 }
|
|
66
|
+
*/
|
|
67
|
+
budget?: { ratio?: number; minPerSecond?: number } | false;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The calls that the middleware can send again after a try with an indeterminate outcome: the
|
|
71
|
+
* request left, and no answer came (a reset, a closed connection, the timeout of one try). The
|
|
72
|
+
* server can have run such a call, so only an idempotent call goes again (roadmap R1).
|
|
73
|
+
*
|
|
74
|
+
* - a function: true for an idempotent method. `idempotentIn(registry)` reads the effects of
|
|
75
|
+
* the procedures that the client knows.
|
|
76
|
+
* - `true`: every call. Use it only with a server that removes duplicate calls by their
|
|
77
|
+
* idempotency key (`ProcedureServer` does).
|
|
78
|
+
* - absent: only a call with `retry: { idempotent: true }` in its context.
|
|
79
|
+
*
|
|
80
|
+
* A try that did not leave the client (a refused connection) and an error that the server
|
|
81
|
+
* marks retryable go again in each case.
|
|
82
|
+
*/
|
|
83
|
+
idempotent?: boolean | ((method: Method) => boolean | undefined);
|
|
84
|
+
|
|
48
85
|
/**
|
|
49
86
|
* Custom function to determine if a request should be retried.
|
|
50
|
-
* Takes precedence over Status.retryable flag.
|
|
87
|
+
* Takes precedence over Status.retryable flag. The rule of `idempotent` still applies.
|
|
51
88
|
*
|
|
52
89
|
* @param item - The response item
|
|
53
90
|
* @param attempt - Current attempt number (0-indexed)
|
|
@@ -96,7 +133,7 @@ export interface RetryOptions {
|
|
|
96
133
|
* @example
|
|
97
134
|
* ```typescript
|
|
98
135
|
* // Create middleware with defaults
|
|
99
|
-
* client.use(createRetryMiddleware({ maxRetries: 3 }));
|
|
136
|
+
* client.use(createRetryMiddleware({ maxRetries: 3, retryDelay: ms(500) }));
|
|
100
137
|
*
|
|
101
138
|
* // Override per-call via context
|
|
102
139
|
* await client.call(method, payload, {
|
|
@@ -105,54 +142,121 @@ export interface RetryOptions {
|
|
|
105
142
|
* ```
|
|
106
143
|
*/
|
|
107
144
|
export function createRetryMiddleware(options: RetryOptions = {}): TypedClientMiddleware<RetryContext, {}> {
|
|
145
|
+
const defaultRetryDelay = optionalMs(options.retryDelay, "retryDelay") ?? TIMER_DEFAULTS.retry.retryDelayMs;
|
|
108
146
|
const {
|
|
109
|
-
|
|
110
|
-
jitter: defaultJitter = 0.1,
|
|
147
|
+
jitter: defaultJitter = 1,
|
|
111
148
|
shouldRetry: customShouldRetry,
|
|
112
149
|
onBeforeRetry,
|
|
113
150
|
onAfterRetry,
|
|
151
|
+
idempotent: idempotentOption,
|
|
114
152
|
} = options;
|
|
115
|
-
const defaultMaxRetries = options.maxRetries ??
|
|
116
|
-
const defaultMultiplier = options.backoffMultiplier ??
|
|
117
|
-
const defaultMaxDelay = options.maxDelay ??
|
|
153
|
+
const defaultMaxRetries = options.maxRetries ?? TIMER_DEFAULTS.retry.maxRetries;
|
|
154
|
+
const defaultMultiplier = options.backoffMultiplier ?? TIMER_DEFAULTS.retry.backoffMultiplier;
|
|
155
|
+
const defaultMaxDelay = optionalMs(options.maxDelay, "maxDelay") ?? TIMER_DEFAULTS.retry.maxDelayMs;
|
|
118
156
|
|
|
119
|
-
/**
|
|
120
|
-
|
|
157
|
+
/**
|
|
158
|
+
* The overrides of one call (`retry: { ... }` in its context), with the delays in milliseconds.
|
|
159
|
+
* Before, only maxAttempts was read.
|
|
160
|
+
*/
|
|
161
|
+
const overridesOf = (value: unknown): { maxAttempts?: number; delay?: number; backoffMultiplier?: number; maxDelay?: number } => {
|
|
121
162
|
if (typeof value !== "object" || value === null) return {};
|
|
122
|
-
const out:
|
|
123
|
-
for (const key of ["maxAttempts", "
|
|
163
|
+
const out: { maxAttempts?: number; delay?: number; backoffMultiplier?: number; maxDelay?: number } = {};
|
|
164
|
+
for (const key of ["maxAttempts", "backoffMultiplier"] as const) {
|
|
124
165
|
const field = (value as Record<string, unknown>)[key];
|
|
125
166
|
if (typeof field === "number" && Number.isFinite(field) && field >= 0) out[key] = field;
|
|
126
167
|
}
|
|
168
|
+
for (const key of ["delay", "maxDelay"] as const) {
|
|
169
|
+
const field = (value as Record<string, unknown>)[key];
|
|
170
|
+
if (field !== undefined) {
|
|
171
|
+
if (!isDuration(field)) throw new TypeError(`retry.${key} must be a duration such as ms(500) (roadmap R11)`);
|
|
172
|
+
out[key] = optionalMs(field, `retry.${key}`)!;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
127
175
|
return out;
|
|
128
176
|
};
|
|
129
177
|
|
|
130
|
-
/**
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
178
|
+
/**
|
|
179
|
+
* The backoff delay of an attempt (from 0): exponential, at most the maximum delay, with the
|
|
180
|
+
* random part of `jitter` (full jitter by default). The backoff is `backoffDelay` of
|
|
181
|
+
* `@mark1russell7/service/backoff` (roadmap R37), whose tries start at 1.
|
|
182
|
+
*/
|
|
183
|
+
const retryDelayOf = (attempt: number, overrides: ReturnType<typeof overridesOf>): number =>
|
|
184
|
+
backoffDelay(
|
|
185
|
+
{
|
|
186
|
+
base: overrides.delay ?? defaultRetryDelay,
|
|
187
|
+
factor: overrides.backoffMultiplier ?? defaultMultiplier,
|
|
188
|
+
cap: overrides.maxDelay ?? defaultMaxDelay,
|
|
189
|
+
jitter: { kind: "subtract", fraction: Math.min(1, Math.max(0, defaultJitter)) },
|
|
190
|
+
},
|
|
191
|
+
attempt + 1,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// The retry budget (R12): a balance of retries that each call fills by `ratio`
|
|
195
|
+
const budget = options.budget === false ? undefined : { ratio: options.budget?.ratio ?? 0.2, minPerSecond: options.budget?.minPerSecond ?? 10 };
|
|
196
|
+
const budgetCap = budget ? Math.max(1, budget.ratio * 100) : 0;
|
|
197
|
+
let balance = budgetCap;
|
|
198
|
+
const clock = options.clock ?? realClock;
|
|
199
|
+
let secondStart = clock.now();
|
|
200
|
+
let retriesThisSecond = 0;
|
|
201
|
+
/** This function takes one retry from the budget, or gives false when the budget is empty. */
|
|
202
|
+
const takeRetry = (): boolean => {
|
|
203
|
+
if (!budget) return true;
|
|
204
|
+
if (balance >= 1) {
|
|
205
|
+
balance -= 1;
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
const now = clock.now();
|
|
209
|
+
if (now - secondStart >= 1000) {
|
|
210
|
+
secondStart = now;
|
|
211
|
+
retriesThisSecond = 0;
|
|
212
|
+
}
|
|
213
|
+
if (retriesThisSecond < budget.minPerSecond) {
|
|
214
|
+
retriesThisSecond++;
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
return false;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/** True when the call can go again after an indeterminate try: the call says so, or the option. */
|
|
221
|
+
const isIdempotent = (method: Method, value: unknown): boolean => {
|
|
222
|
+
const own = typeof value === "object" && value !== null ? (value as { idempotent?: unknown }).idempotent : undefined;
|
|
223
|
+
if (typeof own === "boolean") return own;
|
|
224
|
+
if (typeof idempotentOption === "function") return idempotentOption(method) === true;
|
|
225
|
+
return idempotentOption === true;
|
|
135
226
|
};
|
|
136
227
|
|
|
137
228
|
return <TReq, TRes>(next: ClientRunner<TReq, TRes>): ClientRunner<TReq, TRes> => {
|
|
138
229
|
return async function* (context: ClientContext<TReq>) {
|
|
139
230
|
// Read from metadata first (user-provided context), then fall back to options
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
231
|
+
const given = context.message.metadata.retry;
|
|
232
|
+
const overrides = overridesOf(given);
|
|
233
|
+
// maxAttempts counts the attempts: the first try and the retries (R12: before, the override
|
|
234
|
+
// was used as the number of retries, against its name and its documentation)
|
|
235
|
+
const maxRetries = overrides.maxAttempts !== undefined ? Math.max(0, overrides.maxAttempts - 1) : defaultMaxRetries;
|
|
236
|
+
if (budget) balance = Math.min(budgetCap, balance + budget.ratio);
|
|
237
|
+
const idempotent = isIdempotent(context.message.method, context.message.metadata.retry);
|
|
238
|
+
const shouldRetryItem = (item: ResponseItem<TRes>, attempt: number): boolean => {
|
|
239
|
+
const wanted = customShouldRetry
|
|
144
240
|
? customShouldRetry(item, attempt)
|
|
145
241
|
: item.status.type === "error" && item.status.retryable;
|
|
242
|
+
// A request that can have run goes again only when the call is idempotent (R1). Before,
|
|
243
|
+
// a reset or a timeout of one try sent a write again.
|
|
244
|
+
return wanted && (deliveryOf(item) !== "indeterminate" || idempotent);
|
|
245
|
+
};
|
|
146
246
|
const signal = context.message.signal;
|
|
147
247
|
const baseId = context.message.id;
|
|
248
|
+
// One key for all the tries of the call: a server that removes duplicates runs it one time
|
|
249
|
+
const idempotencyKey = idempotencyKeyOf(context.message.metadata) ?? newIdempotencyKey();
|
|
250
|
+
context.message.metadata[IDEMPOTENCY_KEY_METADATA] = idempotencyKey;
|
|
148
251
|
|
|
149
252
|
let attempt = 0;
|
|
150
253
|
for (;;) {
|
|
151
254
|
// Update retry metadata with current state
|
|
152
255
|
context.message.metadata.retry = {
|
|
153
|
-
...
|
|
256
|
+
...(typeof given === "object" && given !== null ? (given as object) : {}),
|
|
154
257
|
attempt,
|
|
155
|
-
maxAttempts: maxRetries,
|
|
258
|
+
maxAttempts: maxRetries + 1,
|
|
259
|
+
idempotent,
|
|
156
260
|
};
|
|
157
261
|
// Each attempt gets its own request id. The id correlates the frames of one attempt
|
|
158
262
|
// on the wire: a cancelled attempt can still answer under its id (deep dive TRN-8/9).
|
|
@@ -204,7 +308,15 @@ export function createRetryMiddleware(options: RetryOptions = {}): TypedClientMi
|
|
|
204
308
|
return;
|
|
205
309
|
}
|
|
206
310
|
|
|
207
|
-
|
|
311
|
+
// The budget is empty: the error goes to the caller (R12)
|
|
312
|
+
if (!takeRetry()) {
|
|
313
|
+
yield retryItem;
|
|
314
|
+
if (onAfterRetry) await onAfterRetry(false, attempt);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// A server that asks for a wait (Retry-After) gets at least that wait (R12)
|
|
319
|
+
let delay = Math.max(retryDelayOf(attempt, overrides), retryAfterOf(retryItem) ?? 0);
|
|
208
320
|
|
|
209
321
|
// Before retry hook
|
|
210
322
|
if (onBeforeRetry) {
|
|
@@ -225,7 +337,7 @@ export function createRetryMiddleware(options: RetryOptions = {}): TypedClientMi
|
|
|
225
337
|
}
|
|
226
338
|
|
|
227
339
|
// The backoff stops at once when the caller aborts (deep dive TRN-8)
|
|
228
|
-
if (!(await sleep(delay, signal))) {
|
|
340
|
+
if (!(await sleep(delay, signal, clock))) {
|
|
229
341
|
yield abortedItem<TRes>(context.message.id);
|
|
230
342
|
return;
|
|
231
343
|
}
|
|
@@ -5,26 +5,29 @@
|
|
|
5
5
|
* Works with any transport!
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type {
|
|
9
|
-
import { ABORTED_FIRST, errorItem, nextOrAbort } from "./items.js";
|
|
8
|
+
import type { ClientRunner, ClientContext, ResponseItem, TypedClientMiddleware } from "../types.js";
|
|
9
|
+
import { ABORTED_FIRST, abortedItem, errorItem, nextOrAbort, withDelivery } from "./items.js";
|
|
10
10
|
import type { TimeoutContext } from "./contexts.js";
|
|
11
|
+
import { deadlineOf, narrowDeadline, restoreDeadline } from "../deadline.js";
|
|
12
|
+
import { resilience } from "./resilience.js";
|
|
13
|
+
import { optionalMs, type Duration } from "../../duration.js";
|
|
11
14
|
|
|
12
15
|
/**
|
|
13
16
|
* Timeout middleware options.
|
|
14
17
|
*/
|
|
15
18
|
export interface TimeoutOptions {
|
|
16
19
|
/**
|
|
17
|
-
*
|
|
20
|
+
* The time of the entire request (including retries), for example `seconds(10)`.
|
|
18
21
|
* @default undefined (no overall timeout)
|
|
19
22
|
*/
|
|
20
|
-
overall?:
|
|
23
|
+
overall?: Duration | undefined;
|
|
21
24
|
|
|
22
25
|
/**
|
|
23
|
-
*
|
|
26
|
+
* The time of each individual attempt, for example `seconds(2)`.
|
|
24
27
|
* Useful with retry middleware for per-attempt timeouts.
|
|
25
28
|
* @default undefined (no per-attempt timeout)
|
|
26
29
|
*/
|
|
27
|
-
perAttempt?:
|
|
30
|
+
perAttempt?: Duration | undefined;
|
|
28
31
|
|
|
29
32
|
/**
|
|
30
33
|
* Custom timeout error message.
|
|
@@ -89,17 +92,18 @@ function composeAbortSignals(...signals: (AbortSignal | undefined)[]): {
|
|
|
89
92
|
}
|
|
90
93
|
}
|
|
91
94
|
|
|
92
|
-
// Fallback: Manual composition for older environments
|
|
95
|
+
// Fallback: Manual composition for older environments. The composed signal keeps the reason of
|
|
96
|
+
// the signal that aborted, as with AbortSignal.any() (before, it got a new AbortError).
|
|
93
97
|
const controller = new AbortController();
|
|
94
98
|
const abortListeners: Array<() => void> = [];
|
|
95
99
|
|
|
96
100
|
for (const signal of validSignals) {
|
|
97
101
|
if (signal.aborted) {
|
|
98
|
-
controller.abort();
|
|
102
|
+
controller.abort(signal.reason);
|
|
99
103
|
break;
|
|
100
104
|
}
|
|
101
105
|
|
|
102
|
-
const listener = () => controller.abort();
|
|
106
|
+
const listener = () => controller.abort(signal.reason);
|
|
103
107
|
signal.addEventListener("abort", listener, { once: true });
|
|
104
108
|
abortListeners.push(() => signal.removeEventListener("abort", listener));
|
|
105
109
|
}
|
|
@@ -116,7 +120,8 @@ function composeAbortSignals(...signals: (AbortSignal | undefined)[]): {
|
|
|
116
120
|
/**
|
|
117
121
|
* Run `next` with a timeout. When the time runs out, the request's signal aborts, and the
|
|
118
122
|
* reader gets one TIMEOUT error item in place of the transport's ABORTED item or thrown error.
|
|
119
|
-
* The middleware does not wait for a transport that ignores the signal
|
|
123
|
+
* The middleware does not wait for a transport that ignores the signal: not after the timeout,
|
|
124
|
+
* and not after an abort of the caller, which gives an ABORTED item.
|
|
120
125
|
*
|
|
121
126
|
* Before, the middleware waited for a thrown error, but the transports yield error items: the
|
|
122
127
|
* reader got "ABORTED" (not retryable), so a per-attempt timeout with retry never retried, and
|
|
@@ -131,25 +136,34 @@ async function* runWithTimeout<TReq, TRes>(
|
|
|
131
136
|
): AsyncGenerator<ResponseItem<TRes>, void, undefined> {
|
|
132
137
|
const timeoutController = new AbortController();
|
|
133
138
|
const timeoutId = setTimeout(() => timeoutController.abort(), ms);
|
|
139
|
+
// The overall timeout is the deadline of the call: the transport sends its remaining time to
|
|
140
|
+
// the server (roadmap R10). The timeout of one try is not: the server keeps a keyed write
|
|
141
|
+
// until the next try can get its result, and the request signal stops another call.
|
|
142
|
+
const deadlineBefore = retryable ? deadlineOf(context.message.metadata) : narrowDeadline(context.message.metadata, Date.now() + ms);
|
|
134
143
|
|
|
135
144
|
// Compose with existing signal
|
|
136
145
|
const originalSignal = context.message.signal;
|
|
137
146
|
const { controller: composedController, cleanup } = composeAbortSignals(originalSignal, timeoutController.signal);
|
|
138
147
|
context.message.signal = composedController.signal;
|
|
139
148
|
const timedOut = (): boolean => timeoutController.signal.aborted && !originalSignal?.aborted;
|
|
140
|
-
|
|
149
|
+
// The request left before the time ran out: the server can still run it (R1)
|
|
150
|
+
const timeoutItem = (): ResponseItem<TRes> =>
|
|
151
|
+
withDelivery(errorItem<TRes>(context.message.id, "TIMEOUT", message, retryable), "indeterminate");
|
|
141
152
|
|
|
142
153
|
const iterator = next(context)[Symbol.asyncIterator]();
|
|
143
154
|
// "running": a next() may be pending; "done": the iterator ended; "abandoned": the timeout won
|
|
144
155
|
let phase: "running" | "done" | "abandoned" = "running";
|
|
145
156
|
try {
|
|
146
157
|
for (;;) {
|
|
147
|
-
|
|
158
|
+
// The timeout and the abort of the caller both end the wait (roadmap R30: before, the wait
|
|
159
|
+
// ignored the abort of the caller, so a transport that ignored the signal kept the caller
|
|
160
|
+
// waiting until the timeout, and the caller got TIMEOUT for its own abort)
|
|
161
|
+
const result = await nextOrAbort(iterator, composedController.signal);
|
|
148
162
|
if (result === ABORTED_FIRST) {
|
|
149
163
|
phase = "abandoned";
|
|
150
|
-
// The transport did not stop in time: end it in the background, and give the timeout
|
|
164
|
+
// The transport did not stop in time: end it in the background, and give the timeout or the abort
|
|
151
165
|
void Promise.resolve(iterator.return?.()).catch(() => undefined);
|
|
152
|
-
yield timeoutItem();
|
|
166
|
+
yield timedOut() ? timeoutItem() : abortedItem<TRes>(context.message.id);
|
|
153
167
|
return;
|
|
154
168
|
}
|
|
155
169
|
if (result.done) {
|
|
@@ -172,6 +186,7 @@ async function* runWithTimeout<TReq, TRes>(
|
|
|
172
186
|
throw error;
|
|
173
187
|
} finally {
|
|
174
188
|
clearTimeout(timeoutId);
|
|
189
|
+
restoreDeadline(context.message.metadata, deadlineBefore);
|
|
175
190
|
cleanup();
|
|
176
191
|
// Restore the caller's original signal; we only swapped in the composed signal for this
|
|
177
192
|
// attempt. Leaving the (possibly aborted) composed signal in place poisons subsequent
|
|
@@ -200,22 +215,24 @@ async function* runWithTimeout<TReq, TRes>(
|
|
|
200
215
|
* @example
|
|
201
216
|
* ```typescript
|
|
202
217
|
* // Create middleware with default timeout
|
|
203
|
-
* client.use(createOverallTimeoutMiddleware({ overall:
|
|
218
|
+
* client.use(createOverallTimeoutMiddleware({ overall: seconds(5) }));
|
|
204
219
|
*
|
|
205
220
|
* // Override per-call via context
|
|
206
221
|
* await client.call(method, payload, {
|
|
207
|
-
* context: { timeout: { overall:
|
|
222
|
+
* context: { timeout: { overall: seconds(10) } }
|
|
208
223
|
* });
|
|
209
224
|
* ```
|
|
210
225
|
*/
|
|
211
226
|
export function createOverallTimeoutMiddleware(options: Pick<TimeoutOptions, "overall" | "message">): TypedClientMiddleware<TimeoutContext, {}> {
|
|
212
|
-
const {
|
|
227
|
+
const { message = "Overall request timeout" } = options;
|
|
228
|
+
const defaultOverall = optionalMs(options.overall, "overall");
|
|
213
229
|
|
|
214
230
|
return <TReq, TRes>(next: ClientRunner<TReq, TRes>): ClientRunner<TReq, TRes> => {
|
|
215
231
|
return async function* (context: ClientContext<TReq>) {
|
|
216
232
|
// Read from metadata first (user-provided context), then fall back to options
|
|
217
|
-
const overall =
|
|
218
|
-
??
|
|
233
|
+
const overall =
|
|
234
|
+
optionalMs((context.message.metadata.timeout as TimeoutContext["timeout"] | undefined)?.overall, "timeout.overall") ??
|
|
235
|
+
defaultOverall;
|
|
219
236
|
|
|
220
237
|
if (!overall) {
|
|
221
238
|
// No timeout - passthrough
|
|
@@ -243,22 +260,24 @@ export function createOverallTimeoutMiddleware(options: Pick<TimeoutOptions, "ov
|
|
|
243
260
|
* @example
|
|
244
261
|
* ```typescript
|
|
245
262
|
* // Create middleware with default timeout
|
|
246
|
-
* client.use(createTimeoutMiddleware({ perAttempt:
|
|
263
|
+
* client.use(createTimeoutMiddleware({ perAttempt: seconds(1) }));
|
|
247
264
|
*
|
|
248
265
|
* // Override per-call via context
|
|
249
266
|
* await client.call(method, payload, {
|
|
250
|
-
* context: { timeout: { perAttempt:
|
|
267
|
+
* context: { timeout: { perAttempt: seconds(2) } }
|
|
251
268
|
* });
|
|
252
269
|
* ```
|
|
253
270
|
*/
|
|
254
271
|
export function createTimeoutMiddleware(options: Pick<TimeoutOptions, "perAttempt" | "message">): TypedClientMiddleware<TimeoutContext, {}> {
|
|
255
|
-
const {
|
|
272
|
+
const { message = "Request timeout" } = options;
|
|
273
|
+
const defaultPerAttempt = optionalMs(options.perAttempt, "perAttempt");
|
|
256
274
|
|
|
257
275
|
return <TReq, TRes>(next: ClientRunner<TReq, TRes>): ClientRunner<TReq, TRes> => {
|
|
258
276
|
return async function* (context: ClientContext<TReq>) {
|
|
259
277
|
// Read from metadata first (user-provided context), then fall back to options
|
|
260
|
-
const perAttempt =
|
|
261
|
-
??
|
|
278
|
+
const perAttempt =
|
|
279
|
+
optionalMs((context.message.metadata.timeout as TimeoutContext["timeout"] | undefined)?.perAttempt, "timeout.perAttempt") ??
|
|
280
|
+
defaultPerAttempt;
|
|
262
281
|
|
|
263
282
|
if (!perAttempt) {
|
|
264
283
|
// No timeout - passthrough
|
|
@@ -266,8 +285,12 @@ export function createTimeoutMiddleware(options: Pick<TimeoutOptions, "perAttemp
|
|
|
266
285
|
return;
|
|
267
286
|
}
|
|
268
287
|
|
|
288
|
+
// The limit of one try is at most the remaining time of the call (roadmap R10)
|
|
289
|
+
const deadline = deadlineOf(context.message.metadata);
|
|
290
|
+
const limit = deadline === undefined ? perAttempt : Math.max(0, Math.min(perAttempt, deadline - Date.now()));
|
|
291
|
+
|
|
269
292
|
// A retry middleware outside this one can start a new attempt
|
|
270
|
-
yield* runWithTimeout(next, context,
|
|
293
|
+
yield* runWithTimeout(next, context, limit, message, true);
|
|
271
294
|
};
|
|
272
295
|
};
|
|
273
296
|
}
|
|
@@ -275,34 +298,20 @@ export function createTimeoutMiddleware(options: Pick<TimeoutOptions, "perAttemp
|
|
|
275
298
|
/**
|
|
276
299
|
* Create combined timeout middleware with both overall and per-attempt timeouts.
|
|
277
300
|
*
|
|
301
|
+
* @deprecated Use `resilience({ overall, perAttempt, retry })`. This function puts the two
|
|
302
|
+
* timeouts in one slot, so a retry middleware that you add with another `use()` cannot sit
|
|
303
|
+
* between them: the retry is outside both, or inside both. `resilience` puts the retry between
|
|
304
|
+
* them (roadmap R10).
|
|
305
|
+
*
|
|
278
306
|
* @param options - Timeout configuration
|
|
279
307
|
* @returns Middleware function
|
|
280
308
|
*
|
|
281
309
|
* @example
|
|
282
310
|
* ```typescript
|
|
283
|
-
* client.use(
|
|
284
|
-
* overall: 5000, // 5 seconds total
|
|
285
|
-
* perAttempt: 1000 // 1 second per attempt
|
|
286
|
-
* }));
|
|
311
|
+
* client.use(resilience({ overall: seconds(5), perAttempt: seconds(1), retry: { maxRetries: 2 } }));
|
|
287
312
|
* ```
|
|
288
313
|
*/
|
|
289
314
|
export function createCombinedTimeoutMiddleware(options: TimeoutOptions): TypedClientMiddleware<TimeoutContext, {}> {
|
|
290
315
|
const { overall, perAttempt, message = "Request timeout" } = options;
|
|
291
|
-
|
|
292
|
-
return <TReq, TRes>(next: ClientRunner<TReq, TRes>): ClientRunner<TReq, TRes> => {
|
|
293
|
-
// Compose both timeout middlewares
|
|
294
|
-
let composed: ClientRunner<TReq, TRes> = next;
|
|
295
|
-
|
|
296
|
-
if (perAttempt) {
|
|
297
|
-
const perAttemptMiddleware = createTimeoutMiddleware({ perAttempt, message: `${message} (per-attempt)` }) as ClientMiddleware<TReq, TRes>;
|
|
298
|
-
composed = perAttemptMiddleware(composed);
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
if (overall) {
|
|
302
|
-
const overallMiddleware = createOverallTimeoutMiddleware({ overall, message: `${message} (overall)` }) as ClientMiddleware<TReq, TRes>;
|
|
303
|
-
composed = overallMiddleware(composed);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
return composed;
|
|
307
|
-
};
|
|
316
|
+
return resilience({ overall, perAttempt, message }) as TypedClientMiddleware<TimeoutContext, {}>;
|
|
308
317
|
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import type { TypedClientMiddleware } from "../types.js";
|
|
16
16
|
import type { TracingContext } from "./contexts.js";
|
|
17
|
+
import { TRACEPARENT_METADATA_KEY, formatTraceparent, isW3cIds, newSpanId, newTraceId, parseTraceparent } from "../../tracing/traceparent.js";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Tracing Configuration
|
|
@@ -98,9 +99,10 @@ function generateId(): string {
|
|
|
98
99
|
export function createTracingMiddleware(
|
|
99
100
|
options: TracingOptions = {}
|
|
100
101
|
): TypedClientMiddleware<TracingContext, {}> {
|
|
102
|
+
// W3C ids by default, so the server continues the trace (roadmap R16)
|
|
101
103
|
const {
|
|
102
|
-
generateTraceId =
|
|
103
|
-
generateSpanId =
|
|
104
|
+
generateTraceId = newTraceId,
|
|
105
|
+
generateSpanId = newSpanId,
|
|
104
106
|
getCurrentTrace,
|
|
105
107
|
serviceName,
|
|
106
108
|
} = options;
|
|
@@ -108,8 +110,9 @@ export function createTracingMiddleware(
|
|
|
108
110
|
return (next) => async function* (context) {
|
|
109
111
|
// Check if there's already tracing metadata
|
|
110
112
|
if (!context.message.metadata.tracing) {
|
|
111
|
-
// Try to get current trace context
|
|
112
|
-
|
|
113
|
+
// Try to get current trace context: the function of the options, or the trace context of
|
|
114
|
+
// the caller (a call that a handler makes on a server carries the span of that handler)
|
|
115
|
+
const currentTrace = getCurrentTrace?.() ?? parseTraceparent(context.message.metadata[TRACEPARENT_METADATA_KEY]) ?? null;
|
|
113
116
|
|
|
114
117
|
if (currentTrace) {
|
|
115
118
|
// Continue existing trace (create child span)
|
|
@@ -132,6 +135,12 @@ export function createTracingMiddleware(
|
|
|
132
135
|
}
|
|
133
136
|
}
|
|
134
137
|
|
|
138
|
+
// The W3C trace context of the call: the server continues this trace (R16)
|
|
139
|
+
const tracing = context.message.metadata.tracing;
|
|
140
|
+
if (tracing && isW3cIds(tracing.traceId, tracing.spanId)) {
|
|
141
|
+
context.message.metadata[TRACEPARENT_METADATA_KEY] = formatTraceparent(tracing.traceId, tracing.spanId);
|
|
142
|
+
}
|
|
143
|
+
|
|
135
144
|
// Continue middleware chain
|
|
136
145
|
yield* next(context);
|
|
137
146
|
};
|
package/src/client/types.ts
CHANGED
|
@@ -26,7 +26,13 @@ export interface Method {
|
|
|
26
26
|
/** Operation name (e.g., "get", "create", "update", "delete", "watch") */
|
|
27
27
|
operation: string;
|
|
28
28
|
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Optional version (e.g., "v1", "v2").
|
|
31
|
+
*
|
|
32
|
+
* @deprecated The wire protocol (v1, roadmap R25) has no version of a procedure: the server
|
|
33
|
+
* ignores this field. A URL strategy can put it in the path. Use a new path for a procedure
|
|
34
|
+
* that changes in a way that is not compatible.
|
|
35
|
+
*/
|
|
30
36
|
version?: string;
|
|
31
37
|
}
|
|
32
38
|
|
|
@@ -66,6 +72,8 @@ export interface Metadata {
|
|
|
66
72
|
retry?: {
|
|
67
73
|
attempt: number;
|
|
68
74
|
maxAttempts: number;
|
|
75
|
+
/** True: a second run of the call changes nothing more, so a retry is safe */
|
|
76
|
+
idempotent?: boolean;
|
|
69
77
|
};
|
|
70
78
|
|
|
71
79
|
/** Extensible for custom fields */
|
package/src/clock.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The clock of the core (roadmap R35). The retry, the circuit breaker and the WebSocket client
|
|
3
|
+
* read the time and start their timers only through a clock. Thus a test gives a fake clock, and
|
|
4
|
+
* the timing is exact and repeatable.
|
|
5
|
+
*
|
|
6
|
+
* The clock comes from `@mark1russell7/service/clock` (roadmap R37). The entry imports nothing
|
|
7
|
+
* else, and it has 228 bytes of gzip. The `fakeClock` of `@mark1russell7/service/testing` fits here.
|
|
8
|
+
* A `Cancel` of the platform also has `unref`. The circuit breaker uses it for its reset: a breaker
|
|
9
|
+
* must not keep a process alive for its reset time (BUGS-2026-07 M4).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { platformClock, type Cancel, type Clock } from "@mark1russell7/service/clock";
|
|
13
|
+
|
|
14
|
+
export type { Cancel, Clock };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The clock of the platform: `performance.now()` and `setTimeout`. Its timers keep a Node process
|
|
18
|
+
* alive, as `setTimeout` does: a script that waits for a retry does not end during the wait. The
|
|
19
|
+
* `realClock` of the service package is different: its timers do not keep a process alive.
|
|
20
|
+
*/
|
|
21
|
+
export const realClock: Clock = platformClock();
|
package/src/duration.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed durations (roadmap R11).
|
|
3
|
+
*
|
|
4
|
+
* Each public option of this package that is a time takes a `Duration`: a quantity of
|
|
5
|
+
* `@mark1russell7/quantity` with a time unit. A plain number is a type error, so `timeout: 5`
|
|
6
|
+
* cannot mean five milliseconds in one place and five seconds in another. The wire keeps
|
|
7
|
+
* milliseconds (`procedure-timeout`), and the internals convert each duration one time, when an
|
|
8
|
+
* option is read.
|
|
9
|
+
*
|
|
10
|
+
* `@mark1russell7/quantity` has no `Duration` type yet. This alias narrows its `Quantity` to the
|
|
11
|
+
* time units, so the type also refuses `{ value: 5, unit: "kg" }`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { quantity, valueIn, type Quantity } from "@mark1russell7/quantity";
|
|
15
|
+
|
|
16
|
+
/** The canonical time units of `@mark1russell7/quantity` that a duration of this package uses. */
|
|
17
|
+
export type TimeUnit = "ns" | "us" | "ms" | "s" | "min" | "h" | "d";
|
|
18
|
+
|
|
19
|
+
/** A time: a quantity with a time unit. */
|
|
20
|
+
export type Duration = Quantity & { readonly unit: TimeUnit };
|
|
21
|
+
|
|
22
|
+
const TIME_UNITS: ReadonlySet<string> = new Set<TimeUnit>(["ns", "us", "ms", "s", "min", "h", "d"]);
|
|
23
|
+
|
|
24
|
+
/** This function makes a duration of milliseconds. */
|
|
25
|
+
export const ms = (value: number): Duration => quantity(value, "ms") as Duration;
|
|
26
|
+
/** This function makes a duration of seconds. */
|
|
27
|
+
export const seconds = (value: number): Duration => quantity(value, "s") as Duration;
|
|
28
|
+
/** This function makes a duration of minutes. */
|
|
29
|
+
export const minutes = (value: number): Duration => quantity(value, "min") as Duration;
|
|
30
|
+
/** This function makes a duration of hours. */
|
|
31
|
+
export const hours = (value: number): Duration => quantity(value, "h") as Duration;
|
|
32
|
+
|
|
33
|
+
/** True when a value is a duration: a finite number with a time unit. */
|
|
34
|
+
export function isDuration(value: unknown): value is Duration {
|
|
35
|
+
if (value === null || typeof value !== "object") return false;
|
|
36
|
+
const { value: amount, unit } = value as { value?: unknown; unit?: unknown };
|
|
37
|
+
return typeof amount === "number" && Number.isFinite(amount) && typeof unit === "string" && TIME_UNITS.has(unit);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The number of milliseconds of a duration. A value that is not a duration (a plain number, or
|
|
42
|
+
* a quantity of another kind) throws a `TypeError` that names the option.
|
|
43
|
+
*/
|
|
44
|
+
export function toMs(duration: Duration, option = "duration"): number {
|
|
45
|
+
if (!isDuration(duration)) {
|
|
46
|
+
throw new TypeError(
|
|
47
|
+
`${option} must be a duration such as ms(500) or seconds(2), got ${JSON.stringify(duration)}. Plain numbers are not durations (roadmap R11).`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return valueIn(duration, "ms");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The milliseconds of an optional duration, or undefined. */
|
|
54
|
+
export function optionalMs(duration: Duration | undefined, option?: string): number | undefined {
|
|
55
|
+
return duration === undefined ? undefined : toMs(duration, option);
|
|
56
|
+
}
|