@panorama-industries/effect-vercel-queue 0.0.0-stage → 0.2.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 (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -2
  3. package/dist/contracts/message-handling.d.ts +128 -0
  4. package/dist/contracts/message-handling.d.ts.map +1 -0
  5. package/dist/contracts/message-handling.js +4 -0
  6. package/dist/contracts/message-handling.js.map +1 -0
  7. package/dist/contracts/queue-api.d.ts +255 -0
  8. package/dist/contracts/queue-api.d.ts.map +1 -0
  9. package/dist/contracts/queue-api.js +18 -0
  10. package/dist/contracts/queue-api.js.map +1 -0
  11. package/dist/contracts/queue-config.d.ts +87 -0
  12. package/dist/contracts/queue-config.d.ts.map +1 -0
  13. package/dist/contracts/queue-config.js +7 -0
  14. package/dist/contracts/queue-config.js.map +1 -0
  15. package/dist/contracts/queue-consumer.d.ts +30 -0
  16. package/dist/contracts/queue-consumer.d.ts.map +1 -0
  17. package/dist/contracts/queue-consumer.js +15 -0
  18. package/dist/contracts/queue-consumer.js.map +1 -0
  19. package/dist/contracts/queue-domain.d.ts +171 -0
  20. package/dist/contracts/queue-domain.d.ts.map +1 -0
  21. package/dist/contracts/queue-domain.js +137 -0
  22. package/dist/contracts/queue-domain.js.map +1 -0
  23. package/dist/contracts/queue-poller.d.ts +169 -0
  24. package/dist/contracts/queue-poller.d.ts.map +1 -0
  25. package/dist/contracts/queue-poller.js +10 -0
  26. package/dist/contracts/queue-poller.js.map +1 -0
  27. package/dist/contracts/queue-producer.d.ts +23 -0
  28. package/dist/contracts/queue-producer.d.ts.map +1 -0
  29. package/dist/contracts/queue-producer.js +5 -0
  30. package/dist/contracts/queue-producer.js.map +1 -0
  31. package/dist/contracts/topic.d.ts +47 -0
  32. package/dist/contracts/topic.d.ts.map +1 -0
  33. package/dist/contracts/topic.js +34 -0
  34. package/dist/contracts/topic.js.map +1 -0
  35. package/dist/implementation/queue-api-live.d.ts +16 -0
  36. package/dist/implementation/queue-api-live.d.ts.map +1 -0
  37. package/dist/implementation/queue-api-live.js +326 -0
  38. package/dist/implementation/queue-api-live.js.map +1 -0
  39. package/dist/implementation/queue-config-live.d.ts +37 -0
  40. package/dist/implementation/queue-config-live.d.ts.map +1 -0
  41. package/dist/implementation/queue-config-live.js +99 -0
  42. package/dist/implementation/queue-config-live.js.map +1 -0
  43. package/dist/implementation/queue-consumer-live.d.ts +14 -0
  44. package/dist/implementation/queue-consumer-live.d.ts.map +1 -0
  45. package/dist/implementation/queue-consumer-live.js +56 -0
  46. package/dist/implementation/queue-consumer-live.js.map +1 -0
  47. package/dist/implementation/queue-live.d.ts +15 -0
  48. package/dist/implementation/queue-live.d.ts.map +1 -0
  49. package/dist/implementation/queue-live.js +16 -0
  50. package/dist/implementation/queue-live.js.map +1 -0
  51. package/dist/implementation/queue-poller-live.d.ts +14 -0
  52. package/dist/implementation/queue-poller-live.d.ts.map +1 -0
  53. package/dist/implementation/queue-poller-live.js +66 -0
  54. package/dist/implementation/queue-poller-live.js.map +1 -0
  55. package/dist/implementation/queue-producer-live.d.ts +9 -0
  56. package/dist/implementation/queue-producer-live.d.ts.map +1 -0
  57. package/dist/implementation/queue-producer-live.js +54 -0
  58. package/dist/implementation/queue-producer-live.js.map +1 -0
  59. package/dist/index.d.ts +16 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +16 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/internal/cloud-event.d.ts +46 -0
  64. package/dist/internal/cloud-event.d.ts.map +1 -0
  65. package/dist/internal/cloud-event.js +140 -0
  66. package/dist/internal/cloud-event.js.map +1 -0
  67. package/dist/internal/lease.d.ts +23 -0
  68. package/dist/internal/lease.d.ts.map +1 -0
  69. package/dist/internal/lease.js +44 -0
  70. package/dist/internal/lease.js.map +1 -0
  71. package/dist/internal/multipart.d.ts +23 -0
  72. package/dist/internal/multipart.d.ts.map +1 -0
  73. package/dist/internal/multipart.js +205 -0
  74. package/dist/internal/multipart.js.map +1 -0
  75. package/dist/internal/process.d.ts +25 -0
  76. package/dist/internal/process.d.ts.map +1 -0
  77. package/dist/internal/process.js +95 -0
  78. package/dist/internal/process.js.map +1 -0
  79. package/dist/internal/queue-protocol.d.ts +38 -0
  80. package/dist/internal/queue-protocol.d.ts.map +1 -0
  81. package/dist/internal/queue-protocol.js +119 -0
  82. package/dist/internal/queue-protocol.js.map +1 -0
  83. package/dist/internal/version.d.ts +4 -0
  84. package/dist/internal/version.d.ts.map +1 -0
  85. package/dist/internal/version.js +4 -0
  86. package/dist/internal/version.js.map +1 -0
  87. package/dist/retry-policy.d.ts +26 -0
  88. package/dist/retry-policy.d.ts.map +1 -0
  89. package/dist/retry-policy.js +34 -0
  90. package/dist/retry-policy.js.map +1 -0
  91. package/dist/testing/index.d.ts +2 -0
  92. package/dist/testing/index.d.ts.map +1 -0
  93. package/dist/testing/index.js +2 -0
  94. package/dist/testing/index.js.map +1 -0
  95. package/dist/testing/test-queue.d.ts +74 -0
  96. package/dist/testing/test-queue.d.ts.map +1 -0
  97. package/dist/testing/test-queue.js +253 -0
  98. package/dist/testing/test-queue.js.map +1 -0
  99. package/package.json +59 -3
  100. package/src/contracts/message-handling.ts +68 -0
  101. package/src/contracts/queue-api.ts +199 -0
  102. package/src/contracts/queue-config.ts +47 -0
  103. package/src/contracts/queue-consumer.ts +40 -0
  104. package/src/contracts/queue-domain.ts +202 -0
  105. package/src/contracts/queue-poller.ts +76 -0
  106. package/src/contracts/queue-producer.ts +40 -0
  107. package/src/contracts/topic.ts +76 -0
  108. package/src/implementation/queue-api-live.ts +637 -0
  109. package/src/implementation/queue-config-live.ts +178 -0
  110. package/src/implementation/queue-consumer-live.ts +112 -0
  111. package/src/implementation/queue-live.ts +32 -0
  112. package/src/implementation/queue-poller-live.ts +130 -0
  113. package/src/implementation/queue-producer-live.ts +89 -0
  114. package/src/index.ts +19 -0
  115. package/src/internal/cloud-event.ts +244 -0
  116. package/src/internal/lease.ts +84 -0
  117. package/src/internal/multipart.ts +240 -0
  118. package/src/internal/process.ts +194 -0
  119. package/src/internal/queue-protocol.ts +169 -0
  120. package/src/internal/version.ts +5 -0
  121. package/src/retry-policy.ts +48 -0
  122. package/src/testing/index.ts +1 -0
  123. package/src/testing/test-queue.ts +469 -0
@@ -0,0 +1,194 @@
1
+ import type { Tracer } from "effect";
2
+
3
+ import { Duration, Effect, Option, Schema } from "effect";
4
+ import { Headers, HttpTraceContext } from "effect/http";
5
+
6
+ import type {
7
+ ConsumeFailure,
8
+ ConsumeOptions,
9
+ MessageHandler,
10
+ } from "../contracts/message-handling.ts";
11
+ import type { QueueApi, QueueLeaseError } from "../contracts/queue-api.ts";
12
+ import type { ReceivedMessage } from "../contracts/queue-domain.ts";
13
+ import type { Topic } from "../contracts/topic.ts";
14
+ import type { LeaseDeadline } from "./lease.ts";
15
+
16
+ import { RetryDirective } from "../contracts/message-handling.ts";
17
+ import { MessageMetadata, PayloadDecodeFailed } from "../contracts/queue-domain.ts";
18
+ import { keepLeaseAlive, leaseVisibilityTimeout } from "./lease.ts";
19
+
20
+ export interface Delivery {
21
+ readonly message: ReceivedMessage;
22
+ readonly deadline: LeaseDeadline;
23
+ }
24
+
25
+ /** Errors a processed delivery can end with once its error policy answered `Fail`. */
26
+ export type ProcessError<E> = E | PayloadDecodeFailed | QueueLeaseError;
27
+
28
+ interface Escalation<E> {
29
+ readonly forPolicy: Option.Option<E | ConsumeFailure>;
30
+ readonly error: ProcessError<E>;
31
+ }
32
+
33
+ const escalate = <E>(
34
+ forPolicy: Option.Option<E | ConsumeFailure>,
35
+ error: ProcessError<E>,
36
+ ): Escalation<E> => ({ forPolicy, error });
37
+
38
+ const toPolicy = <E>(error: E | PayloadDecodeFailed): Escalation<E> =>
39
+ escalate<E>(Option.some(error), error);
40
+
41
+ /**
42
+ * Leases, decodes, handles and settles one delivered message, following `@vercel/queue`'s
43
+ * `ConsumerGroup.processMessage`.
44
+ */
45
+ export const processDelivery = <A, I, E, R, R2>(
46
+ api: QueueApi["Service"],
47
+ topic: Topic<A, I>,
48
+ handler: MessageHandler<A, E, R>,
49
+ options: ConsumeOptions<E, R2>,
50
+ delivery: Delivery,
51
+ ): Effect.Effect<void, ProcessError<E>, R | R2> => {
52
+ const { handle, metadata, body } = delivery.message;
53
+ const timeout = leaseVisibilityTimeout(options.visibilityTimeout);
54
+ const maxReruns = Math.max(0, Math.floor(options.experimentalMaxCoalescedReruns ?? 0));
55
+
56
+ const decode = Schema.decodeEffect(topic.wire)(body).pipe(
57
+ Effect.mapError(
58
+ (error) =>
59
+ new PayloadDecodeFailed({
60
+ topic: handle.topic,
61
+ messageId: handle.messageId,
62
+ message: error.message,
63
+ }),
64
+ ),
65
+ );
66
+
67
+ const runHandler = (payload: A, meta: MessageMetadata, deadline: LeaseDeadline) =>
68
+ Effect.scoped(
69
+ keepLeaseAlive(api, handle, timeout, deadline).pipe(Effect.andThen(handler(payload, meta))),
70
+ ).pipe(Effect.mapError(toPolicy));
71
+
72
+ const settleFailure = (error: QueueLeaseError) => Effect.fail(escalate<E>(Option.none(), error));
73
+
74
+ const acknowledge = (rerun: number, coalescedCount: number) =>
75
+ api
76
+ .acknowledge(
77
+ handle,
78
+ coalescedCount > 0 ? { experimentalIfCoalesced: coalescedCount } : undefined,
79
+ )
80
+ .pipe(
81
+ Effect.as(Option.none<number>()),
82
+ Effect.catchReason("MessageNotAvailable", "ConcurrencyCoalescedPending", (reason, error) =>
83
+ rerun > maxReruns
84
+ ? Effect.fail(escalate<E>(Option.some(reason), error))
85
+ : Effect.succeed(Option.some(reason.coalescedCount)),
86
+ ),
87
+ Effect.catchTags({
88
+ MessageNotAvailable: settleFailure,
89
+ MessageNotFound: settleFailure,
90
+ VercelApiError: settleFailure,
91
+ ConsumerRegistryNotConfigured: settleFailure,
92
+ }),
93
+ );
94
+
95
+ const attempt = Effect.gen(function* () {
96
+ const payload = yield* Effect.mapError(decode, toPolicy);
97
+ yield* runHandler(payload, metadata, delivery.deadline);
98
+ let coalescedCount = 0;
99
+ for (let rerun = 1; ; rerun++) {
100
+ const refused = yield* acknowledge(rerun, coalescedCount);
101
+ if (Option.isNone(refused)) return;
102
+ coalescedCount = refused.value;
103
+ yield* runHandler(
104
+ payload,
105
+ new MessageMetadata({
106
+ messageId: metadata.messageId,
107
+ deliveryCount: metadata.deliveryCount,
108
+ createdAt: metadata.createdAt,
109
+ expiresAt: metadata.expiresAt,
110
+ topic: metadata.topic,
111
+ consumerGroup: metadata.consumerGroup,
112
+ region: metadata.region,
113
+ ...(metadata.experimentalHandoffCount === undefined
114
+ ? {}
115
+ : { experimentalHandoffCount: metadata.experimentalHandoffCount }),
116
+ experimentalCoalesced: { rerun, coalescedCount },
117
+ }),
118
+ Option.some("expired"),
119
+ );
120
+ }
121
+ });
122
+
123
+ const chooseDirective = (error: E | ConsumeFailure) =>
124
+ options.onError === undefined
125
+ ? Effect.succeed(RetryDirective.Fail())
126
+ : options
127
+ .onError(error, metadata)
128
+ .pipe(
129
+ Effect.catchDefect((defect) =>
130
+ Effect.logWarning("Queue error policy failed; failing the delivery", defect).pipe(
131
+ Effect.as(RetryDirective.Fail()),
132
+ ),
133
+ ),
134
+ );
135
+
136
+ const applyDirective = (directive: RetryDirective, error: ProcessError<E>) =>
137
+ RetryDirective.$match(directive, {
138
+ Acknowledge: () =>
139
+ api
140
+ .acknowledge(handle)
141
+ .pipe(Effect.catch((cause) => Effect.logWarning("Failed to acknowledge message", cause))),
142
+ RetryAfter: ({ delay }) =>
143
+ api.changeVisibility(handle, delay).pipe(
144
+ Effect.asVoid,
145
+ Effect.catch((cause) =>
146
+ Effect.logWarning("Failed to reschedule message for retry", cause),
147
+ ),
148
+ ),
149
+ ExperimentalHandoff: () =>
150
+ api.changeVisibility(handle, Duration.zero, { experimentalHandoff: true }).pipe(
151
+ Effect.asVoid,
152
+ Effect.catch((cause) => Effect.logWarning("Failed to hand off message", cause)),
153
+ ),
154
+ Fail: () => Effect.fail(error),
155
+ });
156
+
157
+ return attempt.pipe(
158
+ Effect.catch(({ forPolicy, error }) =>
159
+ Option.match(forPolicy, {
160
+ onNone: () => Effect.fail(error),
161
+ onSome: (cause) =>
162
+ chooseDirective(cause).pipe(
163
+ Effect.flatMap((directive) => applyDirective(directive, error)),
164
+ ),
165
+ }),
166
+ ),
167
+ Effect.annotateLogs({ module: "QueueConsumer", messageId: handle.messageId }),
168
+ );
169
+ };
170
+
171
+ const PRODUCER_TRACEPARENT = "x-vercel-queue-traceparent";
172
+
173
+ /** Links the producer's span, delivered in `x-vercel-queue-traceparent`, without parenting to it. */
174
+ export const producerLinks = (headers: Headers.Headers): ReadonlyArray<Tracer.SpanLink> => {
175
+ const traceparent = headers[PRODUCER_TRACEPARENT];
176
+ if (!traceparent) return [];
177
+ return Option.match(HttpTraceContext.w3c(Headers.fromInput({ traceparent })), {
178
+ onNone: () => [],
179
+ onSome: (span) => [{ span, attributes: {} }],
180
+ });
181
+ };
182
+
183
+ /** Runs `self` in a consumer-kind `vqs.process` span, as `@vercel/queue`'s `withConsumerSpan`. */
184
+ export const withProcessSpan =
185
+ (links: ReadonlyArray<Tracer.SpanLink>) =>
186
+ <A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
187
+ Effect.withSpan(self, "vqs.process", {
188
+ kind: "consumer",
189
+ links,
190
+ attributes: {
191
+ "messaging.system": "vercel-queue",
192
+ "messaging.operation": "process",
193
+ },
194
+ });
@@ -0,0 +1,169 @@
1
+ import { DateTime, Duration, Option, Predicate, Schema } from "effect";
2
+
3
+ import type { OutgoingMessage } from "../contracts/queue-api.ts";
4
+
5
+ export const errorCodeHeader = "Vqs-Error-Code";
6
+
7
+ export const apiBaseUrl = (url: URL): string =>
8
+ `${url.origin}${url.pathname.replace(/\/+$/, "")}/api/v3/topic`;
9
+
10
+ export const topicPath = (topic: string, ...segments: ReadonlyArray<string>): string =>
11
+ [topic, ...segments].map((segment) => `/${encodeURIComponent(segment)}`).join("");
12
+
13
+ export const wholeSeconds = (input: Duration.Input): number =>
14
+ Math.ceil(Duration.toMillis(Duration.fromInputUnsafe(input)) / 1000);
15
+
16
+ const trimHeaderValue = (value: string) => value.replace(/^[\t\n\r ]+|[\t\n\r ]+$/g, "");
17
+
18
+ /** A case-insensitive header set with the same overwrite semantics as `Headers.set`. */
19
+ export class HeaderSet {
20
+ private readonly entries = new Map<string, string>();
21
+
22
+ constructor(init: Readonly<Record<string, string>> = {}) {
23
+ for (const [name, value] of Object.entries(init)) this.set(name, value);
24
+ }
25
+
26
+ set(name: string, value: string): this {
27
+ this.entries.set(name.toLowerCase(), trimHeaderValue(value));
28
+ return this;
29
+ }
30
+
31
+ delete(name: string): this {
32
+ this.entries.delete(name.toLowerCase());
33
+ return this;
34
+ }
35
+
36
+ get(name: string): string | undefined {
37
+ return this.entries.get(name.toLowerCase());
38
+ }
39
+
40
+ toRecord(): Record<string, string> {
41
+ return Object.fromEntries(this.entries);
42
+ }
43
+
44
+ /** Entries sorted by name, as `Headers` iterates them. */
45
+ sorted(): Array<readonly [string, string]> {
46
+ return [...this.entries].toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
47
+ }
48
+ }
49
+
50
+ const isReservedMessageHeader = (name: string) => {
51
+ const lower = name.toLowerCase();
52
+ return lower === "authorization" || lower === "content-type" || lower.startsWith("vqs-");
53
+ };
54
+
55
+ export const messageHeaders = (
56
+ custom: Readonly<Record<string, string>>,
57
+ message: OutgoingMessage,
58
+ ): HeaderSet => {
59
+ const headers = new HeaderSet(custom).delete("authorization");
60
+ for (const [name, value] of Object.entries(message.headers ?? {})) {
61
+ if (!isReservedMessageHeader(name)) headers.set(name, value);
62
+ }
63
+ headers.set("content-type", message.contentType);
64
+ if (message.idempotencyKey) headers.set("vqs-idempotency-key", message.idempotencyKey);
65
+ if (message.retention !== undefined) {
66
+ headers.set("vqs-retention-seconds", String(wholeSeconds(message.retention)));
67
+ }
68
+ if (message.delay !== undefined) {
69
+ headers.set("vqs-delay-seconds", String(wholeSeconds(message.delay)));
70
+ }
71
+ if (message.experimentalConcurrencyKey) {
72
+ headers.set("vqs-experimental-concurrency-key", message.experimentalConcurrencyKey);
73
+ }
74
+ return headers;
75
+ };
76
+
77
+ export const retryAfterFromHeader = (
78
+ value: string | undefined,
79
+ nowMillis: number,
80
+ ): Option.Option<Duration.Duration> => {
81
+ if (!value) return Option.none();
82
+ const seconds = Number(value);
83
+ if (Number.isFinite(seconds) && seconds >= 0) return Option.some(Duration.seconds(seconds));
84
+ const dateMillis = Date.parse(value);
85
+ return Number.isNaN(dateMillis)
86
+ ? Option.none()
87
+ : Option.some(Duration.millis(Math.max(0, dateMillis - nowMillis)));
88
+ };
89
+
90
+ export interface ErrorBody {
91
+ readonly text: string;
92
+ readonly message: string;
93
+ readonly originalMessageId: Option.Option<string>;
94
+ }
95
+
96
+ const decodeJsonObject = Schema.decodeUnknownOption(
97
+ Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)),
98
+ );
99
+
100
+ export const errorBodyOf = (text: string, contentType: string | undefined): ErrorBody => {
101
+ const plain: ErrorBody = { text, message: text, originalMessageId: Option.none() };
102
+ if (!contentType?.toLowerCase().includes("json")) return plain;
103
+ return Option.match(decodeJsonObject(text), {
104
+ onNone: () => plain,
105
+ onSome: (body) => {
106
+ const error = body["error"];
107
+ const message = Predicate.isString(error)
108
+ ? error
109
+ : Predicate.isObject(error) && Predicate.isString(error["message"])
110
+ ? error["message"]
111
+ : text;
112
+ const original = body["originalMessageId"];
113
+ return {
114
+ text,
115
+ message,
116
+ originalMessageId: Predicate.isString(original) ? Option.some(original) : Option.none(),
117
+ };
118
+ },
119
+ });
120
+ };
121
+
122
+ const positiveInt = (value: unknown): Option.Option<number> =>
123
+ Predicate.isNumber(value) && Number.isInteger(value) && value > 0
124
+ ? Option.some(value)
125
+ : Option.none();
126
+
127
+ export const coalescedPendingCount = (
128
+ errorCode: string | undefined,
129
+ coalescedHeader: string | undefined,
130
+ text: string,
131
+ ): Option.Option<number> => {
132
+ if (errorCode !== "concurrency_coalesced_pending") return Option.none();
133
+ return Option.orElse(positiveInt(Number(coalescedHeader ?? null)), () =>
134
+ Option.flatMap(decodeJsonObject(text), (body) => positiveInt(body["coalescedCount"])),
135
+ );
136
+ };
137
+
138
+ export interface QueuePartHeaders {
139
+ readonly messageId: string;
140
+ readonly receiptHandle: string;
141
+ readonly deliveryCount: number;
142
+ readonly timestamp: string;
143
+ readonly expiresAt: Option.Option<string>;
144
+ readonly contentType: string;
145
+ readonly handoffCount: Option.Option<number>;
146
+ }
147
+
148
+ export const queuePartHeaders = (
149
+ headers: Readonly<Record<string, string>>,
150
+ ): Option.Option<QueuePartHeaders> => {
151
+ const messageId = headers["vqs-message-id"];
152
+ const timestamp = headers["vqs-timestamp"];
153
+ const receiptHandle = headers["vqs-receipt-handle"];
154
+ if (!messageId || !timestamp || !receiptHandle) return Option.none();
155
+ const deliveryCount = Number.parseInt(headers["vqs-delivery-count"] || "0", 10);
156
+ if (Number.isNaN(deliveryCount)) return Option.none();
157
+ return Option.some({
158
+ messageId,
159
+ receiptHandle,
160
+ deliveryCount,
161
+ timestamp,
162
+ expiresAt: Option.fromUndefinedOr(headers["vqs-expires-at"] || undefined),
163
+ contentType: headers["content-type"] || "application/octet-stream",
164
+ handoffCount: positiveInt(Number(headers["vqs-experimental-handoff-count"] || Number.NaN)),
165
+ });
166
+ };
167
+
168
+ export const parseDate = (value: string): Option.Option<DateTime.Utc> =>
169
+ DateTime.make(new Date(value));
@@ -0,0 +1,5 @@
1
+ export const packageName = "@panorama-industries/effect-vercel-queue";
2
+
3
+ export const packageVersion = "0.2.0";
4
+
5
+ export const userAgent = `${packageName}/${packageVersion}`;
@@ -0,0 +1,48 @@
1
+ import { Clock, Effect, Option, Pull, Schedule } from "effect";
2
+
3
+ import type { MessageMetadata } from "./contracts/queue-domain.ts";
4
+
5
+ import { RetryDirective } from "./contracts/message-handling.ts";
6
+
7
+ /**
8
+ * Build an `onError` policy that redelivers failed messages on a `Schedule`.
9
+ *
10
+ * The n-th failed delivery (`deliveryCount` minus experimental handoffs, which do not count as
11
+ * retries) waits for the schedule's n-th delay: the policy answers `RetryAfter(delay)` while the
12
+ * schedule continues and `onExhausted` once it is done. The schedule receives the error as input
13
+ * and is stepped from a fresh state on every call, all at the current time, so elapsed-time
14
+ * schedules see no elapsed time.
15
+ *
16
+ * ```ts
17
+ * consumer.handler(Orders, fulfil, {
18
+ * onError: retryOnSchedule(Schedule.exponential("1 second").pipe(Schedule.upTo({ times: 5 }))),
19
+ * });
20
+ * ```
21
+ *
22
+ * @param schedule - Delay of each retry; its outputs are ignored.
23
+ * @param options.onExhausted - Directive once the schedule is done. Default: `Acknowledge`, so the
24
+ * message is dropped.
25
+ */
26
+ export const retryOnSchedule =
27
+ <Out, In, R>(
28
+ schedule: Schedule.Schedule<Out, In, never, R>,
29
+ options?: { readonly onExhausted?: RetryDirective },
30
+ ) =>
31
+ (error: In, metadata: MessageMetadata): Effect.Effect<RetryDirective, never, R> =>
32
+ Effect.gen(function* () {
33
+ const step = yield* Schedule.toStep(schedule);
34
+ const now = yield* Clock.currentTimeMillis;
35
+ const attempt = Math.max(
36
+ 1,
37
+ metadata.deliveryCount - (metadata.experimentalHandoffCount ?? 0),
38
+ );
39
+ let delay = Option.none<RetryDirective>();
40
+ for (let n = 0; n < attempt; n++) {
41
+ delay = yield* step(now, error).pipe(
42
+ Effect.map(([, duration]) => Option.some(RetryDirective.RetryAfter({ delay: duration }))),
43
+ Pull.catchDone(() => Effect.succeed(Option.none<RetryDirective>())),
44
+ );
45
+ if (Option.isNone(delay)) break;
46
+ }
47
+ return Option.getOrElse(delay, () => options?.onExhausted ?? RetryDirective.Acknowledge());
48
+ });
@@ -0,0 +1 @@
1
+ export * from "./test-queue.ts";