@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.
Files changed (299) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +61 -38
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/adapters/http/client/transport.d.ts.map +1 -1
  5. package/dist/adapters/http/client/transport.js +35 -3
  6. package/dist/adapters/http/client/transport.js.map +1 -1
  7. package/dist/adapters/http/client/types.d.ts +3 -2
  8. package/dist/adapters/http/client/types.d.ts.map +1 -1
  9. package/dist/adapters/http/server/transport.d.ts +2 -2
  10. package/dist/adapters/http/server/transport.d.ts.map +1 -1
  11. package/dist/adapters/http/server/transport.js +53 -20
  12. package/dist/adapters/http/server/transport.js.map +1 -1
  13. package/dist/adapters/http/shared/headers.d.ts +1 -1
  14. package/dist/adapters/http/shared/headers.d.ts.map +1 -1
  15. package/dist/adapters/http/shared/headers.js +7 -6
  16. package/dist/adapters/http/shared/headers.js.map +1 -1
  17. package/dist/adapters/local/client/transport.d.ts +3 -0
  18. package/dist/adapters/local/client/transport.d.ts.map +1 -1
  19. package/dist/adapters/local/client/transport.js +63 -4
  20. package/dist/adapters/local/client/transport.js.map +1 -1
  21. package/dist/adapters/local/client/types.d.ts +15 -0
  22. package/dist/adapters/local/client/types.d.ts.map +1 -1
  23. package/dist/adapters/local/client/types.js.map +1 -1
  24. package/dist/adapters/mock/client/transport.d.ts +3 -2
  25. package/dist/adapters/mock/client/transport.d.ts.map +1 -1
  26. package/dist/adapters/mock/client/transport.js +8 -5
  27. package/dist/adapters/mock/client/transport.js.map +1 -1
  28. package/dist/adapters/mock/client/types.d.ts +7 -6
  29. package/dist/adapters/mock/client/types.d.ts.map +1 -1
  30. package/dist/adapters/port/transport.d.ts +62 -0
  31. package/dist/adapters/port/transport.d.ts.map +1 -0
  32. package/dist/adapters/port/transport.js +94 -0
  33. package/dist/adapters/port/transport.js.map +1 -0
  34. package/dist/adapters/websocket/client/index.d.ts +1 -1
  35. package/dist/adapters/websocket/client/index.d.ts.map +1 -1
  36. package/dist/adapters/websocket/client/index.js.map +1 -1
  37. package/dist/adapters/websocket/client/transport.d.ts +47 -7
  38. package/dist/adapters/websocket/client/transport.d.ts.map +1 -1
  39. package/dist/adapters/websocket/client/transport.js +213 -67
  40. package/dist/adapters/websocket/client/transport.js.map +1 -1
  41. package/dist/adapters/websocket/client/types.d.ts +70 -21
  42. package/dist/adapters/websocket/client/types.d.ts.map +1 -1
  43. package/dist/adapters/websocket/client/types.js.map +1 -1
  44. package/dist/adapters/websocket/server/transport.d.ts +65 -24
  45. package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
  46. package/dist/adapters/websocket/server/transport.js +192 -263
  47. package/dist/adapters/websocket/server/transport.js.map +1 -1
  48. package/dist/adapters/websocket/server/types.d.ts +20 -1
  49. package/dist/adapters/websocket/server/types.d.ts.map +1 -1
  50. package/dist/adapters/websocket/shared/rpc.d.ts +129 -0
  51. package/dist/adapters/websocket/shared/rpc.d.ts.map +1 -0
  52. package/dist/adapters/websocket/shared/rpc.js +425 -0
  53. package/dist/adapters/websocket/shared/rpc.js.map +1 -0
  54. package/dist/browser.d.ts +29 -7
  55. package/dist/browser.d.ts.map +1 -1
  56. package/dist/browser.js +26 -3
  57. package/dist/browser.js.map +1 -1
  58. package/dist/client/call-types.d.ts +10 -10
  59. package/dist/client/call-types.d.ts.map +1 -1
  60. package/dist/client/call-types.js +1 -6
  61. package/dist/client/call-types.js.map +1 -1
  62. package/dist/client/client.d.ts +6 -1
  63. package/dist/client/client.d.ts.map +1 -1
  64. package/dist/client/client.js +23 -9
  65. package/dist/client/client.js.map +1 -1
  66. package/dist/client/deadline.d.ts +38 -0
  67. package/dist/client/deadline.d.ts.map +1 -0
  68. package/dist/client/deadline.js +60 -0
  69. package/dist/client/deadline.js.map +1 -0
  70. package/dist/client/errors/factory.d.ts.map +1 -1
  71. package/dist/client/errors/factory.js +11 -7
  72. package/dist/client/errors/factory.js.map +1 -1
  73. package/dist/client/index.d.ts +9 -2
  74. package/dist/client/index.d.ts.map +1 -1
  75. package/dist/client/index.js +8 -2
  76. package/dist/client/index.js.map +1 -1
  77. package/dist/client/middleware/cache.d.ts +19 -5
  78. package/dist/client/middleware/cache.d.ts.map +1 -1
  79. package/dist/client/middleware/cache.js +106 -18
  80. package/dist/client/middleware/cache.js.map +1 -1
  81. package/dist/client/middleware/circuit-breaker.d.ts +28 -10
  82. package/dist/client/middleware/circuit-breaker.d.ts.map +1 -1
  83. package/dist/client/middleware/circuit-breaker.js +35 -24
  84. package/dist/client/middleware/circuit-breaker.js.map +1 -1
  85. package/dist/client/middleware/contexts.d.ts +16 -10
  86. package/dist/client/middleware/contexts.d.ts.map +1 -1
  87. package/dist/client/middleware/contexts.js.map +1 -1
  88. package/dist/client/middleware/items.d.ts +31 -2
  89. package/dist/client/middleware/items.d.ts.map +1 -1
  90. package/dist/client/middleware/items.js +86 -6
  91. package/dist/client/middleware/items.js.map +1 -1
  92. package/dist/client/middleware/lru.d.ts +2 -0
  93. package/dist/client/middleware/lru.d.ts.map +1 -1
  94. package/dist/client/middleware/lru.js +11 -0
  95. package/dist/client/middleware/lru.js.map +1 -1
  96. package/dist/client/middleware/rate-limit.d.ts +7 -6
  97. package/dist/client/middleware/rate-limit.d.ts.map +1 -1
  98. package/dist/client/middleware/rate-limit.js +9 -6
  99. package/dist/client/middleware/rate-limit.js.map +1 -1
  100. package/dist/client/middleware/resilience.d.ts +61 -0
  101. package/dist/client/middleware/resilience.d.ts.map +1 -0
  102. package/dist/client/middleware/resilience.js +59 -0
  103. package/dist/client/middleware/resilience.js.map +1 -0
  104. package/dist/client/middleware/retry.d.ts +45 -11
  105. package/dist/client/middleware/retry.d.ts.map +1 -1
  106. package/dist/client/middleware/retry.js +102 -22
  107. package/dist/client/middleware/retry.js.map +1 -1
  108. package/dist/client/middleware/timeout.d.ts +15 -12
  109. package/dist/client/middleware/timeout.d.ts.map +1 -1
  110. package/dist/client/middleware/timeout.js +46 -37
  111. package/dist/client/middleware/timeout.js.map +1 -1
  112. package/dist/client/middleware/tracing.d.ts.map +1 -1
  113. package/dist/client/middleware/tracing.js +11 -3
  114. package/dist/client/middleware/tracing.js.map +1 -1
  115. package/dist/client/types.d.ts +9 -1
  116. package/dist/client/types.d.ts.map +1 -1
  117. package/dist/client/types.js.map +1 -1
  118. package/dist/clock.d.ts +19 -0
  119. package/dist/clock.d.ts.map +1 -0
  120. package/dist/clock.js +18 -0
  121. package/dist/clock.js.map +1 -0
  122. package/dist/duration.d.ts +37 -0
  123. package/dist/duration.d.ts.map +1 -0
  124. package/dist/duration.js +44 -0
  125. package/dist/duration.js.map +1 -0
  126. package/dist/machines.d.ts +90 -0
  127. package/dist/machines.d.ts.map +1 -0
  128. package/dist/machines.js +127 -0
  129. package/dist/machines.js.map +1 -0
  130. package/dist/procedures/capability.d.ts +3 -6
  131. package/dist/procedures/capability.d.ts.map +1 -1
  132. package/dist/procedures/capability.js +11 -9
  133. package/dist/procedures/capability.js.map +1 -1
  134. package/dist/procedures/collection/index.d.ts +3 -3
  135. package/dist/procedures/collection/index.d.ts.map +1 -1
  136. package/dist/procedures/collection/index.js +1 -1
  137. package/dist/procedures/collection/index.js.map +1 -1
  138. package/dist/procedures/collection/procedures.d.ts.map +1 -1
  139. package/dist/procedures/collection/procedures.js +49 -1
  140. package/dist/procedures/collection/procedures.js.map +1 -1
  141. package/dist/procedures/collection/schemas.d.ts +33 -0
  142. package/dist/procedures/collection/schemas.d.ts.map +1 -1
  143. package/dist/procedures/collection/schemas.js +68 -0
  144. package/dist/procedures/collection/schemas.js.map +1 -1
  145. package/dist/procedures/collection/storage.d.ts +37 -0
  146. package/dist/procedures/collection/storage.d.ts.map +1 -1
  147. package/dist/procedures/core/array.d.ts.map +1 -1
  148. package/dist/procedures/core/array.js +26 -15
  149. package/dist/procedures/core/array.js.map +1 -1
  150. package/dist/procedures/core/index.d.ts.map +1 -1
  151. package/dist/procedures/core/index.js +8 -0
  152. package/dist/procedures/core/index.js.map +1 -1
  153. package/dist/procedures/core/meta.d.ts.map +1 -1
  154. package/dist/procedures/core/meta.js +2 -1
  155. package/dist/procedures/core/meta.js.map +1 -1
  156. package/dist/procedures/core/object.d.ts.map +1 -1
  157. package/dist/procedures/core/object.js +42 -13
  158. package/dist/procedures/core/object.js.map +1 -1
  159. package/dist/procedures/core/operands.d.ts.map +1 -1
  160. package/dist/procedures/core/operands.js +6 -1
  161. package/dist/procedures/core/operands.js.map +1 -1
  162. package/dist/procedures/core/string.d.ts.map +1 -1
  163. package/dist/procedures/core/string.js +4 -2
  164. package/dist/procedures/core/string.js.map +1 -1
  165. package/dist/procedures/define-procedure.d.ts +19 -7
  166. package/dist/procedures/define-procedure.d.ts.map +1 -1
  167. package/dist/procedures/define-procedure.js +66 -21
  168. package/dist/procedures/define-procedure.js.map +1 -1
  169. package/dist/procedures/effects.d.ts +16 -0
  170. package/dist/procedures/effects.d.ts.map +1 -1
  171. package/dist/procedures/effects.js +33 -0
  172. package/dist/procedures/effects.js.map +1 -1
  173. package/dist/procedures/idempotency.d.ts +73 -0
  174. package/dist/procedures/idempotency.d.ts.map +1 -0
  175. package/dist/procedures/idempotency.js +131 -0
  176. package/dist/procedures/idempotency.js.map +1 -0
  177. package/dist/procedures/index.d.ts +7 -3
  178. package/dist/procedures/index.d.ts.map +1 -1
  179. package/dist/procedures/index.js +6 -2
  180. package/dist/procedures/index.js.map +1 -1
  181. package/dist/procedures/interceptors.d.ts +17 -4
  182. package/dist/procedures/interceptors.d.ts.map +1 -1
  183. package/dist/procedures/interceptors.js +18 -7
  184. package/dist/procedures/interceptors.js.map +1 -1
  185. package/dist/procedures/invoke.d.ts +15 -3
  186. package/dist/procedures/invoke.d.ts.map +1 -1
  187. package/dist/procedures/invoke.js +8 -3
  188. package/dist/procedures/invoke.js.map +1 -1
  189. package/dist/procedures/manifest.d.ts +83 -0
  190. package/dist/procedures/manifest.d.ts.map +1 -0
  191. package/dist/procedures/manifest.js +90 -0
  192. package/dist/procedures/manifest.js.map +1 -0
  193. package/dist/procedures/runtime-programs.d.ts +19 -0
  194. package/dist/procedures/runtime-programs.d.ts.map +1 -0
  195. package/dist/procedures/runtime-programs.js +24 -0
  196. package/dist/procedures/runtime-programs.js.map +1 -0
  197. package/dist/procedures/types.d.ts +20 -1
  198. package/dist/procedures/types.d.ts.map +1 -1
  199. package/dist/procedures/types.js.map +1 -1
  200. package/dist/protocol.d.ts +40 -0
  201. package/dist/protocol.d.ts.map +1 -0
  202. package/dist/protocol.js +58 -0
  203. package/dist/protocol.js.map +1 -0
  204. package/dist/server/host-policy.d.ts +4 -3
  205. package/dist/server/host-policy.d.ts.map +1 -1
  206. package/dist/server/host-policy.js +6 -3
  207. package/dist/server/host-policy.js.map +1 -1
  208. package/dist/server/host-procedures.d.ts +31 -0
  209. package/dist/server/host-procedures.d.ts.map +1 -0
  210. package/dist/server/host-procedures.js +75 -0
  211. package/dist/server/host-procedures.js.map +1 -0
  212. package/dist/server/index.d.ts +3 -3
  213. package/dist/server/index.d.ts.map +1 -1
  214. package/dist/server/index.js +1 -1
  215. package/dist/server/index.js.map +1 -1
  216. package/dist/server/procedure-server.d.ts +110 -4
  217. package/dist/server/procedure-server.d.ts.map +1 -1
  218. package/dist/server/procedure-server.js +269 -17
  219. package/dist/server/procedure-server.js.map +1 -1
  220. package/dist/server/request-auth.d.ts +19 -0
  221. package/dist/server/request-auth.d.ts.map +1 -0
  222. package/dist/server/request-auth.js +32 -0
  223. package/dist/server/request-auth.js.map +1 -0
  224. package/dist/server/types.d.ts +32 -0
  225. package/dist/server/types.d.ts.map +1 -1
  226. package/dist/server/types.js.map +1 -1
  227. package/dist/timers.d.ts +60 -0
  228. package/dist/timers.d.ts.map +1 -0
  229. package/dist/timers.js +33 -0
  230. package/dist/timers.js.map +1 -0
  231. package/dist/tracing/traceparent.d.ts +26 -0
  232. package/dist/tracing/traceparent.d.ts.map +1 -0
  233. package/dist/tracing/traceparent.js +53 -0
  234. package/dist/tracing/traceparent.js.map +1 -0
  235. package/package.json +7 -1
  236. package/src/adapters/http/client/transport.ts +31 -3
  237. package/src/adapters/http/client/types.ts +3 -2
  238. package/src/adapters/http/server/transport.ts +60 -24
  239. package/src/adapters/http/shared/headers.ts +7 -6
  240. package/src/adapters/local/client/transport.ts +70 -5
  241. package/src/adapters/local/client/types.ts +18 -0
  242. package/src/adapters/mock/client/transport.ts +9 -6
  243. package/src/adapters/mock/client/types.ts +7 -6
  244. package/src/adapters/port/transport.ts +124 -0
  245. package/src/adapters/websocket/client/index.ts +1 -0
  246. package/src/adapters/websocket/client/transport.ts +232 -73
  247. package/src/adapters/websocket/client/types.ts +80 -21
  248. package/src/adapters/websocket/server/transport.ts +199 -301
  249. package/src/adapters/websocket/server/types.ts +20 -1
  250. package/src/adapters/websocket/shared/rpc.ts +453 -0
  251. package/src/browser.ts +51 -3
  252. package/src/client/call-types.ts +10 -14
  253. package/src/client/client.ts +28 -9
  254. package/src/client/deadline.ts +64 -0
  255. package/src/client/errors/factory.ts +12 -8
  256. package/src/client/index.ts +21 -3
  257. package/src/client/middleware/cache.ts +120 -23
  258. package/src/client/middleware/circuit-breaker.ts +65 -35
  259. package/src/client/middleware/contexts.ts +17 -10
  260. package/src/client/middleware/items.ts +101 -6
  261. package/src/client/middleware/lru.ts +12 -0
  262. package/src/client/middleware/rate-limit.ts +9 -7
  263. package/src/client/middleware/resilience.ts +81 -0
  264. package/src/client/middleware/retry.ts +146 -34
  265. package/src/client/middleware/timeout.ts +55 -46
  266. package/src/client/middleware/tracing.ts +13 -4
  267. package/src/client/types.ts +9 -1
  268. package/src/clock.ts +21 -0
  269. package/src/duration.ts +56 -0
  270. package/src/machines.ts +186 -0
  271. package/src/procedures/capability.ts +11 -9
  272. package/src/procedures/collection/index.ts +9 -1
  273. package/src/procedures/collection/procedures.ts +57 -0
  274. package/src/procedures/collection/schemas.ts +110 -0
  275. package/src/procedures/collection/storage.ts +38 -0
  276. package/src/procedures/core/array.ts +27 -16
  277. package/src/procedures/core/index.ts +8 -0
  278. package/src/procedures/core/meta.ts +2 -1
  279. package/src/procedures/core/object.ts +48 -14
  280. package/src/procedures/core/operands.ts +7 -0
  281. package/src/procedures/core/string.ts +4 -2
  282. package/src/procedures/define-procedure.ts +82 -24
  283. package/src/procedures/effects.ts +35 -0
  284. package/src/procedures/idempotency.ts +152 -0
  285. package/src/procedures/index.ts +18 -2
  286. package/src/procedures/interceptors.ts +28 -11
  287. package/src/procedures/invoke.ts +19 -5
  288. package/src/procedures/manifest.ts +158 -0
  289. package/src/procedures/runtime-programs.ts +27 -0
  290. package/src/procedures/types.ts +20 -1
  291. package/src/protocol.ts +97 -0
  292. package/src/server/host-policy.ts +8 -5
  293. package/src/server/host-procedures.ts +102 -0
  294. package/src/server/index.ts +5 -2
  295. package/src/server/procedure-server.ts +324 -10
  296. package/src/server/request-auth.ts +35 -0
  297. package/src/server/types.ts +37 -0
  298. package/src/timers.ts +82 -0
  299. package/src/tracing/traceparent.ts +61 -0
@@ -12,6 +12,11 @@
12
12
 
13
13
  import type { ClientMiddleware, ClientRunner, ClientContext, TypedClientMiddleware } from "../types.js";
14
14
  import type { CircuitBreakerContext } from "./contexts.js";
15
+ import { optionalMs, type Duration } from "../../duration.js";
16
+ import { isAvailabilityFailure } from "./items.js";
17
+ import { realClock, type Cancel, type Clock } from "../../clock.js";
18
+ import { CIRCUIT_BREAKER_MACHINE, type MachineTransition } from "../../machines.js";
19
+ import { TIMER_DEFAULTS } from "../../timers.js";
15
20
 
16
21
  /**
17
22
  * Circuit breaker state.
@@ -29,16 +34,16 @@ export interface CircuitBreakerOptions {
29
34
  failureThreshold?: number;
30
35
 
31
36
  /**
32
- * Time window for counting failures (in milliseconds).
33
- * @default 10000 (10 seconds)
37
+ * Time window for counting failures.
38
+ * @default 10 seconds
34
39
  */
35
- failureWindow?: number;
40
+ failureWindow?: Duration;
36
41
 
37
42
  /**
38
- * Time to wait before attempting to close circuit (in milliseconds).
39
- * @default 30000 (30 seconds)
43
+ * Time to wait before attempting to close circuit.
44
+ * @default 30 seconds
40
45
  */
41
- resetTimeout?: number;
46
+ resetTimeout?: Duration;
42
47
 
43
48
  /**
44
49
  * Number of successful requests required to close circuit from HALF_OPEN.
@@ -55,8 +60,11 @@ export interface CircuitBreakerOptions {
55
60
 
56
61
  /**
57
62
  * Custom error predicate.
58
- * Return true if error should count as failure.
59
- * @default All errors count as failures
63
+ * Return true if error should count as failure. An error that is not a failure counts as
64
+ * nothing: not a failure and not a success.
65
+ * @default `isAvailabilityFailure`: only UNAVAILABLE, TIMEOUT, a stopped server, a gateway
66
+ * error and network errors count. An abort or a validation error does not (roadmap R12: before,
67
+ * every error counted, so a caller that aborted could open the circuit).
60
68
  */
61
69
  isFailure?: (error: Error) => boolean;
62
70
 
@@ -64,12 +72,30 @@ export interface CircuitBreakerOptions {
64
72
  * Callback when circuit state changes.
65
73
  */
66
74
  onStateChange?: (oldState: CircuitState, newState: CircuitState) => void;
75
+
76
+ /**
77
+ * The clock of the window and of the reset (roadmap R35). The default is the clock of the
78
+ * platform. A test gives a fake clock.
79
+ */
80
+ clock?: Clock;
81
+
82
+ /**
83
+ * This function gets each change of the state, with its event, as a transition of
84
+ * `CIRCUIT_BREAKER_MACHINE` (roadmap R35). A test replays them against the document.
85
+ */
86
+ onTransition?: (transition: MachineTransition) => void;
87
+
88
+ /** The name of the breaker in its transitions. The default is "circuit-breaker". */
89
+ name?: string;
67
90
  }
68
91
 
69
92
  /**
70
93
  * Failure record for tracking errors.
71
94
  */
72
95
  interface FailureRecord {
96
+ /** The time of the monotonic clock: the window uses it */
97
+ at: number;
98
+ /** The wall-clock time, for the statistics */
73
99
  timestamp: number;
74
100
  error: Error;
75
101
  }
@@ -114,8 +140,8 @@ export class CircuitBreakerError extends Error {
114
140
  * ```typescript
115
141
  * client.use(createCircuitBreakerMiddleware({
116
142
  * failureThreshold: 5, // Open after 5 failures
117
- * failureWindow: 10000, // Within 10 seconds
118
- * resetTimeout: 30000, // Try again after 30 seconds
143
+ * failureWindow: seconds(10), // Within 10 seconds
144
+ * resetTimeout: seconds(30), // Try again after 30 seconds
119
145
  * successThreshold: 2 // Close after 2 successes
120
146
  * }));
121
147
  * ```
@@ -125,13 +151,13 @@ export function createCircuitBreakerMiddleware(
125
151
  ): TypedClientMiddleware<CircuitBreakerContext, {}> {
126
152
  const {
127
153
  failureThreshold = 5,
128
- failureWindow = 10000,
129
- resetTimeout = 30000,
130
154
  successThreshold = 2,
131
- isFailure = () => true,
155
+ isFailure = isAvailabilityFailure,
132
156
  onStateChange,
133
157
  } = options;
134
158
  const halfOpenMaxRequests = options.halfOpenMaxRequests ?? successThreshold;
159
+ const failureWindow = optionalMs(options.failureWindow, "failureWindow") ?? TIMER_DEFAULTS.circuitBreaker.failureWindowMs;
160
+ const resetTimeout = optionalMs(options.resetTimeout, "resetTimeout") ?? TIMER_DEFAULTS.circuitBreaker.resetTimeoutMs;
135
161
 
136
162
  let state: CircuitState = "CLOSED";
137
163
  let failures: FailureRecord[] = [];
@@ -139,23 +165,26 @@ export function createCircuitBreakerMiddleware(
139
165
  let totalRequests = 0;
140
166
  let probesInFlight = 0;
141
167
  let lastStateChange = Date.now();
142
- let resetTimer: ReturnType<typeof setTimeout> | null = null;
168
+ let resetTimer: Cancel | null = null;
169
+ const clock = options.clock ?? realClock;
170
+ const instance = options.name ?? "circuit-breaker";
143
171
 
144
172
  /**
145
- * Change circuit state.
173
+ * Change circuit state. `on` is the event of `CIRCUIT_BREAKER_MACHINE`.
146
174
  */
147
- function setState(newState: CircuitState): void {
175
+ function setState(newState: CircuitState, on: string): void {
148
176
  const oldState = state;
149
177
  state = newState;
150
178
  if (oldState !== newState) lastStateChange = Date.now();
151
179
 
152
- if (oldState !== newState && onStateChange) {
153
- onStateChange(oldState, newState);
180
+ if (oldState !== newState) {
181
+ options.onTransition?.({ machine: CIRCUIT_BREAKER_MACHINE.id, instance, from: oldState, on, to: newState, at: clock.now() });
182
+ onStateChange?.(oldState, newState);
154
183
  }
155
184
 
156
185
  // Clear reset timer when moving to CLOSED
157
186
  if (newState === "CLOSED" && resetTimer) {
158
- clearTimeout(resetTimer);
187
+ resetTimer();
159
188
  resetTimer = null;
160
189
  }
161
190
  }
@@ -164,22 +193,23 @@ export function createCircuitBreakerMiddleware(
164
193
  * Record a failure.
165
194
  */
166
195
  function recordFailure(error: Error): void {
167
- const now = Date.now();
196
+ // The window uses a monotonic clock: a change of the wall clock does not move it (R12)
197
+ const now = clock.now();
168
198
 
169
199
  // Add failure
170
- failures.push({ timestamp: now, error });
200
+ failures.push({ at: now, timestamp: Date.now(), error });
171
201
 
172
202
  // Remove old failures outside window
173
- failures = failures.filter((f) => now - f.timestamp < failureWindow);
203
+ failures = failures.filter((f) => now - f.at < failureWindow);
174
204
 
175
205
  if (state === "HALF_OPEN") {
176
206
  // Any failure while testing reopens the circuit immediately — don't wait for the full
177
207
  // failureThreshold. See documentation/BUGS-2026-07.md (H11).
178
- setState("OPEN");
208
+ setState("OPEN", "probe-failure");
179
209
  successes = 0;
180
210
  scheduleReset();
181
211
  } else if (state === "CLOSED" && failures.length >= failureThreshold) {
182
- setState("OPEN");
212
+ setState("OPEN", "failure-threshold");
183
213
  scheduleReset();
184
214
  }
185
215
  }
@@ -192,14 +222,14 @@ export function createCircuitBreakerMiddleware(
192
222
  successes++;
193
223
  if (successes >= successThreshold) {
194
224
  // Enough successes - close circuit
195
- setState("CLOSED");
225
+ setState("CLOSED", "probe-successes");
196
226
  failures = [];
197
227
  successes = 0;
198
228
  }
199
229
  } else if (state === "CLOSED") {
200
230
  // Clear old failures on success
201
- const now = Date.now();
202
- failures = failures.filter((f) => now - f.timestamp < failureWindow);
231
+ const now = clock.now();
232
+ failures = failures.filter((f) => now - f.at < failureWindow);
203
233
  }
204
234
  }
205
235
 
@@ -208,15 +238,15 @@ export function createCircuitBreakerMiddleware(
208
238
  */
209
239
  function scheduleReset(): void {
210
240
  if (resetTimer) {
211
- clearTimeout(resetTimer);
241
+ resetTimer();
212
242
  }
213
243
 
214
- resetTimer = setTimeout(() => {
244
+ resetTimer = clock.after(resetTimeout, () => {
215
245
  if (state === "OPEN") {
216
- setState("HALF_OPEN");
246
+ setState("HALF_OPEN", "reset-timeout");
217
247
  successes = 0;
218
248
  }
219
- }, resetTimeout);
249
+ });
220
250
  // Don't keep the event loop alive just for the reset timer. See BUGS-2026-07.md (M4).
221
251
  resetTimer.unref?.();
222
252
  }
@@ -239,10 +269,10 @@ export function createCircuitBreakerMiddleware(
239
269
  }
240
270
 
241
271
  function getStats(): CircuitBreakerStats {
242
- const now = Date.now();
272
+ const now = clock.now();
243
273
  return {
244
274
  state,
245
- failures: failures.filter((f) => now - f.timestamp < failureWindow).length,
275
+ failures: failures.filter((f) => now - f.at < failureWindow).length,
246
276
  successes,
247
277
  totalRequests,
248
278
  lastFailureTime: failures.length > 0 ? failures[failures.length - 1]!.timestamp : null,
@@ -257,10 +287,10 @@ export function createCircuitBreakerMiddleware(
257
287
  totalRequests++;
258
288
  // The state for the other middlewares (CircuitBreakerContext)
259
289
  const writeState = (): void => {
260
- const now = Date.now();
290
+ const now = clock.now();
261
291
  context.message.metadata["circuitBreaker"] = {
262
292
  state,
263
- failures: failures.filter((f) => now - f.timestamp < failureWindow).length,
293
+ failures: failures.filter((f) => now - f.at < failureWindow).length,
264
294
  successes,
265
295
  };
266
296
  };
@@ -15,6 +15,8 @@
15
15
  * ```
16
16
  */
17
17
 
18
+ import type { Duration } from "../../duration.js";
19
+
18
20
  // =============================================================================
19
21
  // Retry Middleware Context
20
22
  // =============================================================================
@@ -29,12 +31,17 @@ export interface RetryContext {
29
31
  attempt?: number;
30
32
  /** Maximum number of attempts: an override of a call, then the value that the middleware uses */
31
33
  maxAttempts?: number;
32
- /** An override of the base delay, in milliseconds */
33
- delay?: number;
34
+ /** An override of the base delay */
35
+ delay?: Duration;
34
36
  /** An override of the factor of the delay from one attempt to the next */
35
37
  backoffMultiplier?: number;
36
- /** An override of the longest delay, in milliseconds */
37
- maxDelay?: number;
38
+ /** An override of the longest delay */
39
+ maxDelay?: Duration;
40
+ /**
41
+ * True: the call can go again after a try with an indeterminate outcome, because a second
42
+ * run changes nothing more (roadmap R1). It overrides the `idempotent` option of the middleware.
43
+ */
44
+ idempotent?: boolean;
38
45
  };
39
46
  }
40
47
 
@@ -51,8 +58,8 @@ export interface RetryContext {
51
58
  */
52
59
  export interface CacheContext {
53
60
  cache: {
54
- /** The time-to-live of the entry that this call stores, in milliseconds */
55
- ttl?: number;
61
+ /** The time-to-live of the entry that this call stores */
62
+ ttl?: Duration;
56
63
  /** True: the call skips the cache (no read, no write) */
57
64
  bypass?: boolean;
58
65
  /** True: the call fetches and replaces the stored response */
@@ -72,10 +79,10 @@ export interface CacheContext {
72
79
  */
73
80
  export interface TimeoutContext {
74
81
  timeout: {
75
- /** Overall timeout in milliseconds (if configured) */
76
- overall?: number;
77
- /** Per-attempt timeout in milliseconds (if configured) */
78
- perAttempt?: number;
82
+ /** The overall timeout (if configured) */
83
+ overall?: Duration;
84
+ /** The timeout of each attempt (if configured) */
85
+ perAttempt?: Duration;
79
86
  };
80
87
  }
81
88
 
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { ResponseItem } from "../types.js";
11
+ import { realClock, type Clock } from "../../clock.js";
11
12
 
12
13
  /** This function makes an error item. */
13
14
  export function errorItem<TRes>(id: string, code: string, message: string, retryable: boolean): ResponseItem<TRes> {
@@ -19,6 +20,72 @@ export function abortedItem<TRes>(id: string): ResponseItem<TRes> {
19
20
  return errorItem<TRes>(id, "ABORTED", "Request was aborted", false);
20
21
  }
21
22
 
23
+ /**
24
+ * What a failed try tells about its request, in the metadata of the error item (`delivery`).
25
+ * - `not-sent`: the request did not leave the client. A retry is safe.
26
+ * - `indeterminate`: the request left, and no answer came. The server can have run it, so only
27
+ * an idempotent call can go again (the three outcomes of `@mark1russell7/service`).
28
+ * An error item without the field is an answer of the server: its `retryable` flag decides.
29
+ */
30
+ export type Delivery = "not-sent" | "indeterminate";
31
+
32
+ /** The metadata key of the delivery of a failed try. */
33
+ export const DELIVERY_METADATA_KEY = "delivery";
34
+
35
+ /** This function marks an error item with the delivery of its request. */
36
+ export function withDelivery<TRes>(item: ResponseItem<TRes>, delivery: Delivery): ResponseItem<TRes> {
37
+ return { ...item, metadata: { ...item.metadata, [DELIVERY_METADATA_KEY]: delivery } };
38
+ }
39
+
40
+ /** The metadata key of the time that the server asks a client to wait before a retry, in milliseconds. */
41
+ export const RETRY_AFTER_METADATA_KEY = "retry-after";
42
+
43
+ /** The time that an error item asks a client to wait before a retry, in milliseconds, or undefined. */
44
+ export function retryAfterOf(item: ResponseItem<unknown>): number | undefined {
45
+ const value = item.metadata?.[RETRY_AFTER_METADATA_KEY];
46
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
47
+ }
48
+
49
+ /**
50
+ * The error codes that say that the service is not available: the server is down, busy, slow or
51
+ * stopping, or the network failed. A circuit breaker counts only these (roadmap R12). An abort,
52
+ * a validation error, a refusal or an error of the handler says nothing about the health of the
53
+ * service.
54
+ */
55
+ const AVAILABILITY_CODES = new Set([
56
+ "UNAVAILABLE",
57
+ "TIMEOUT",
58
+ "SERVER_STOPPED",
59
+ "SERVICE_UNAVAILABLE",
60
+ "BAD_GATEWAY",
61
+ "GATEWAY_TIMEOUT",
62
+ "NETWORK_ERROR",
63
+ "CONNECTION_REFUSED",
64
+ "STREAM_ENDED",
65
+ // The connection to the other side closed before the call left (roadmap R30: before, the list
66
+ // did not have this code of R24)
67
+ "CONNECTION_CLOSED",
68
+ ]);
69
+
70
+ /** True when an error says that the service is not available (see AVAILABILITY_CODES). */
71
+ export function isAvailabilityFailure(error: unknown): boolean {
72
+ const code = (error as { code?: unknown } | undefined)?.code;
73
+ if (typeof code === "string" && AVAILABILITY_CODES.has(code)) return true;
74
+ return isNetworkError(error);
75
+ }
76
+
77
+ /** The time of a monotonic clock in milliseconds: a change of the wall clock does not move it. */
78
+ export function monotonicNow(): number {
79
+ const performance = (globalThis as { performance?: { now(): number } }).performance;
80
+ return performance ? performance.now() : Date.now();
81
+ }
82
+
83
+ /** The delivery of a failed try, or undefined for an answer of the server. */
84
+ export function deliveryOf(item: ResponseItem<unknown>): Delivery | undefined {
85
+ const delivery = item.metadata?.[DELIVERY_METADATA_KEY];
86
+ return delivery === "not-sent" || delivery === "indeterminate" ? delivery : undefined;
87
+ }
88
+
22
89
  const NETWORK_CODES = new Set([
23
90
  "ECONNREFUSED",
24
91
  "ECONNRESET",
@@ -52,35 +119,63 @@ export function isNetworkError(error: unknown): boolean {
52
119
  return /WebSocket (connection closed|disconnected)|Connection timeout/.test(error.message);
53
120
  }
54
121
 
122
+ /** The network codes that tell that the connection never opened, so no request left. */
123
+ const NOT_SENT_CODES = new Set([
124
+ "ECONNREFUSED",
125
+ "ENETUNREACH",
126
+ "EHOSTUNREACH",
127
+ "ENOTFOUND",
128
+ "EAI_AGAIN",
129
+ "UND_ERR_CONNECT_TIMEOUT",
130
+ "CONNECTION_REFUSED",
131
+ ]);
132
+
133
+ /**
134
+ * This function tells if a thrown error proves that the request did not leave the client: the
135
+ * connection was refused, the name did not resolve, or the WebSocket did not open in time.
136
+ * A reset or a close during the request does not prove it (the request can have arrived).
137
+ */
138
+ export function isNotSentError(error: unknown): boolean {
139
+ if (!(error instanceof Error)) return false;
140
+ const code = (error as { code?: unknown }).code;
141
+ if (typeof code === "string" && NOT_SENT_CODES.has(code)) return true;
142
+ const cause = (error as { cause?: unknown }).cause;
143
+ if (cause !== undefined && cause !== error && isNotSentError(cause)) return true;
144
+ // The WebSocket transport fails a request that waited for a connection that did not come
145
+ return /^(WebSocket disconnected|Connection timeout)$/.test(error.message);
146
+ }
147
+
55
148
  /**
56
149
  * The item that stands for a thrown error, so that `shouldRetry` can decide about it. It is
57
- * retryable only when the error is a network failure.
150
+ * retryable only when the error is a network failure. Its delivery tells if the request left.
58
151
  */
59
152
  export function thrownItem<TRes>(id: string, error: unknown): ResponseItem<TRes> {
60
153
  const code = (error as { code?: unknown } | undefined)?.code;
61
- return errorItem<TRes>(
154
+ const item = errorItem<TRes>(
62
155
  id,
63
156
  typeof code === "string" ? code : "THROWN",
64
157
  error instanceof Error ? error.message : String(error),
65
158
  isNetworkError(error),
66
159
  );
160
+ return withDelivery(item, isNotSentError(error) ? "not-sent" : "indeterminate");
67
161
  }
68
162
 
69
163
  /**
70
164
  * This function waits `ms` milliseconds. It returns false at once when the signal aborts (deep
71
165
  * dive TRN-8: before, the backoff of the retry middleware ignored the signal).
72
166
  */
73
- export function sleep(ms: number, signal?: AbortSignal): Promise<boolean> {
167
+ export function sleep(ms: number, signal?: AbortSignal, clock: Clock = realClock): Promise<boolean> {
74
168
  if (signal?.aborted) return Promise.resolve(false);
75
169
  return new Promise((resolve) => {
76
170
  const onAbort = (): void => {
77
- clearTimeout(timer);
171
+ cancel();
78
172
  resolve(false);
79
173
  };
80
- const timer = setTimeout(() => {
174
+ // The clock of the caller (roadmap R35): a test gives a fake clock
175
+ const cancel = clock.after(Math.max(0, ms), () => {
81
176
  signal?.removeEventListener("abort", onAbort);
82
177
  resolve(true);
83
- }, Math.max(0, ms));
178
+ });
84
179
  signal?.addEventListener("abort", onAbort, { once: true });
85
180
  });
86
181
  }
@@ -63,4 +63,16 @@ export class LruMap<K, V> {
63
63
  clear(): void {
64
64
  this.entries.clear();
65
65
  }
66
+
67
+ /** Delete each key whose value matches. This does not count as a use. The number deleted. */
68
+ deleteWhere(matches: (value: V, key: K) => boolean): number {
69
+ let deleted = 0;
70
+ for (const [key, value] of this.entries) {
71
+ if (matches(value, key)) {
72
+ this.entries.delete(key);
73
+ deleted++;
74
+ }
75
+ }
76
+ return deleted;
77
+ }
66
78
  }
@@ -10,6 +10,8 @@
10
10
  import type { ClientMiddleware, ClientRunner, ClientContext, TypedClientMiddleware } from "../types.js";
11
11
  import type { RateLimitContext } from "./contexts.js";
12
12
  import { abortedItem } from "./items.js";
13
+ import { optionalMs, type Duration } from "../../duration.js";
14
+ import { TIMER_DEFAULTS } from "../../timers.js";
13
15
 
14
16
  /**
15
17
  * Rate limiting options.
@@ -25,7 +27,7 @@ export interface RateLimitOptions {
25
27
  * Time window in milliseconds.
26
28
  * @default 60000 (1 minute)
27
29
  */
28
- window?: number;
30
+ window?: Duration;
29
31
 
30
32
  /**
31
33
  * Behavior when rate limit is exceeded.
@@ -96,14 +98,14 @@ interface QueuedRequest {
96
98
  * // Reject strategy (default)
97
99
  * client.use(createRateLimitMiddleware({
98
100
  * maxRequests: 100, // 100 requests
99
- * window: 60000, // per minute
101
+ * window: minutes(1),
100
102
  * strategy: "reject" // Throw error when exceeded
101
103
  * }));
102
104
  *
103
105
  * // Queue strategy
104
106
  * client.use(createRateLimitMiddleware({
105
107
  * maxRequests: 10,
106
- * window: 1000,
108
+ * window: seconds(1),
107
109
  * strategy: "queue", // Queue requests when exceeded
108
110
  * maxQueueSize: 50
109
111
  * }));
@@ -114,12 +116,12 @@ export function createRateLimitMiddleware(
114
116
  ): TypedClientMiddleware<RateLimitContext, {}> {
115
117
  const {
116
118
  maxRequests = 100,
117
- window = 60000,
118
119
  strategy = "reject",
119
120
  maxQueueSize = 100,
120
121
  message = "Rate limit exceeded",
121
122
  onRateLimitExceeded,
122
123
  } = options;
124
+ const window = optionalMs(options.window, "window") ?? TIMER_DEFAULTS.rateLimit.windowMs;
123
125
 
124
126
  // Token bucket state
125
127
  let tokens = maxRequests;
@@ -273,9 +275,9 @@ export function createRateLimitMiddleware(
273
275
  * @example
274
276
  * ```typescript
275
277
  * client.use(createPerServiceRateLimiter({
276
- * users: { maxRequests: 100, window: 60000 },
277
- * orders: { maxRequests: 50, window: 60000 },
278
- * default: { maxRequests: 200, window: 60000 }
278
+ * users: { maxRequests: 100, window: minutes(1) },
279
+ * orders: { maxRequests: 50, window: minutes(1) },
280
+ * default: { maxRequests: 200, window: minutes(1) }
279
281
  * }));
280
282
  * ```
281
283
  */
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The resilience preset (roadmap R10): the timeouts, the cache, the retry, the circuit breaker and
3
+ * the rate limit of a client, in the one order that works.
4
+ *
5
+ * The first layer is the outermost one:
6
+ *
7
+ * 1. The overall timeout: it covers the whole call, retries included.
8
+ * 2. The cache: a hit makes no request, so no try and no slot of the rate limit.
9
+ * 3. The retry: it starts each try.
10
+ * 4. The circuit breaker: it counts each try, and an open breaker stops a retry loop.
11
+ * 5. The rate limit: each try takes a slot.
12
+ * 6. The timeout of one try: inside the retry, so the retry can start the next try.
13
+ *
14
+ * Each order pitfall of a hand-made stack is impossible here: an overall timeout inside the
15
+ * retry, a per-try timeout outside it, or a rate limit outside the retry. The deadline of the
16
+ * call goes to the server as `procedure-timeout` with each try (`client/deadline.ts`).
17
+ */
18
+
19
+ import type { ClientMiddleware, ClientRunner, TypedClientMiddleware } from "../types.js";
20
+ import type { CacheContext, CircuitBreakerContext, RateLimitContext, RetryContext, TimeoutContext } from "./contexts.js";
21
+ import { createCacheMiddleware, type CacheOptions } from "./cache.js";
22
+ import { createCircuitBreakerMiddleware, type CircuitBreakerOptions } from "./circuit-breaker.js";
23
+ import { createRateLimitMiddleware, type RateLimitOptions } from "./rate-limit.js";
24
+ import { createRetryMiddleware, type RetryOptions } from "./retry.js";
25
+ import { createOverallTimeoutMiddleware, createTimeoutMiddleware } from "./timeout.js";
26
+ import type { Duration } from "../../duration.js";
27
+
28
+ /** The parts of the preset. An absent part is not in the stack. */
29
+ export interface ResilienceOptions {
30
+ /** The time of the whole call, retries included. */
31
+ overall?: Duration | undefined;
32
+ /** The time of one try. It is at most the remaining time of the call. */
33
+ perAttempt?: Duration | undefined;
34
+ /** The retry. Its `idempotent` option decides which calls go again after an indeterminate try. */
35
+ retry?: RetryOptions | undefined;
36
+ /** The circuit breaker. */
37
+ breaker?: CircuitBreakerOptions | undefined;
38
+ /** The cache of the reads that opt in. */
39
+ cache?: CacheOptions | undefined;
40
+ /** The rate limit of the tries. */
41
+ rateLimit?: RateLimitOptions | undefined;
42
+ /** The message of a timeout error. */
43
+ message?: string | undefined;
44
+ }
45
+
46
+ /** The context that the preset gives: the context of each part that it can have. */
47
+ export type ResilienceContext = TimeoutContext & Partial<RetryContext & CacheContext & CircuitBreakerContext & RateLimitContext>;
48
+
49
+ /**
50
+ * This function gives one middleware with the parts of `options` in the correct order (see the
51
+ * file comment). Add it with one `use()`.
52
+ *
53
+ * @example
54
+ * ```typescript
55
+ * const client = new Client(transport).use(
56
+ * resilience({
57
+ * overall: seconds(10),
58
+ * perAttempt: seconds(3),
59
+ * retry: { maxRetries: 2, idempotent: idempotentIn(registry) },
60
+ * breaker: { failureThreshold: 5 },
61
+ * }),
62
+ * );
63
+ * ```
64
+ */
65
+ export function resilience(options: ResilienceOptions): TypedClientMiddleware<ResilienceContext, {}> {
66
+ const layers: ClientMiddleware[] = [];
67
+ const message = options.message;
68
+ if (options.overall) {
69
+ layers.push(createOverallTimeoutMiddleware({ overall: options.overall, ...(message ? { message: `${message} (overall)` } : {}) }) as ClientMiddleware);
70
+ }
71
+ if (options.cache) layers.push(createCacheMiddleware(options.cache) as ClientMiddleware);
72
+ if (options.retry) layers.push(createRetryMiddleware(options.retry) as ClientMiddleware);
73
+ if (options.breaker) layers.push(createCircuitBreakerMiddleware(options.breaker) as ClientMiddleware);
74
+ if (options.rateLimit) layers.push(createRateLimitMiddleware(options.rateLimit) as ClientMiddleware);
75
+ if (options.perAttempt) {
76
+ layers.push(createTimeoutMiddleware({ perAttempt: options.perAttempt, ...(message ? { message: `${message} (per-attempt)` } : {}) }) as ClientMiddleware);
77
+ }
78
+ const composed = <TReq, TRes>(next: ClientRunner<TReq, TRes>): ClientRunner<TReq, TRes> =>
79
+ layers.reduceRight<ClientRunner<TReq, TRes>>((inner, layer) => (layer as ClientMiddleware<TReq, TRes>)(inner), next);
80
+ return composed as TypedClientMiddleware<ResilienceContext, {}>;
81
+ }