@procedures/client 0.6.0 → 0.8.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 (127) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +63 -7
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/adapters/http/client/transport.d.ts +5 -0
  5. package/dist/adapters/http/client/transport.d.ts.map +1 -1
  6. package/dist/adapters/http/client/transport.js +10 -4
  7. package/dist/adapters/http/client/transport.js.map +1 -1
  8. package/dist/adapters/http/server/ndjson.d.ts +73 -0
  9. package/dist/adapters/http/server/ndjson.d.ts.map +1 -0
  10. package/dist/adapters/http/server/ndjson.js +228 -0
  11. package/dist/adapters/http/server/ndjson.js.map +1 -0
  12. package/dist/adapters/http/server/transport.d.ts +15 -4
  13. package/dist/adapters/http/server/transport.d.ts.map +1 -1
  14. package/dist/adapters/http/server/transport.js +29 -69
  15. package/dist/adapters/http/server/transport.js.map +1 -1
  16. package/dist/adapters/http/server/types.d.ts +15 -0
  17. package/dist/adapters/http/server/types.d.ts.map +1 -1
  18. package/dist/adapters/sequence.d.ts +14 -0
  19. package/dist/adapters/sequence.d.ts.map +1 -1
  20. package/dist/adapters/sequence.js +16 -0
  21. package/dist/adapters/sequence.js.map +1 -1
  22. package/dist/adapters/websocket/client/transport.d.ts +7 -3
  23. package/dist/adapters/websocket/client/transport.d.ts.map +1 -1
  24. package/dist/adapters/websocket/client/transport.js +9 -3
  25. package/dist/adapters/websocket/client/transport.js.map +1 -1
  26. package/dist/adapters/websocket/server/transport.d.ts +10 -0
  27. package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
  28. package/dist/adapters/websocket/server/transport.js +22 -2
  29. package/dist/adapters/websocket/server/transport.js.map +1 -1
  30. package/dist/browser.d.ts +5 -5
  31. package/dist/browser.d.ts.map +1 -1
  32. package/dist/browser.js +4 -3
  33. package/dist/browser.js.map +1 -1
  34. package/dist/client/errors/registry.d.ts.map +1 -1
  35. package/dist/client/errors/registry.js +1 -0
  36. package/dist/client/errors/registry.js.map +1 -1
  37. package/dist/client/middleware/retry.d.ts +4 -0
  38. package/dist/client/middleware/retry.d.ts.map +1 -1
  39. package/dist/client/middleware/retry.js +86 -78
  40. package/dist/client/middleware/retry.js.map +1 -1
  41. package/dist/events/bus.d.ts +32 -14
  42. package/dist/events/bus.d.ts.map +1 -1
  43. package/dist/events/bus.js +119 -65
  44. package/dist/events/bus.js.map +1 -1
  45. package/dist/events/index.d.ts +3 -2
  46. package/dist/events/index.d.ts.map +1 -1
  47. package/dist/events/index.js.map +1 -1
  48. package/dist/events/publisher.d.ts.map +1 -1
  49. package/dist/events/publisher.js +20 -6
  50. package/dist/events/publisher.js.map +1 -1
  51. package/dist/events/remote.d.ts +80 -18
  52. package/dist/events/remote.d.ts.map +1 -1
  53. package/dist/events/remote.js +448 -58
  54. package/dist/events/remote.js.map +1 -1
  55. package/dist/events/types.d.ts +13 -2
  56. package/dist/events/types.d.ts.map +1 -1
  57. package/dist/node.d.ts +6 -0
  58. package/dist/node.d.ts.map +1 -1
  59. package/dist/node.js +5 -0
  60. package/dist/node.js.map +1 -1
  61. package/dist/procedures/caller.d.ts +33 -0
  62. package/dist/procedures/caller.d.ts.map +1 -0
  63. package/dist/procedures/caller.js +27 -0
  64. package/dist/procedures/caller.js.map +1 -0
  65. package/dist/procedures/collection/index.d.ts +2 -2
  66. package/dist/procedures/collection/index.d.ts.map +1 -1
  67. package/dist/procedures/collection/index.js +1 -1
  68. package/dist/procedures/collection/index.js.map +1 -1
  69. package/dist/procedures/collection/procedures.d.ts.map +1 -1
  70. package/dist/procedures/collection/procedures.js +21 -1
  71. package/dist/procedures/collection/procedures.js.map +1 -1
  72. package/dist/procedures/collection/schemas.d.ts +9 -0
  73. package/dist/procedures/collection/schemas.d.ts.map +1 -1
  74. package/dist/procedures/collection/schemas.js +28 -0
  75. package/dist/procedures/collection/schemas.js.map +1 -1
  76. package/dist/procedures/collection/storage.d.ts +6 -0
  77. package/dist/procedures/collection/storage.d.ts.map +1 -1
  78. package/dist/procedures/index.d.ts +3 -1
  79. package/dist/procedures/index.d.ts.map +1 -1
  80. package/dist/procedures/index.js +2 -0
  81. package/dist/procedures/index.js.map +1 -1
  82. package/dist/procedures/invoke.d.ts.map +1 -1
  83. package/dist/procedures/invoke.js +3 -0
  84. package/dist/procedures/invoke.js.map +1 -1
  85. package/dist/procedures/types.d.ts +50 -0
  86. package/dist/procedures/types.d.ts.map +1 -1
  87. package/dist/procedures/types.js.map +1 -1
  88. package/dist/protocol.d.ts +1 -1
  89. package/dist/protocol.d.ts.map +1 -1
  90. package/dist/protocol.js +1 -0
  91. package/dist/protocol.js.map +1 -1
  92. package/dist/server/procedure-server.d.ts +41 -5
  93. package/dist/server/procedure-server.d.ts.map +1 -1
  94. package/dist/server/procedure-server.js +94 -9
  95. package/dist/server/procedure-server.js.map +1 -1
  96. package/dist/timers.d.ts +9 -0
  97. package/dist/timers.d.ts.map +1 -1
  98. package/dist/timers.js +6 -1
  99. package/dist/timers.js.map +1 -1
  100. package/package.json +1 -1
  101. package/src/adapters/http/client/transport.ts +10 -4
  102. package/src/adapters/http/server/ndjson.ts +259 -0
  103. package/src/adapters/http/server/transport.ts +31 -69
  104. package/src/adapters/http/server/types.ts +17 -0
  105. package/src/adapters/sequence.ts +18 -0
  106. package/src/adapters/websocket/client/transport.ts +9 -3
  107. package/src/adapters/websocket/server/transport.ts +23 -2
  108. package/src/browser.ts +9 -2
  109. package/src/client/errors/registry.ts +1 -0
  110. package/src/client/middleware/retry.ts +89 -78
  111. package/src/events/bus.ts +141 -73
  112. package/src/events/index.ts +3 -0
  113. package/src/events/publisher.ts +23 -6
  114. package/src/events/remote.ts +575 -73
  115. package/src/events/types.ts +15 -2
  116. package/src/node.ts +6 -0
  117. package/src/procedures/caller.ts +45 -0
  118. package/src/procedures/collection/index.ts +3 -0
  119. package/src/procedures/collection/procedures.ts +22 -0
  120. package/src/procedures/collection/schemas.ts +37 -0
  121. package/src/procedures/collection/storage.ts +6 -0
  122. package/src/procedures/index.ts +4 -0
  123. package/src/procedures/invoke.ts +2 -0
  124. package/src/procedures/types.ts +53 -0
  125. package/src/protocol.ts +3 -1
  126. package/src/server/procedure-server.ts +111 -11
  127. package/src/timers.ts +15 -1
@@ -8,6 +8,8 @@ import type { Request, Express } from "express";
8
8
  import type { Server as HttpServer } from "http";
9
9
  import type { Method } from "../../../client/types.js";
10
10
  import type { BrowserGuardOptions } from "../../../server/browser-guard.js";
11
+ import type { Clock } from "../../../clock.js";
12
+ import type { Duration } from "../../../duration.js";
11
13
 
12
14
  /**
13
15
  * URL strategy for converting HTTP requests to RPC methods.
@@ -68,6 +70,21 @@ export interface HttpServerTransportOptions extends BrowserGuardOptions {
68
70
  */
69
71
  bodyLimit?: number;
70
72
 
73
+ /**
74
+ * The quiet time of an NDJSON stream. After it, the server writes a keep-alive line: a blank
75
+ * line. Each line of the stream starts the time again. Thus a proxy or a client with an idle
76
+ * timeout does not close a quiet stream. `false`, or a duration of zero or less, sends no
77
+ * keep-alive line.
78
+ * @default seconds(15) (`TIMER_DEFAULTS.httpServer.keepAliveMs`)
79
+ */
80
+ keepAlive?: Duration | false;
81
+
82
+ /**
83
+ * The clock of the keep-alive timers (roadmap R35). The default is the clock of the platform. A
84
+ * test gives a fake clock.
85
+ */
86
+ clock?: Clock;
87
+
71
88
  /**
72
89
  * Custom CORS options. `origin` is "*", one origin or a list: the response names the
73
90
  * request's origin when it is in the list.
@@ -10,6 +10,8 @@
10
10
  * number of lost frames in its metadata key `stream-gap`, and the caller writes a warning. The
11
11
  * caller also gives back the credit of the lost frames.
12
12
  * - A frame without `seq` comes from an older peer. The caller takes it as before.
13
+ * - A blank line of an NDJSON stream is a keep-alive line. It is not a frame and it has no `seq`,
14
+ * so the reader skips it before the check.
13
15
  */
14
16
 
15
17
  /**
@@ -18,6 +20,14 @@
18
20
  */
19
21
  export const STREAM_GAP_METADATA_KEY = "stream-gap";
20
22
 
23
+ /**
24
+ * The metadata key of an item that the HTTP client transport read from an NDJSON stream. Its value
25
+ * is `true`. The receiver sets it. It does not cross the wire, and the receiver removes a key of
26
+ * this name that the peer sent. The constant `STREAM_METADATA_KEY` of the server has the same
27
+ * name, with the value `unbounded` in the metadata of a response.
28
+ */
29
+ export const STREAM_ITEM_METADATA_KEY = "stream";
30
+
21
31
  /** The order of the stream frames of one call, at the side that receives them. */
22
32
  export class StreamSequence {
23
33
  private last = 0;
@@ -48,6 +58,14 @@ export function itemMetadata(metadata: Record<string, unknown> | undefined, gap:
48
58
  return out;
49
59
  }
50
60
 
61
+ /**
62
+ * This function gives the metadata of an item of an NDJSON stream: the metadata of `itemMetadata`,
63
+ * and the key `stream` with the value `true`. The value of the peer for this key goes away.
64
+ */
65
+ export function streamItemMetadata(metadata: Record<string, unknown> | undefined, gap: number): Record<string, unknown> {
66
+ return { ...itemMetadata(metadata, gap), [STREAM_ITEM_METADATA_KEY]: true };
67
+ }
68
+
51
69
  /** The number of frames that did not arrive immediately before an item: 0 without a gap. */
52
70
  export function gapOf(metadata: Record<string, unknown> | undefined): number {
53
71
  const gap = metadata?.[STREAM_GAP_METADATA_KEY];
@@ -874,11 +874,12 @@ export class WebSocketTransport implements Transport {
874
874
  /**
875
875
  * This function subscribes to topics of the bus of the server (`documentation/EVENTS.md`). The
876
876
  * handler gets each event in order. After a drop, the transport sends the subscription again on
877
- * the new connection. The events between the drop and the new confirmation are lost.
877
+ * the new connection. The events between the drop and the new confirmation are lost. The
878
+ * subscribers of the same topics with the same options share one call.
878
879
  *
879
880
  * @param topics - One topic, or a list of 1 to 64 topics
880
881
  * @param handler - The handler of each event. A promise that it gives holds the next event.
881
- * @param options - `onSubscribed`, `onError`, a signal, the metadata of the call
882
+ * @param options - `onSubscribed`, `onError`, a signal, the metadata of the call, the resubscribe rules
882
883
  * @returns The unsubscribe function, with the `ready` promise of the first confirmation
883
884
  *
884
885
  * @example
@@ -904,12 +905,17 @@ export class WebSocketTransport implements Transport {
904
905
  return remoteEvents<T>(this, topics, options, this.subscriptionHooks());
905
906
  }
906
907
 
907
- /** The parts of the subscriptions of this transport: its connection, its backoff, its close and `onEvent`. */
908
+ /**
909
+ * The parts of the subscriptions of this transport: its connection, its backoff, its
910
+ * `maxAttempts`, its clock, its close and `onEvent`.
911
+ */
908
912
  private subscriptionHooks(): SubscriptionHooks {
909
913
  return {
910
914
  lifetime: this.subscriptions.signal,
911
915
  ready: (signal) => this.whenConnected(signal),
912
916
  backoff: (attempt) => reconnectDelay(attempt, this.options.reconnect),
917
+ maxAttempts: this.options.reconnect.maxAttempts,
918
+ clock: this.clock,
913
919
  onEvent: (event) => {
914
920
  if (!this.options.onEvent) return;
915
921
  try {
@@ -90,6 +90,12 @@ export class WebSocketServerTransport implements ServerTransport {
90
90
  */
91
91
  private readonly channels = new Map<string, { incoming: IncomingCalls; outgoing: OutgoingCalls }>();
92
92
 
93
+ /**
94
+ * The transport of the remote events of each connection: one object for each connection, so the
95
+ * subscriptions of one connection to the same topics share one call (EVENTS.md).
96
+ */
97
+ private readonly eventTransports = new Map<string, Transport>();
98
+
93
99
  /** The browser checks of each connection (see `server/browser-guard.ts`) */
94
100
  private readonly guard: BrowserGuardOptions;
95
101
 
@@ -303,6 +309,7 @@ export class WebSocketServerTransport implements ServerTransport {
303
309
  channel?.incoming.abortAll();
304
310
  channel?.outgoing.failAll("WebSocket connection closed");
305
311
  this.channels.delete(connectionId);
312
+ this.eventTransports.delete(connectionId);
306
313
  const conn = this.trackedConnections.get(connectionId);
307
314
  if (conn) {
308
315
  this.trackedConnections.delete(connectionId);
@@ -552,7 +559,7 @@ export class WebSocketServerTransport implements ServerTransport {
552
559
  handler: RemoteEventHandler<T>,
553
560
  options: SubscribeOptions = {},
554
561
  ): Subscription {
555
- return subscribeRemote<T>(this.transportFor(connectionId), topics, handler, options, { resubscribe: false });
562
+ return subscribeRemote<T>(this.eventTransportFor(connectionId), topics, handler, options, { resubscribe: false });
556
563
  }
557
564
 
558
565
  /**
@@ -564,7 +571,21 @@ export class WebSocketServerTransport implements ServerTransport {
564
571
  * @param options - A signal, `onSubscribed`, the metadata of the call
565
572
  */
566
573
  events<T = unknown>(connectionId: string, topics: string | readonly string[], options: RemoteEventsOptions = {}): AsyncIterable<RemoteEvent<T>> {
567
- return remoteEvents<T>(this.transportFor(connectionId), topics, options, { resubscribe: false });
574
+ return remoteEvents<T>(this.eventTransportFor(connectionId), topics, options, { resubscribe: false });
575
+ }
576
+
577
+ /**
578
+ * The transport of the remote events of a connection: the same object for each subscription of
579
+ * a tracked connection. The close of the connection removes it.
580
+ */
581
+ private eventTransportFor(connectionId: string): Transport {
582
+ if (!this.trackedConnections.has(connectionId)) return this.transportFor(connectionId);
583
+ let transport = this.eventTransports.get(connectionId);
584
+ if (!transport) {
585
+ transport = this.transportFor(connectionId);
586
+ this.eventTransports.set(connectionId, transport);
587
+ }
588
+ return transport;
568
589
  }
569
590
 
570
591
  /**
package/src/browser.ts CHANGED
@@ -128,6 +128,7 @@ export {
128
128
  registerModules,
129
129
  createRegistry,
130
130
  registerClientProcedures,
131
+ callerOf,
131
132
  // Procedure reference system (procedure-as-data)
132
133
  PROCEDURE_SYMBOL,
133
134
  PROCEDURE_JSON_KEY,
@@ -213,6 +214,7 @@ export {
213
214
 
214
215
  export type {
215
216
  Effects,
217
+ FsRootRules,
216
218
  PermittedEffects,
217
219
  Register,
218
220
  ClearanceOf,
@@ -235,6 +237,8 @@ export type {
235
237
  StorageMetadata,
236
238
  ProcedureModule,
237
239
  ModuleInput,
240
+ Caller,
241
+ CallerOptions,
238
242
  RegistrationOptions,
239
243
  ProcedureRegistryLike,
240
244
  ProcedureDefinition,
@@ -323,8 +327,9 @@ export type { TraceContext } from "./tracing/traceparent.js";
323
327
  // Wire protocol v1: the version and the one list of error codes (roadmap R25)
324
328
  export { PROTOCOL_VERSION, PROTOCOL_HEADER, ERROR_CODES, isProtocolErrorCode, httpStatusOf, protocolVersionOf } from "./protocol.js";
325
329
  export type { ProtocolErrorCode, ErrorCodeInfo } from "./protocol.js";
326
- // The seq of the stream frames: the metadata key of an item after a gap (WIRE-PROTOCOL.md)
327
- export { STREAM_GAP_METADATA_KEY } from "./adapters/sequence.js";
330
+ // The seq of the stream frames: the metadata key of an item after a gap, and the key of an item
331
+ // that the HTTP client read from an NDJSON stream (WIRE-PROTOCOL.md)
332
+ export { STREAM_GAP_METADATA_KEY, STREAM_ITEM_METADATA_KEY } from "./adapters/sequence.js";
328
333
  // The deadline of a call: the remaining time goes as procedure-timeout (roadmap R10)
329
334
  export { TIMEOUT_METADATA_KEY, DEADLINE_METADATA_KEY } from "./client/deadline.js";
330
335
  // Typed durations: each public option that is a time takes a Duration (roadmap R11)
@@ -410,6 +415,7 @@ export type {
410
415
  TypedEventBus,
411
416
  EventHandler,
412
417
  EventBusOptions,
418
+ EventListenOptions,
413
419
  EventWaitOptions,
414
420
  ChannelMap,
415
421
  } from "./events/index.js";
@@ -423,6 +429,7 @@ export type {
423
429
  RemoteEvent,
424
430
  RemoteEventHandler,
425
431
  RemoteEventsOptions,
432
+ ResubscribeBackoff,
426
433
  SubscribeOptions,
427
434
  Subscription,
428
435
  } from "./events/index.js";
@@ -84,6 +84,7 @@ const PROTOCOL_CATEGORIES: Readonly<Record<ProtocolErrorCode, ErrorCategory>> =
84
84
  CONNECTION_CLOSED: ErrorCategory.NETWORK,
85
85
  CONNECTION_NOT_FOUND: ErrorCategory.CLIENT,
86
86
  TOPIC_NOT_FOUND: ErrorCategory.CLIENT,
87
+ TOPIC_CLEARED: ErrorCategory.CANCELLED,
87
88
  };
88
89
 
89
90
  /**
@@ -108,6 +108,10 @@ export interface RetryOptions {
108
108
  /**
109
109
  * Hook called after each retry completes (success or failure).
110
110
  *
111
+ * The middleware calls it when the call ends: after the last item, or when the reader stops
112
+ * reading. A `Client` with `throwOnError` stops at the error item. Before, the hook then did
113
+ * not run, so it did not hear of a failure. A thrown error and an abort do not call it.
114
+ *
111
115
  * @param success - Whether the retry succeeded
112
116
  * @param attempt - Attempt number that just completed
113
117
  */
@@ -249,99 +253,106 @@ export function createRetryMiddleware(options: RetryOptions = {}): TypedClientMi
249
253
  const idempotencyKey = idempotencyKeyOf(context.message.metadata) ?? newIdempotencyKey();
250
254
  context.message.metadata[IDEMPOTENCY_KEY_METADATA] = idempotencyKey;
251
255
 
252
- let attempt = 0;
253
- for (;;) {
254
- // Update retry metadata with current state
255
- context.message.metadata.retry = {
256
- ...(typeof given === "object" && given !== null ? (given as object) : {}),
257
- attempt,
258
- maxAttempts: maxRetries + 1,
259
- idempotent,
260
- };
261
- // Each attempt gets its own request id. The id correlates the frames of one attempt
262
- // on the wire: a cancelled attempt can still answer under its id (deep dive TRN-8/9).
263
- context.message.id = attempt === 0 ? baseId : `${baseId}.retry${attempt}`;
256
+ // The outcome of the call for onAfterRetry. The hook runs in `finally`: a reader that stops
257
+ // at an error item (a Client with throwOnError) closes this generator at its yield. Before,
258
+ // the hook came after the yield, so it did not hear of a failure.
259
+ let outcome: { success: boolean; attempt: number } | undefined;
260
+ try {
261
+ let attempt = 0;
262
+ for (;;) {
263
+ // Update retry metadata with current state
264
+ context.message.metadata.retry = {
265
+ ...(typeof given === "object" && given !== null ? (given as object) : {}),
266
+ attempt,
267
+ maxAttempts: maxRetries + 1,
268
+ idempotent,
269
+ };
270
+ // Each attempt gets its own request id. The id correlates the frames of one attempt
271
+ // on the wire: a cancelled attempt can still answer under its id (deep dive TRN-8/9).
272
+ context.message.id = attempt === 0 ? baseId : `${baseId}.retry${attempt}`;
264
273
 
265
- // Check if cancelled before attempt
266
- if (signal?.aborted) {
267
- yield abortedItem<TRes>(context.message.id);
268
- return;
269
- }
274
+ // Check if cancelled before attempt
275
+ if (signal?.aborted) {
276
+ yield abortedItem<TRes>(context.message.id);
277
+ return;
278
+ }
270
279
 
271
- // The items pass through as they arrive (BUGS-2026-07 M2: before, the middleware
272
- // collected the whole stream first). A retry is possible only while no item has
273
- // reached the reader: the first item is a retryable error, or the attempt throws
274
- // before its first item. After that, a retry would repeat items.
275
- let started = false;
276
- let lastItem: ResponseItem<TRes> | undefined;
277
- let retryItem: ResponseItem<TRes> | undefined;
278
- try {
279
- for await (const item of next(context)) {
280
- if (!started && attempt < maxRetries && shouldRetryItem(item, attempt)) {
281
- retryItem = item;
282
- break;
280
+ // The items pass through as they arrive (BUGS-2026-07 M2: before, the middleware
281
+ // collected the whole stream first). A retry is possible only while no item has
282
+ // reached the reader: the first item is a retryable error, or the attempt throws
283
+ // before its first item. After that, a retry would repeat items.
284
+ let started = false;
285
+ let lastItem: ResponseItem<TRes> | undefined;
286
+ let retryItem: ResponseItem<TRes> | undefined;
287
+ try {
288
+ for await (const item of next(context)) {
289
+ if (!started && attempt < maxRetries && shouldRetryItem(item, attempt)) {
290
+ retryItem = item;
291
+ break;
292
+ }
293
+ started = true;
294
+ lastItem = item;
295
+ outcome = { success: item.status.type === "success", attempt };
296
+ yield item;
283
297
  }
284
- started = true;
285
- lastItem = item;
286
- yield item;
287
- }
288
- } catch (error) {
289
- const isAborted = signal?.aborted || (error instanceof Error && error.name === "AbortError");
290
- if (started || isAborted || attempt >= maxRetries) {
291
- throw error;
298
+ } catch (error) {
299
+ // A thrown error does not call onAfterRetry
300
+ outcome = undefined;
301
+ const isAborted = signal?.aborted || (error instanceof Error && error.name === "AbortError");
302
+ if (started || isAborted || attempt >= maxRetries) {
303
+ throw error;
304
+ }
305
+ // A thrown error is retried only when it is a network failure, or when shouldRetry
306
+ // says so. Before, every thrown error was retried: a validation error, an open
307
+ // circuit breaker, a rate limit (deep dive TRN-8).
308
+ const item = thrownItem<TRes>(context.message.id, error);
309
+ if (!shouldRetryItem(item, attempt)) {
310
+ throw error;
311
+ }
312
+ retryItem = item;
292
313
  }
293
- // A thrown error is retried only when it is a network failure, or when shouldRetry
294
- // says so. Before, every thrown error was retried: a validation error, an open
295
- // circuit breaker, a rate limit (deep dive TRN-8).
296
- const item = thrownItem<TRes>(context.message.id, error);
297
- if (!shouldRetryItem(item, attempt)) {
298
- throw error;
314
+
315
+ if (retryItem === undefined) {
316
+ // Success, a non-retryable error, or the last attempt
317
+ outcome = { success: lastItem?.status.type === "success", attempt };
318
+ return;
299
319
  }
300
- retryItem = item;
301
- }
302
320
 
303
- if (retryItem === undefined) {
304
- // Success, a non-retryable error, or the last attempt
305
- if (onAfterRetry) {
306
- await onAfterRetry(lastItem?.status.type === "success", attempt);
321
+ // The budget is empty: the error goes to the caller (R12)
322
+ if (!takeRetry()) {
323
+ outcome = { success: false, attempt };
324
+ yield retryItem;
325
+ return;
307
326
  }
308
- return;
309
- }
310
327
 
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
- }
328
+ // A server that asks for a wait (Retry-After) gets at least that wait (R12)
329
+ let delay = Math.max(retryDelayOf(attempt, overrides), retryAfterOf(retryItem) ?? 0);
317
330
 
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);
331
+ // Before retry hook
332
+ if (onBeforeRetry) {
333
+ const hookResult = await onBeforeRetry(retryItem, attempt);
334
+ if (!hookResult.shouldRetry) {
335
+ // Hook decided not to retry - yield the error and return
336
+ outcome = { success: false, attempt };
337
+ yield retryItem;
338
+ return;
339
+ }
320
340
 
321
- // Before retry hook
322
- if (onBeforeRetry) {
323
- const hookResult = await onBeforeRetry(retryItem, attempt);
324
- if (!hookResult.shouldRetry) {
325
- // Hook decided not to retry - yield the error and return
326
- yield retryItem;
327
- if (onAfterRetry) {
328
- await onAfterRetry(false, attempt);
341
+ // Use custom delay if provided by hook
342
+ if (hookResult.delayMs !== undefined) {
343
+ delay = hookResult.delayMs;
329
344
  }
330
- return;
331
345
  }
332
346
 
333
- // Use custom delay if provided by hook
334
- if (hookResult.delayMs !== undefined) {
335
- delay = hookResult.delayMs;
347
+ // The backoff stops at once when the caller aborts (deep dive TRN-8)
348
+ if (!(await sleep(delay, signal, clock))) {
349
+ yield abortedItem<TRes>(context.message.id);
350
+ return;
336
351
  }
352
+ attempt++;
337
353
  }
338
-
339
- // The backoff stops at once when the caller aborts (deep dive TRN-8)
340
- if (!(await sleep(delay, signal, clock))) {
341
- yield abortedItem<TRes>(context.message.id);
342
- return;
343
- }
344
- attempt++;
354
+ } finally {
355
+ if (outcome && onAfterRetry) await onAfterRetry(outcome.success, outcome.attempt);
345
356
  }
346
357
  };
347
358
  };