@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.
- package/LICENSE +21 -0
- package/README.md +13 -2
- package/dist/contracts/message-handling.d.ts +128 -0
- package/dist/contracts/message-handling.d.ts.map +1 -0
- package/dist/contracts/message-handling.js +4 -0
- package/dist/contracts/message-handling.js.map +1 -0
- package/dist/contracts/queue-api.d.ts +255 -0
- package/dist/contracts/queue-api.d.ts.map +1 -0
- package/dist/contracts/queue-api.js +18 -0
- package/dist/contracts/queue-api.js.map +1 -0
- package/dist/contracts/queue-config.d.ts +87 -0
- package/dist/contracts/queue-config.d.ts.map +1 -0
- package/dist/contracts/queue-config.js +7 -0
- package/dist/contracts/queue-config.js.map +1 -0
- package/dist/contracts/queue-consumer.d.ts +30 -0
- package/dist/contracts/queue-consumer.d.ts.map +1 -0
- package/dist/contracts/queue-consumer.js +15 -0
- package/dist/contracts/queue-consumer.js.map +1 -0
- package/dist/contracts/queue-domain.d.ts +171 -0
- package/dist/contracts/queue-domain.d.ts.map +1 -0
- package/dist/contracts/queue-domain.js +137 -0
- package/dist/contracts/queue-domain.js.map +1 -0
- package/dist/contracts/queue-poller.d.ts +169 -0
- package/dist/contracts/queue-poller.d.ts.map +1 -0
- package/dist/contracts/queue-poller.js +10 -0
- package/dist/contracts/queue-poller.js.map +1 -0
- package/dist/contracts/queue-producer.d.ts +23 -0
- package/dist/contracts/queue-producer.d.ts.map +1 -0
- package/dist/contracts/queue-producer.js +5 -0
- package/dist/contracts/queue-producer.js.map +1 -0
- package/dist/contracts/topic.d.ts +47 -0
- package/dist/contracts/topic.d.ts.map +1 -0
- package/dist/contracts/topic.js +34 -0
- package/dist/contracts/topic.js.map +1 -0
- package/dist/implementation/queue-api-live.d.ts +16 -0
- package/dist/implementation/queue-api-live.d.ts.map +1 -0
- package/dist/implementation/queue-api-live.js +326 -0
- package/dist/implementation/queue-api-live.js.map +1 -0
- package/dist/implementation/queue-config-live.d.ts +37 -0
- package/dist/implementation/queue-config-live.d.ts.map +1 -0
- package/dist/implementation/queue-config-live.js +99 -0
- package/dist/implementation/queue-config-live.js.map +1 -0
- package/dist/implementation/queue-consumer-live.d.ts +14 -0
- package/dist/implementation/queue-consumer-live.d.ts.map +1 -0
- package/dist/implementation/queue-consumer-live.js +56 -0
- package/dist/implementation/queue-consumer-live.js.map +1 -0
- package/dist/implementation/queue-live.d.ts +15 -0
- package/dist/implementation/queue-live.d.ts.map +1 -0
- package/dist/implementation/queue-live.js +16 -0
- package/dist/implementation/queue-live.js.map +1 -0
- package/dist/implementation/queue-poller-live.d.ts +14 -0
- package/dist/implementation/queue-poller-live.d.ts.map +1 -0
- package/dist/implementation/queue-poller-live.js +66 -0
- package/dist/implementation/queue-poller-live.js.map +1 -0
- package/dist/implementation/queue-producer-live.d.ts +9 -0
- package/dist/implementation/queue-producer-live.d.ts.map +1 -0
- package/dist/implementation/queue-producer-live.js +54 -0
- package/dist/implementation/queue-producer-live.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/cloud-event.d.ts +46 -0
- package/dist/internal/cloud-event.d.ts.map +1 -0
- package/dist/internal/cloud-event.js +140 -0
- package/dist/internal/cloud-event.js.map +1 -0
- package/dist/internal/lease.d.ts +23 -0
- package/dist/internal/lease.d.ts.map +1 -0
- package/dist/internal/lease.js +44 -0
- package/dist/internal/lease.js.map +1 -0
- package/dist/internal/multipart.d.ts +23 -0
- package/dist/internal/multipart.d.ts.map +1 -0
- package/dist/internal/multipart.js +205 -0
- package/dist/internal/multipart.js.map +1 -0
- package/dist/internal/process.d.ts +25 -0
- package/dist/internal/process.d.ts.map +1 -0
- package/dist/internal/process.js +95 -0
- package/dist/internal/process.js.map +1 -0
- package/dist/internal/queue-protocol.d.ts +38 -0
- package/dist/internal/queue-protocol.d.ts.map +1 -0
- package/dist/internal/queue-protocol.js +119 -0
- package/dist/internal/queue-protocol.js.map +1 -0
- package/dist/internal/version.d.ts +4 -0
- package/dist/internal/version.d.ts.map +1 -0
- package/dist/internal/version.js +4 -0
- package/dist/internal/version.js.map +1 -0
- package/dist/retry-policy.d.ts +26 -0
- package/dist/retry-policy.d.ts.map +1 -0
- package/dist/retry-policy.js +34 -0
- package/dist/retry-policy.js.map +1 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +2 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/test-queue.d.ts +74 -0
- package/dist/testing/test-queue.d.ts.map +1 -0
- package/dist/testing/test-queue.js +253 -0
- package/dist/testing/test-queue.js.map +1 -0
- package/package.json +59 -3
- package/src/contracts/message-handling.ts +68 -0
- package/src/contracts/queue-api.ts +199 -0
- package/src/contracts/queue-config.ts +47 -0
- package/src/contracts/queue-consumer.ts +40 -0
- package/src/contracts/queue-domain.ts +202 -0
- package/src/contracts/queue-poller.ts +76 -0
- package/src/contracts/queue-producer.ts +40 -0
- package/src/contracts/topic.ts +76 -0
- package/src/implementation/queue-api-live.ts +637 -0
- package/src/implementation/queue-config-live.ts +178 -0
- package/src/implementation/queue-consumer-live.ts +112 -0
- package/src/implementation/queue-live.ts +32 -0
- package/src/implementation/queue-poller-live.ts +130 -0
- package/src/implementation/queue-producer-live.ts +89 -0
- package/src/index.ts +19 -0
- package/src/internal/cloud-event.ts +244 -0
- package/src/internal/lease.ts +84 -0
- package/src/internal/multipart.ts +240 -0
- package/src/internal/process.ts +194 -0
- package/src/internal/queue-protocol.ts +169 -0
- package/src/internal/version.ts +5 -0
- package/src/retry-policy.ts +48 -0
- package/src/testing/index.ts +1 -0
- package/src/testing/test-queue.ts +469 -0
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
import type { VercelRegion } from "@panorama-industries/effect-vercel-core";
|
|
2
|
+
import type { HttpClientError, HttpClientResponse } from "effect/http";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ApiRateLimited,
|
|
6
|
+
ApiUnauthorized,
|
|
7
|
+
ApiUnexpectedResponse,
|
|
8
|
+
VercelApiError,
|
|
9
|
+
VercelOidc,
|
|
10
|
+
vercelApiErrorFromHttpClientError,
|
|
11
|
+
vercelApiErrorFromResponse,
|
|
12
|
+
withVercelApi,
|
|
13
|
+
} from "@panorama-industries/effect-vercel-core";
|
|
14
|
+
import {
|
|
15
|
+
Array as Arr,
|
|
16
|
+
Clock,
|
|
17
|
+
DateTime,
|
|
18
|
+
Duration,
|
|
19
|
+
Effect,
|
|
20
|
+
Layer,
|
|
21
|
+
Option,
|
|
22
|
+
Predicate,
|
|
23
|
+
Random,
|
|
24
|
+
Schedule,
|
|
25
|
+
Schema,
|
|
26
|
+
Stream,
|
|
27
|
+
} from "effect";
|
|
28
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
29
|
+
|
|
30
|
+
import type {
|
|
31
|
+
OutgoingMessage,
|
|
32
|
+
QueueLeaseError,
|
|
33
|
+
QueueReceiveByIdError,
|
|
34
|
+
QueueRequestError,
|
|
35
|
+
QueueSendBatchError,
|
|
36
|
+
QueueSendError,
|
|
37
|
+
ReceiveByIdOptions,
|
|
38
|
+
ReceiveOptions,
|
|
39
|
+
} from "../contracts/queue-api.ts";
|
|
40
|
+
import type { ConsumerGroupName, TopicName } from "../contracts/queue-domain.ts";
|
|
41
|
+
import type { MultipartPart } from "../internal/multipart.ts";
|
|
42
|
+
import type { QueuePartHeaders } from "../internal/queue-protocol.ts";
|
|
43
|
+
|
|
44
|
+
import { BatchSendResult, HandoffOutcome, QueueApi, SendResult } from "../contracts/queue-api.ts";
|
|
45
|
+
import { QueueAuth, QueueConfig } from "../contracts/queue-config.ts";
|
|
46
|
+
import {
|
|
47
|
+
BatchSizeOutOfRange,
|
|
48
|
+
ConcurrencyCoalescedPending,
|
|
49
|
+
ConsumerDiscoveryFailed,
|
|
50
|
+
ConsumerRegistryNotConfigured,
|
|
51
|
+
MessageAlreadyProcessed,
|
|
52
|
+
MessageCorrupted,
|
|
53
|
+
MessageHandle,
|
|
54
|
+
MessageId,
|
|
55
|
+
MessageLocked,
|
|
56
|
+
MessageMetadata,
|
|
57
|
+
MessageNotAvailable,
|
|
58
|
+
MessageNotFound,
|
|
59
|
+
MessageUnavailable,
|
|
60
|
+
ReceiptHandle,
|
|
61
|
+
ReceivedMessage,
|
|
62
|
+
} from "../contracts/queue-domain.ts";
|
|
63
|
+
import {
|
|
64
|
+
boundaryFromContentType,
|
|
65
|
+
decodeMultipartMixed,
|
|
66
|
+
encodeMultipartMixed,
|
|
67
|
+
} from "../internal/multipart.ts";
|
|
68
|
+
import {
|
|
69
|
+
HeaderSet,
|
|
70
|
+
apiBaseUrl,
|
|
71
|
+
coalescedPendingCount,
|
|
72
|
+
errorBodyOf,
|
|
73
|
+
errorCodeHeader,
|
|
74
|
+
messageHeaders,
|
|
75
|
+
parseDate,
|
|
76
|
+
queuePartHeaders,
|
|
77
|
+
retryAfterFromHeader,
|
|
78
|
+
topicPath,
|
|
79
|
+
wholeSeconds,
|
|
80
|
+
} from "../internal/queue-protocol.ts";
|
|
81
|
+
import { userAgent } from "../internal/version.ts";
|
|
82
|
+
|
|
83
|
+
type Response = HttpClientResponse.HttpClientResponse;
|
|
84
|
+
|
|
85
|
+
const defaultRetention = Duration.hours(24);
|
|
86
|
+
|
|
87
|
+
const leaseCallAttempts = 3;
|
|
88
|
+
|
|
89
|
+
const SendResponse = Schema.fromJsonString(
|
|
90
|
+
Schema.Struct({ messageId: Schema.NullOr(Schema.String) }),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const BatchResponse = Schema.fromJsonString(
|
|
94
|
+
Schema.Struct({
|
|
95
|
+
results: Schema.Array(
|
|
96
|
+
Schema.Union([
|
|
97
|
+
Schema.Struct({ status: Schema.Literal("sent"), messageId: MessageId }),
|
|
98
|
+
Schema.Struct({ status: Schema.Literal("deferred") }),
|
|
99
|
+
Schema.Struct({
|
|
100
|
+
status: Schema.Literal("failed"),
|
|
101
|
+
statusCode: Schema.Int,
|
|
102
|
+
error: Schema.String,
|
|
103
|
+
retryable: Schema.Boolean,
|
|
104
|
+
}),
|
|
105
|
+
]),
|
|
106
|
+
),
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const decodeHandoffOutcome = Schema.decodeUnknownOption(HandoffOutcome);
|
|
111
|
+
|
|
112
|
+
const header = (response: Response, name: string): string | undefined =>
|
|
113
|
+
response.headers[name.toLowerCase()];
|
|
114
|
+
|
|
115
|
+
const unexpected = (operation: string, status: number, message: string) =>
|
|
116
|
+
new VercelApiError({
|
|
117
|
+
operation,
|
|
118
|
+
errorCode: Option.none(),
|
|
119
|
+
reason: new ApiUnexpectedResponse({ message, status }),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const isRetryableLeaseError = (error: unknown): boolean =>
|
|
123
|
+
error instanceof VercelApiError &&
|
|
124
|
+
(Predicate.isTagged(error.reason, "ApiNetworkFailure") ||
|
|
125
|
+
(Predicate.isTagged(error.reason, "ApiRateLimited") && Option.isSome(error.reason.retryAfter)));
|
|
126
|
+
|
|
127
|
+
const leaseRetry = Schedule.forever.pipe(
|
|
128
|
+
Schedule.setInputType<unknown>(),
|
|
129
|
+
Schedule.while(
|
|
130
|
+
({ input, attempt }) => attempt < leaseCallAttempts && isRetryableLeaseError(input),
|
|
131
|
+
),
|
|
132
|
+
Schedule.modifyDelay(({ input, attempt }) => {
|
|
133
|
+
if (
|
|
134
|
+
input instanceof VercelApiError &&
|
|
135
|
+
Predicate.isTagged(input.reason, "ApiRateLimited") &&
|
|
136
|
+
Option.isSome(input.reason.retryAfter)
|
|
137
|
+
) {
|
|
138
|
+
return Effect.succeed(input.reason.retryAfter.value);
|
|
139
|
+
}
|
|
140
|
+
const base = 250 * attempt;
|
|
141
|
+
return Effect.map(Random.nextBetween(base / 2, base), Duration.millis);
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const maxBatchSize = 100;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Implements {@link QueueApi} over HTTP with the same requests, headers and status handling as
|
|
149
|
+
* `@vercel/queue`.
|
|
150
|
+
*
|
|
151
|
+
* Requires `HttpClient`, `QueueConfig` and `VercelOidc` (used only with `QueueAuth.Oidc`). A
|
|
152
|
+
* missing OIDC token fails calls with `VercelApiError` (`ApiUnauthorized`). Acknowledging and
|
|
153
|
+
* changing visibility make up to 3 attempts when the request never reached the queue or was
|
|
154
|
+
* rate limited with a `Retry-After` delay; nothing else is retried.
|
|
155
|
+
*/
|
|
156
|
+
export const QueueApiLive: Layer.Layer<
|
|
157
|
+
QueueApi,
|
|
158
|
+
never,
|
|
159
|
+
HttpClient.HttpClient | QueueConfig | VercelOidc
|
|
160
|
+
> = Layer.effect(
|
|
161
|
+
QueueApi,
|
|
162
|
+
Effect.gen(function* () {
|
|
163
|
+
const config = yield* QueueConfig;
|
|
164
|
+
const oidc = yield* VercelOidc;
|
|
165
|
+
const httpClient = yield* HttpClient.HttpClient;
|
|
166
|
+
|
|
167
|
+
const token = QueueAuth.$match(config.auth, {
|
|
168
|
+
Token: ({ token }) => Effect.succeed(token),
|
|
169
|
+
Oidc: () => oidc.token,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const deploymentHeader = (pinned: boolean) =>
|
|
173
|
+
HttpClientRequest.setHeaders(
|
|
174
|
+
pinned
|
|
175
|
+
? Option.match(config.deploymentId, {
|
|
176
|
+
onNone: () => ({}),
|
|
177
|
+
onSome: (id) => ({ "vqs-deployment-id": id }),
|
|
178
|
+
})
|
|
179
|
+
: {},
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const execute = (
|
|
183
|
+
operation: string,
|
|
184
|
+
region: VercelRegion,
|
|
185
|
+
request: HttpClientRequest.HttpClientRequest,
|
|
186
|
+
): Effect.Effect<Response, VercelApiError> =>
|
|
187
|
+
httpClient.pipe(
|
|
188
|
+
withVercelApi({ baseUrl: apiBaseUrl(config.baseUrl(region)), token, userAgent }),
|
|
189
|
+
HttpClient.mapRequestEffect((request) =>
|
|
190
|
+
Effect.map(DateTime.now, (now) =>
|
|
191
|
+
HttpClientRequest.setHeader(request, "vqs-client-ts", DateTime.formatIso(now)),
|
|
192
|
+
),
|
|
193
|
+
),
|
|
194
|
+
(client) => client.execute(request),
|
|
195
|
+
Effect.catchTags({
|
|
196
|
+
HttpClientError: (error: HttpClientError.HttpClientError) =>
|
|
197
|
+
vercelApiErrorFromHttpClientError(error, { operation, errorCodeHeader }),
|
|
198
|
+
OidcTokenUnavailable: (error) =>
|
|
199
|
+
Effect.fail(
|
|
200
|
+
new VercelApiError({
|
|
201
|
+
operation,
|
|
202
|
+
errorCode: Option.none(),
|
|
203
|
+
reason: new ApiUnauthorized({ message: error.message }),
|
|
204
|
+
}),
|
|
205
|
+
),
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const readErrorBody = (response: Response) =>
|
|
210
|
+
Effect.map(
|
|
211
|
+
Effect.orElseSucceed(response.text, () => ""),
|
|
212
|
+
(text) => errorBodyOf(text, header(response, "content-type")),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const failCommon = Effect.fnUntraced(function* (
|
|
216
|
+
operation: string,
|
|
217
|
+
response: Response,
|
|
218
|
+
): Effect.fn.Return<never, QueueRequestError> {
|
|
219
|
+
const body = yield* readErrorBody(response);
|
|
220
|
+
if (
|
|
221
|
+
response.status === 503 &&
|
|
222
|
+
header(response, errorCodeHeader) === "consumer_registry_not_configured"
|
|
223
|
+
) {
|
|
224
|
+
return yield* new ConsumerRegistryNotConfigured({
|
|
225
|
+
message: body.message || "Consumer registry not configured",
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
const now = yield* Clock.currentTimeMillis;
|
|
229
|
+
const error = yield* Effect.flip(
|
|
230
|
+
vercelApiErrorFromResponse(response, { operation, errorCodeHeader }),
|
|
231
|
+
);
|
|
232
|
+
if (Predicate.isTagged(error.reason, "ApiRateLimited")) {
|
|
233
|
+
return yield* new VercelApiError({
|
|
234
|
+
operation: error.operation,
|
|
235
|
+
errorCode: error.errorCode,
|
|
236
|
+
reason: new ApiRateLimited({
|
|
237
|
+
message: error.reason.message,
|
|
238
|
+
retryAfter: retryAfterFromHeader(header(response, "retry-after"), now),
|
|
239
|
+
}),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return yield* error;
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const drain = (response: Response) => Effect.ignore(response.text);
|
|
246
|
+
|
|
247
|
+
const readJson = <A>(
|
|
248
|
+
operation: string,
|
|
249
|
+
response: Response,
|
|
250
|
+
schema: Schema.Codec<A, string>,
|
|
251
|
+
): Effect.Effect<A, VercelApiError> =>
|
|
252
|
+
Effect.flatMap(
|
|
253
|
+
Effect.catch(response.text, (error) =>
|
|
254
|
+
vercelApiErrorFromHttpClientError(error, { operation }),
|
|
255
|
+
),
|
|
256
|
+
(text) =>
|
|
257
|
+
Effect.mapError(Schema.decodeUnknownEffect(schema)(text), (error) =>
|
|
258
|
+
unexpected(operation, response.status, `Invalid response body: ${error.message}`),
|
|
259
|
+
),
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const toReceivedMessage = Effect.fnUntraced(function* (
|
|
263
|
+
part: QueuePartHeaders,
|
|
264
|
+
body: Uint8Array,
|
|
265
|
+
location: Pick<MessageHandle, "topic" | "consumerGroup" | "region">,
|
|
266
|
+
): Effect.fn.Return<ReceivedMessage, MessageCorrupted> {
|
|
267
|
+
const corrupted = (message: string) =>
|
|
268
|
+
new MessageCorrupted({ messageId: part.messageId, message });
|
|
269
|
+
const createdAt = yield* Option.match(parseDate(part.timestamp), {
|
|
270
|
+
onNone: () => Effect.fail(corrupted(`Invalid Vqs-Timestamp: ${part.timestamp}`)),
|
|
271
|
+
onSome: Effect.succeed,
|
|
272
|
+
});
|
|
273
|
+
const expiresAt = yield* Option.match(part.expiresAt, {
|
|
274
|
+
onNone: () => Effect.succeed(DateTime.addDuration(createdAt, defaultRetention)),
|
|
275
|
+
onSome: (raw) =>
|
|
276
|
+
Option.match(parseDate(raw), {
|
|
277
|
+
onNone: () => Effect.fail(corrupted(`Invalid Vqs-Expires-At: ${raw}`)),
|
|
278
|
+
onSome: Effect.succeed,
|
|
279
|
+
}),
|
|
280
|
+
});
|
|
281
|
+
const messageId = MessageId.make(part.messageId);
|
|
282
|
+
return new ReceivedMessage({
|
|
283
|
+
handle: new MessageHandle({
|
|
284
|
+
...location,
|
|
285
|
+
messageId,
|
|
286
|
+
receiptHandle: ReceiptHandle.make(part.receiptHandle),
|
|
287
|
+
}),
|
|
288
|
+
metadata: new MessageMetadata({
|
|
289
|
+
...location,
|
|
290
|
+
messageId,
|
|
291
|
+
deliveryCount: part.deliveryCount,
|
|
292
|
+
createdAt,
|
|
293
|
+
expiresAt,
|
|
294
|
+
...Option.match(part.handoffCount, {
|
|
295
|
+
onNone: () => ({}),
|
|
296
|
+
onSome: (experimentalHandoffCount) => ({ experimentalHandoffCount }),
|
|
297
|
+
}),
|
|
298
|
+
}),
|
|
299
|
+
contentType: part.contentType,
|
|
300
|
+
body,
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const multipartParts = (
|
|
305
|
+
operation: string,
|
|
306
|
+
response: Response,
|
|
307
|
+
): Stream.Stream<MultipartPart, VercelApiError> =>
|
|
308
|
+
Option.match(
|
|
309
|
+
Option.flatMap(
|
|
310
|
+
Option.fromUndefinedOr(header(response, "content-type")),
|
|
311
|
+
boundaryFromContentType,
|
|
312
|
+
),
|
|
313
|
+
{
|
|
314
|
+
onNone: () =>
|
|
315
|
+
Stream.fail(
|
|
316
|
+
unexpected(
|
|
317
|
+
operation,
|
|
318
|
+
response.status,
|
|
319
|
+
"Expected a multipart/mixed response with a boundary",
|
|
320
|
+
),
|
|
321
|
+
),
|
|
322
|
+
onSome: (boundary) =>
|
|
323
|
+
response.stream.pipe(
|
|
324
|
+
decodeMultipartMixed(boundary),
|
|
325
|
+
Stream.catchTags({
|
|
326
|
+
HttpClientError: (error: HttpClientError.HttpClientError) =>
|
|
327
|
+
Stream.fromEffect(vercelApiErrorFromHttpClientError(error, { operation })),
|
|
328
|
+
MultipartMixedError: (error) =>
|
|
329
|
+
Stream.fail(unexpected(operation, response.status, error.message)),
|
|
330
|
+
}),
|
|
331
|
+
),
|
|
332
|
+
},
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const consumerHeaders = (
|
|
336
|
+
visibilityTimeout: Duration.Input | undefined,
|
|
337
|
+
extra: Readonly<Record<string, string>>,
|
|
338
|
+
) => {
|
|
339
|
+
const headers = new HeaderSet({ accept: "multipart/mixed" });
|
|
340
|
+
for (const [name, value] of Object.entries(config.headers)) headers.set(name, value);
|
|
341
|
+
if (visibilityTimeout !== undefined) {
|
|
342
|
+
headers.set("vqs-visibility-timeout-seconds", String(wholeSeconds(visibilityTimeout)));
|
|
343
|
+
}
|
|
344
|
+
for (const [name, value] of Object.entries(extra)) headers.set(name, value);
|
|
345
|
+
return headers.toRecord();
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const send = Effect.fn("QueueApi.send")(function* (
|
|
349
|
+
topic: TopicName,
|
|
350
|
+
message: OutgoingMessage,
|
|
351
|
+
): Effect.fn.Return<SendResult, QueueSendError> {
|
|
352
|
+
const operation = "QueueApi.send";
|
|
353
|
+
const request = HttpClientRequest.post(topicPath(topic)).pipe(
|
|
354
|
+
HttpClientRequest.setHeaders(messageHeaders(config.headers, message).toRecord()),
|
|
355
|
+
deploymentHeader(config.pinSends),
|
|
356
|
+
HttpClientRequest.bodyUint8Array(message.body, message.contentType),
|
|
357
|
+
);
|
|
358
|
+
const response = yield* execute(operation, config.region, request);
|
|
359
|
+
if (response.status < 200 || response.status > 299) {
|
|
360
|
+
if (
|
|
361
|
+
response.status === 502 &&
|
|
362
|
+
header(response, errorCodeHeader) === "consumer_discovery_failed"
|
|
363
|
+
) {
|
|
364
|
+
const body = yield* readErrorBody(response);
|
|
365
|
+
return yield* new ConsumerDiscoveryFailed({
|
|
366
|
+
message: body.message || "Consumer discovery failed",
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
return yield* failCommon(operation, response);
|
|
370
|
+
}
|
|
371
|
+
if (response.status === 202) {
|
|
372
|
+
yield* drain(response);
|
|
373
|
+
return SendResult.Deferred();
|
|
374
|
+
}
|
|
375
|
+
const { messageId } = yield* readJson(operation, response, SendResponse);
|
|
376
|
+
if (messageId === null) return SendResult.Deferred();
|
|
377
|
+
if (messageId === "") {
|
|
378
|
+
return yield* unexpected(operation, response.status, "Empty messageId in response");
|
|
379
|
+
}
|
|
380
|
+
return SendResult.Sent({ messageId: MessageId.make(messageId) });
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const sendBatch = Effect.fn("QueueApi.sendBatch")(function* (
|
|
384
|
+
topic: TopicName,
|
|
385
|
+
messages: Arr.NonEmptyReadonlyArray<OutgoingMessage>,
|
|
386
|
+
): Effect.fn.Return<ReadonlyArray<BatchSendResult>, QueueSendBatchError> {
|
|
387
|
+
const operation = "QueueApi.sendBatch";
|
|
388
|
+
if (messages.length === 0 || messages.length > maxBatchSize) {
|
|
389
|
+
return yield* new BatchSizeOutOfRange({ size: messages.length, maximum: maxBatchSize });
|
|
390
|
+
}
|
|
391
|
+
const boundary = `vqs-${crypto.randomUUID()}`;
|
|
392
|
+
const body = encodeMultipartMixed(
|
|
393
|
+
boundary,
|
|
394
|
+
messages.map((message) => ({
|
|
395
|
+
headers: messageHeaders(config.headers, message).sorted(),
|
|
396
|
+
body: message.body,
|
|
397
|
+
})),
|
|
398
|
+
);
|
|
399
|
+
const request = HttpClientRequest.post(topicPath(topic, "batch")).pipe(
|
|
400
|
+
HttpClientRequest.setHeaders(config.headers),
|
|
401
|
+
deploymentHeader(config.pinSends),
|
|
402
|
+
HttpClientRequest.bodyUint8Array(body, `multipart/mixed; boundary=${boundary}`),
|
|
403
|
+
);
|
|
404
|
+
const response = yield* execute(operation, config.region, request);
|
|
405
|
+
if (response.status < 200 || response.status > 299) {
|
|
406
|
+
return yield* failCommon(operation, response);
|
|
407
|
+
}
|
|
408
|
+
const { results } = yield* readJson(operation, response, BatchResponse);
|
|
409
|
+
if (results.length !== messages.length) {
|
|
410
|
+
return yield* unexpected(
|
|
411
|
+
operation,
|
|
412
|
+
response.status,
|
|
413
|
+
`Invalid batch response: expected ${messages.length} results, received ${results.length}`,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
return results.map((result): BatchSendResult =>
|
|
417
|
+
"messageId" in result
|
|
418
|
+
? BatchSendResult.Sent({ messageId: result.messageId })
|
|
419
|
+
: "statusCode" in result
|
|
420
|
+
? BatchSendResult.Failed({
|
|
421
|
+
status: result.statusCode,
|
|
422
|
+
message: result.error,
|
|
423
|
+
retryable: result.retryable,
|
|
424
|
+
})
|
|
425
|
+
: BatchSendResult.Deferred(),
|
|
426
|
+
);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const receive = Effect.fn("QueueApi.receive")(function* (
|
|
430
|
+
topic: TopicName,
|
|
431
|
+
consumerGroup: ConsumerGroupName,
|
|
432
|
+
options: ReceiveOptions = {},
|
|
433
|
+
): Effect.fn.Return<ReadonlyArray<ReceivedMessage>, QueueRequestError | MessageCorrupted> {
|
|
434
|
+
const operation = "QueueApi.receive";
|
|
435
|
+
const region = options.region ?? config.region;
|
|
436
|
+
const request = HttpClientRequest.post(topicPath(topic, "consumer", consumerGroup)).pipe(
|
|
437
|
+
HttpClientRequest.setHeaders(
|
|
438
|
+
consumerHeaders(options.visibilityTimeout, {
|
|
439
|
+
...(options.limit === undefined ? {} : { "vqs-max-messages": String(options.limit) }),
|
|
440
|
+
...(options.cursorFallback === undefined
|
|
441
|
+
? {}
|
|
442
|
+
: { "vqs-cursor-fallback": options.cursorFallback }),
|
|
443
|
+
}),
|
|
444
|
+
),
|
|
445
|
+
deploymentHeader(true),
|
|
446
|
+
);
|
|
447
|
+
const response = yield* execute(operation, region, request);
|
|
448
|
+
if (response.status === 204) {
|
|
449
|
+
yield* drain(response);
|
|
450
|
+
return [];
|
|
451
|
+
}
|
|
452
|
+
if (response.status < 200 || response.status > 299) {
|
|
453
|
+
return yield* failCommon(operation, response);
|
|
454
|
+
}
|
|
455
|
+
const location = { topic, consumerGroup, region };
|
|
456
|
+
const messages = yield* multipartParts(operation, response).pipe(
|
|
457
|
+
Stream.mapEffect((part) =>
|
|
458
|
+
Option.match(queuePartHeaders(part.headers), {
|
|
459
|
+
onNone: () =>
|
|
460
|
+
Effect.as(
|
|
461
|
+
Effect.logWarning("Missing required queue headers in multipart part"),
|
|
462
|
+
Option.none<ReceivedMessage>(),
|
|
463
|
+
),
|
|
464
|
+
onSome: (headers) =>
|
|
465
|
+
toReceivedMessage(headers, part.body, location).pipe(
|
|
466
|
+
Effect.map(Option.some),
|
|
467
|
+
Effect.catchTag("MessageCorrupted", (error) =>
|
|
468
|
+
Effect.as(
|
|
469
|
+
Effect.logWarning(
|
|
470
|
+
`Skipping corrupted message ${error.messageId}: ${error.message}`,
|
|
471
|
+
),
|
|
472
|
+
Option.none<ReceivedMessage>(),
|
|
473
|
+
),
|
|
474
|
+
),
|
|
475
|
+
),
|
|
476
|
+
}),
|
|
477
|
+
),
|
|
478
|
+
Stream.runCollect,
|
|
479
|
+
);
|
|
480
|
+
return Arr.getSomes(messages);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
const receiveById = Effect.fn("QueueApi.receiveById")(function* (
|
|
484
|
+
topic: TopicName,
|
|
485
|
+
consumerGroup: ConsumerGroupName,
|
|
486
|
+
messageId: MessageId,
|
|
487
|
+
options: ReceiveByIdOptions = {},
|
|
488
|
+
): Effect.fn.Return<ReceivedMessage, QueueReceiveByIdError> {
|
|
489
|
+
const operation = "QueueApi.receiveById";
|
|
490
|
+
const region = options.region ?? config.region;
|
|
491
|
+
const request = HttpClientRequest.post(
|
|
492
|
+
topicPath(topic, "consumer", consumerGroup, "id", messageId),
|
|
493
|
+
).pipe(
|
|
494
|
+
HttpClientRequest.setHeaders(consumerHeaders(options.visibilityTimeout, {})),
|
|
495
|
+
deploymentHeader(true),
|
|
496
|
+
);
|
|
497
|
+
const response = yield* execute(operation, region, request);
|
|
498
|
+
if (response.status < 200 || response.status > 299) {
|
|
499
|
+
const body = yield* readErrorBody(response);
|
|
500
|
+
if (response.status === 404) return yield* new MessageNotFound({ messageId });
|
|
501
|
+
if (response.status === 409) {
|
|
502
|
+
const now = yield* Clock.currentTimeMillis;
|
|
503
|
+
return yield* new MessageNotAvailable({
|
|
504
|
+
messageId,
|
|
505
|
+
reason: Option.match(body.originalMessageId, {
|
|
506
|
+
onNone: () =>
|
|
507
|
+
new MessageLocked({
|
|
508
|
+
retryAfter: retryAfterFromHeader(header(response, "retry-after"), now),
|
|
509
|
+
}),
|
|
510
|
+
onSome: (original) =>
|
|
511
|
+
new MessageUnavailable({
|
|
512
|
+
message: `This message was a duplicate - use originalMessageId: ${original}`,
|
|
513
|
+
}),
|
|
514
|
+
}),
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
if (response.status === 410) return yield* new MessageAlreadyProcessed({ messageId });
|
|
518
|
+
return yield* failCommon(operation, response);
|
|
519
|
+
}
|
|
520
|
+
const first = yield* Stream.runHead(multipartParts(operation, response));
|
|
521
|
+
if (Option.isNone(first)) return yield* new MessageNotFound({ messageId });
|
|
522
|
+
const headers = queuePartHeaders(first.value.headers);
|
|
523
|
+
if (Option.isNone(headers)) {
|
|
524
|
+
return yield* new MessageCorrupted({
|
|
525
|
+
messageId,
|
|
526
|
+
message: "Missing required queue headers in response",
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
return yield* toReceivedMessage(headers.value, first.value.body, {
|
|
530
|
+
topic,
|
|
531
|
+
consumerGroup,
|
|
532
|
+
region,
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
const leaseRequest = (
|
|
537
|
+
operation: string,
|
|
538
|
+
handle: MessageHandle,
|
|
539
|
+
request: HttpClientRequest.HttpClientRequest,
|
|
540
|
+
defaultMessage: string,
|
|
541
|
+
coalesced: boolean,
|
|
542
|
+
) =>
|
|
543
|
+
Effect.gen(function* () {
|
|
544
|
+
const response = yield* execute(operation, handle.region, request);
|
|
545
|
+
if (response.status >= 200 && response.status <= 299) return response;
|
|
546
|
+
const body = yield* readErrorBody(response);
|
|
547
|
+
if (response.status === 404) {
|
|
548
|
+
return yield* new MessageNotFound({ messageId: handle.messageId });
|
|
549
|
+
}
|
|
550
|
+
if (response.status === 409) {
|
|
551
|
+
const count = coalesced
|
|
552
|
+
? coalescedPendingCount(
|
|
553
|
+
header(response, errorCodeHeader),
|
|
554
|
+
header(response, "vqs-experimental-coalesced"),
|
|
555
|
+
body.text,
|
|
556
|
+
)
|
|
557
|
+
: Option.none();
|
|
558
|
+
return yield* new MessageNotAvailable({
|
|
559
|
+
messageId: handle.messageId,
|
|
560
|
+
reason: Option.match(count, {
|
|
561
|
+
onNone: () => new MessageUnavailable({ message: body.message || defaultMessage }),
|
|
562
|
+
onSome: (coalescedCount) => new ConcurrencyCoalescedPending({ coalescedCount }),
|
|
563
|
+
}),
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
return yield* failCommon(operation, response);
|
|
567
|
+
}).pipe(Effect.retry(leaseRetry));
|
|
568
|
+
|
|
569
|
+
const leasePath = (handle: MessageHandle) =>
|
|
570
|
+
topicPath(handle.topic, "consumer", handle.consumerGroup, "lease", handle.receiptHandle);
|
|
571
|
+
|
|
572
|
+
const notAvailableMessage =
|
|
573
|
+
"Invalid receipt handle, message not in correct state, or already processed";
|
|
574
|
+
|
|
575
|
+
const acknowledge = Effect.fn("QueueApi.acknowledge")(function* (
|
|
576
|
+
handle: MessageHandle,
|
|
577
|
+
options: { readonly experimentalIfCoalesced?: number } = {},
|
|
578
|
+
): Effect.fn.Return<void, QueueLeaseError> {
|
|
579
|
+
const ifCoalesced = options.experimentalIfCoalesced;
|
|
580
|
+
const request = HttpClientRequest.delete(leasePath(handle)).pipe(
|
|
581
|
+
HttpClientRequest.setHeaders(config.headers),
|
|
582
|
+
deploymentHeader(true),
|
|
583
|
+
HttpClientRequest.setHeaders(
|
|
584
|
+
ifCoalesced !== undefined && ifCoalesced > 0
|
|
585
|
+
? { "vqs-experimental-if-coalesced": String(ifCoalesced) }
|
|
586
|
+
: {},
|
|
587
|
+
),
|
|
588
|
+
);
|
|
589
|
+
const response = yield* leaseRequest(
|
|
590
|
+
"QueueApi.acknowledge",
|
|
591
|
+
handle,
|
|
592
|
+
request,
|
|
593
|
+
notAvailableMessage,
|
|
594
|
+
true,
|
|
595
|
+
);
|
|
596
|
+
yield* drain(response);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
const changeVisibility = Effect.fn("QueueApi.changeVisibility")(function* (
|
|
600
|
+
handle: MessageHandle,
|
|
601
|
+
visibilityTimeout: Duration.Input,
|
|
602
|
+
options: { readonly experimentalHandoff?: boolean } = {},
|
|
603
|
+
): Effect.fn.Return<Option.Option<HandoffOutcome>, QueueLeaseError> {
|
|
604
|
+
const headers = new HeaderSet({ "content-type": "application/json" });
|
|
605
|
+
for (const [name, value] of Object.entries(config.headers)) headers.set(name, value);
|
|
606
|
+
const request = HttpClientRequest.patch(leasePath(handle)).pipe(
|
|
607
|
+
HttpClientRequest.setHeaders(headers.toRecord()),
|
|
608
|
+
deploymentHeader(true),
|
|
609
|
+
HttpClientRequest.setHeaders(
|
|
610
|
+
options.experimentalHandoff === true ? { "vqs-experimental-handoff": "1" } : {},
|
|
611
|
+
),
|
|
612
|
+
HttpClientRequest.bodyText(
|
|
613
|
+
JSON.stringify({ visibilityTimeoutSeconds: wholeSeconds(visibilityTimeout) }),
|
|
614
|
+
headers.get("content-type") ?? "application/json",
|
|
615
|
+
),
|
|
616
|
+
);
|
|
617
|
+
const response = yield* leaseRequest(
|
|
618
|
+
"QueueApi.changeVisibility",
|
|
619
|
+
handle,
|
|
620
|
+
request,
|
|
621
|
+
notAvailableMessage,
|
|
622
|
+
false,
|
|
623
|
+
);
|
|
624
|
+
yield* drain(response);
|
|
625
|
+
return decodeHandoffOutcome(header(response, "vqs-experimental-handoff"));
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
return QueueApi.of({
|
|
629
|
+
send,
|
|
630
|
+
sendBatch,
|
|
631
|
+
receive,
|
|
632
|
+
receiveById,
|
|
633
|
+
acknowledge,
|
|
634
|
+
changeVisibility,
|
|
635
|
+
});
|
|
636
|
+
}),
|
|
637
|
+
);
|