@hogsend/engine 0.8.0 → 0.9.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/package.json +6 -6
- package/src/container.ts +80 -8
- package/src/destinations/define-destination.ts +104 -0
- package/src/destinations/presets/index.ts +94 -0
- package/src/destinations/presets/posthog.ts +71 -0
- package/src/destinations/presets/segment.ts +75 -0
- package/src/destinations/presets/slack.ts +66 -0
- package/src/destinations/presets/webhook.ts +37 -0
- package/src/destinations/registry-singleton.ts +78 -0
- package/src/env.ts +15 -0
- package/src/index.ts +25 -0
- package/src/journeys/define-journey.ts +0 -1
- package/src/journeys/journey-context.ts +1 -17
- package/src/lib/analytics-singleton.ts +7 -0
- package/src/lib/mailer.ts +21 -6
- package/src/lib/outbound.ts +7 -0
- package/src/lib/seed-posthog-destination.ts +93 -0
- package/src/lib/tracking-events.ts +11 -9
- package/src/lib/webhook-signing.ts +2 -1
- package/src/routes/admin/webhooks.ts +100 -9
- package/src/routes/tracking/click.ts +20 -25
- package/src/routes/tracking/open.ts +20 -27
- package/src/workflows/deliver-webhook.ts +137 -52
|
@@ -52,10 +52,11 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
52
52
|
null;
|
|
53
53
|
const userAgent = c.req.header("user-agent") ?? null;
|
|
54
54
|
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
55
|
+
// First-touch state UPDATE: the `WHERE clickedAt IS NULL` sets `clickedAt`
|
|
56
|
+
// exactly once (the first click), which is the row-level state we keep. The
|
|
57
|
+
// outbound emit is NO LONGER gated on this — every destination must receive
|
|
58
|
+
// EVERY click (owner decision 1), so the emit below fires per-hit.
|
|
59
|
+
await Promise.all([
|
|
59
60
|
db.insert(linkClicks).values({
|
|
60
61
|
trackedLinkId: link.id,
|
|
61
62
|
ipAddress: ip,
|
|
@@ -79,25 +80,17 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
79
80
|
eq(emailSends.id, link.emailSendId),
|
|
80
81
|
isNull(emailSends.clickedAt),
|
|
81
82
|
),
|
|
82
|
-
)
|
|
83
|
-
.returning({ id: emailSends.id }),
|
|
83
|
+
),
|
|
84
84
|
]);
|
|
85
85
|
|
|
86
|
-
const {
|
|
87
|
-
hatchet,
|
|
88
|
-
registry,
|
|
89
|
-
logger,
|
|
90
|
-
analytics: posthog,
|
|
91
|
-
} = c.get("container");
|
|
86
|
+
const { hatchet, registry, logger } = c.get("container");
|
|
92
87
|
|
|
93
88
|
// Resolve the send context ONCE (off the response path) and feed both the
|
|
94
|
-
// re-ingest
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
// SINGLE emitter for `email.clicked` (the provider-webhook echo is suppressed).
|
|
89
|
+
// re-ingest and the PER-HIT outbound emit — avoiding a duplicate
|
|
90
|
+
// `resolveEmailSendContext` read on the click hot path. NO `dedupeKey`: a
|
|
91
|
+
// NULL dedupe key is distinct in Postgres, so every click creates a fresh
|
|
92
|
+
// delivery to every subscribed destination (per-hit, not first-touch).
|
|
99
93
|
const emailSendId = link.emailSendId;
|
|
100
|
-
const isFirstClick = clicked.length > 0;
|
|
101
94
|
void resolveEmailSendContext(db, emailSendId)
|
|
102
95
|
.then(async (ctx) => {
|
|
103
96
|
await pushTrackingEvent({
|
|
@@ -105,7 +98,6 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
105
98
|
hatchet,
|
|
106
99
|
registry,
|
|
107
100
|
logger,
|
|
108
|
-
posthog,
|
|
109
101
|
event: EMAIL_LINK_CLICKED,
|
|
110
102
|
emailSendId,
|
|
111
103
|
properties: { linkUrl: link.originalUrl, linkId: link.id },
|
|
@@ -117,19 +109,22 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
117
109
|
});
|
|
118
110
|
});
|
|
119
111
|
|
|
120
|
-
|
|
112
|
+
// Only emit when the send-context resolved. A missing emailSends row
|
|
113
|
+
// (orphaned tracked link / deleted send) has no userId or recipient to
|
|
114
|
+
// attribute, and a keyed destination (PostHog) would otherwise receive
|
|
115
|
+
// an empty distinct_id. A normal click always resolves a non-null userId.
|
|
116
|
+
if (ctx) {
|
|
121
117
|
await emitOutbound({
|
|
122
118
|
db,
|
|
123
119
|
hatchet,
|
|
124
120
|
logger,
|
|
125
121
|
event: "email.clicked",
|
|
126
|
-
dedupeKey: `email.clicked:${emailSendId}`,
|
|
127
122
|
payload: {
|
|
128
123
|
emailSendId,
|
|
129
|
-
resendId: ctx
|
|
130
|
-
templateKey: ctx
|
|
131
|
-
userId: ctx
|
|
132
|
-
to: ctx
|
|
124
|
+
resendId: ctx.resendId ?? null,
|
|
125
|
+
templateKey: ctx.templateKey ?? null,
|
|
126
|
+
userId: ctx.userId ?? null,
|
|
127
|
+
to: ctx.to ?? ctx.userEmail ?? "",
|
|
133
128
|
at: new Date().toISOString(),
|
|
134
129
|
linkUrl: link.originalUrl,
|
|
135
130
|
linkId: link.id,
|
|
@@ -33,34 +33,25 @@ export const openRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
33
33
|
openRoute,
|
|
34
34
|
async (c) => {
|
|
35
35
|
const { id } = c.req.valid("param");
|
|
36
|
-
const {
|
|
37
|
-
db,
|
|
38
|
-
hatchet,
|
|
39
|
-
registry,
|
|
40
|
-
logger,
|
|
41
|
-
analytics: posthog,
|
|
42
|
-
} = c.get("container");
|
|
36
|
+
const { db, hatchet, registry, logger } = c.get("container");
|
|
43
37
|
|
|
44
|
-
// First-touch
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
38
|
+
// First-touch state UPDATE: the `WHERE openedAt IS NULL` sets `openedAt`
|
|
39
|
+
// exactly once (the first open), which is the row-level state we keep. The
|
|
40
|
+
// outbound emit is NO LONGER gated on this — every destination must receive
|
|
41
|
+
// EVERY open (owner decision 1), so the emit below fires per-hit.
|
|
42
|
+
await db
|
|
49
43
|
.update(emailSends)
|
|
50
44
|
.set({
|
|
51
45
|
openedAt: new Date(),
|
|
52
46
|
updatedAt: new Date(),
|
|
53
47
|
})
|
|
54
|
-
.where(and(eq(emailSends.id, id), isNull(emailSends.openedAt)))
|
|
55
|
-
.returning({ id: emailSends.id });
|
|
48
|
+
.where(and(eq(emailSends.id, id), isNull(emailSends.openedAt)));
|
|
56
49
|
|
|
57
50
|
// Resolve the send context ONCE (off the response path) and feed both the
|
|
58
|
-
// re-ingest
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
// emitter for `email.opened` (the provider-webhook echo is suppressed).
|
|
63
|
-
const isFirstOpen = opened.length > 0;
|
|
51
|
+
// re-ingest and the PER-HIT outbound emit — avoiding a duplicate
|
|
52
|
+
// `resolveEmailSendContext` read on the pixel hot path. NO `dedupeKey`: a
|
|
53
|
+
// NULL dedupe key is distinct in Postgres, so every open creates a fresh
|
|
54
|
+
// delivery to every subscribed destination (per-hit, not first-touch).
|
|
64
55
|
void resolveEmailSendContext(db, id)
|
|
65
56
|
.then(async (ctx) => {
|
|
66
57
|
await pushTrackingEvent({
|
|
@@ -68,7 +59,6 @@ export const openRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
68
59
|
hatchet,
|
|
69
60
|
registry,
|
|
70
61
|
logger,
|
|
71
|
-
posthog,
|
|
72
62
|
event: EMAIL_OPENED,
|
|
73
63
|
emailSendId: id,
|
|
74
64
|
resolvedContext: ctx,
|
|
@@ -79,19 +69,22 @@ export const openRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
79
69
|
});
|
|
80
70
|
});
|
|
81
71
|
|
|
82
|
-
|
|
72
|
+
// Only emit when the send-context resolved. A missing emailSends row
|
|
73
|
+
// (orphaned tracked pixel / deleted send) has no userId or recipient to
|
|
74
|
+
// attribute, and a keyed destination (PostHog) would otherwise receive
|
|
75
|
+
// an empty distinct_id. A normal open always resolves a non-null userId.
|
|
76
|
+
if (ctx) {
|
|
83
77
|
await emitOutbound({
|
|
84
78
|
db,
|
|
85
79
|
hatchet,
|
|
86
80
|
logger,
|
|
87
81
|
event: "email.opened",
|
|
88
|
-
dedupeKey: `email.opened:${id}`,
|
|
89
82
|
payload: {
|
|
90
83
|
emailSendId: id,
|
|
91
|
-
resendId: ctx
|
|
92
|
-
templateKey: ctx
|
|
93
|
-
userId: ctx
|
|
94
|
-
to: ctx
|
|
84
|
+
resendId: ctx.resendId ?? null,
|
|
85
|
+
templateKey: ctx.templateKey ?? null,
|
|
86
|
+
userId: ctx.userId ?? null,
|
|
87
|
+
to: ctx.to ?? ctx.userEmail ?? "",
|
|
95
88
|
at: new Date().toISOString(),
|
|
96
89
|
},
|
|
97
90
|
});
|
|
@@ -4,10 +4,15 @@ import {
|
|
|
4
4
|
webhookEndpoints,
|
|
5
5
|
} from "@hogsend/db";
|
|
6
6
|
import { and, eq, lt, or, sql } from "drizzle-orm";
|
|
7
|
+
import type {
|
|
8
|
+
DestinationEnvelope,
|
|
9
|
+
DestinationTransformResult,
|
|
10
|
+
} from "../destinations/define-destination.js";
|
|
11
|
+
import { webhookDestination } from "../destinations/presets/webhook.js";
|
|
12
|
+
import { getDestinationRegistry } from "../destinations/registry-singleton.js";
|
|
7
13
|
import { getDb } from "../lib/db.js";
|
|
8
14
|
import { hatchet } from "../lib/hatchet.js";
|
|
9
15
|
import { createLogger } from "../lib/logger.js";
|
|
10
|
-
import { signWebhook } from "../lib/webhook-signing.js";
|
|
11
16
|
|
|
12
17
|
/**
|
|
13
18
|
* Outbound webhook delivery — the durable per-(event × endpoint) POST attempt
|
|
@@ -20,11 +25,17 @@ import { signWebhook } from "../lib/webhook-signing.js";
|
|
|
20
25
|
* recovery — mirroring `reapStuckCampaignsTask`. Hatchet's own retry is OFF
|
|
21
26
|
* (`retries: 0`); `nextRetryAt` is the single retry clock.
|
|
22
27
|
*
|
|
23
|
-
* The task
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
+
* The task resolves a delivery-time DESTINATION TRANSFORM by `endpoint.kind`
|
|
29
|
+
* (default "webhook") from the process destination registry, applied to the
|
|
30
|
+
* FROZEN `payload` envelope on the row + the LIVE endpoint read at delivery
|
|
31
|
+
* time. For "webhook" the transform signs with the live secret, so a
|
|
32
|
+
* rotate-secret invalidates in-flight deliveries to a compromised secret
|
|
33
|
+
* (acceptable under at-least-once) and the `body` is the EXACT bytes POSTed —
|
|
34
|
+
* never re-serialized between sign and send (Open Risk 8). A keyed destination
|
|
35
|
+
* (e.g. "posthog") rewrites url/headers/body; a `null` result skips delivery
|
|
36
|
+
* (successful no-op); a transform throw (bad config) is a NON-retryable
|
|
37
|
+
* permanent failure (straight to DLQ, like a persistent 4xx). All
|
|
38
|
+
* retry/backoff/DLQ/reaper/CAS logic operates on the ROW, not the wire.
|
|
28
39
|
*/
|
|
29
40
|
|
|
30
41
|
/** Statuses that are TERMINAL — a duplicate/late enqueue must not re-deliver. */
|
|
@@ -155,55 +166,119 @@ export const deliverWebhookTask = hatchet.task({
|
|
|
155
166
|
return { status: "skipped", reason: "lost_cas" as const };
|
|
156
167
|
}
|
|
157
168
|
|
|
158
|
-
// (4)
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
169
|
+
// (4) Resolve the delivery-time DESTINATION TRANSFORM by the endpoint's
|
|
170
|
+
// `kind` (default "webhook" — byte-identical to the pre-destination signed
|
|
171
|
+
// POST) from the process destination registry. The transform turns the
|
|
172
|
+
// FROZEN row envelope + LIVE endpoint into the concrete HTTP request. For
|
|
173
|
+
// "webhook" it signs from the row payload + live secret; for a keyed
|
|
174
|
+
// destination it rewrites url/headers/body. An UNKNOWN kind (no registered
|
|
175
|
+
// transform) falls back to the always-on `webhook` preset, preserving the
|
|
176
|
+
// pre-registry `ADAPTERS[kind] ?? webhookAdapter` behaviour. A `null` result
|
|
177
|
+
// SKIPS delivery for this event (a successful no-op — marked delivered, no
|
|
178
|
+
// POST). A THROW (bad/missing config) is NOT transient — it routes straight
|
|
179
|
+
// to the failed+DLQ branch like a persistent 4xx (`adapterFailed`).
|
|
180
|
+
const destination =
|
|
181
|
+
getDestinationRegistry().get(endpoint.kind ?? "webhook") ??
|
|
182
|
+
webhookDestination;
|
|
183
|
+
let req: DestinationTransformResult | null = null;
|
|
184
|
+
let transformSkipped = false;
|
|
185
|
+
let adapterFailed = false;
|
|
186
|
+
let adapterUrl = endpoint.url;
|
|
172
187
|
let responseStatus: number | null = null;
|
|
173
188
|
let responseBodySnippet: string | null = null;
|
|
174
189
|
let lastError: string | null = null;
|
|
175
190
|
try {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
191
|
+
req = destination.transform(
|
|
192
|
+
row.payload as unknown as DestinationEnvelope,
|
|
193
|
+
{
|
|
194
|
+
endpoint,
|
|
195
|
+
logger,
|
|
196
|
+
},
|
|
197
|
+
);
|
|
198
|
+
if (req === null) {
|
|
199
|
+
transformSkipped = true;
|
|
200
|
+
} else {
|
|
201
|
+
adapterUrl = req.url;
|
|
187
202
|
}
|
|
188
203
|
} catch (err) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
204
|
+
adapterFailed = true;
|
|
205
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// (4a) Transform returned null → SKIP: mark the row delivered without a POST
|
|
209
|
+
// (a successful no-op for an event this destination chose not to forward).
|
|
210
|
+
// The `delivered` status keeps the row terminal so the reaper never
|
|
211
|
+
// re-drives it. No endpoint `lastDeliveryAt` bump — nothing was sent.
|
|
212
|
+
if (transformSkipped) {
|
|
213
|
+
const skippedAt = new Date();
|
|
214
|
+
await db
|
|
215
|
+
.update(webhookDeliveries)
|
|
216
|
+
.set({
|
|
217
|
+
status: "delivered",
|
|
218
|
+
attemptCount: row.attemptCount + 1,
|
|
219
|
+
responseStatus: null,
|
|
220
|
+
responseBodySnippet: null,
|
|
221
|
+
deliveredAt: skippedAt,
|
|
222
|
+
nextRetryAt: null,
|
|
223
|
+
lastError: null,
|
|
224
|
+
lastAttemptAt: skippedAt,
|
|
225
|
+
updatedAt: skippedAt,
|
|
226
|
+
})
|
|
227
|
+
.where(eq(webhookDeliveries.id, row.id));
|
|
228
|
+
logger.info("deliver-webhook: skipped by destination transform", {
|
|
229
|
+
deliveryId: row.id,
|
|
230
|
+
endpointId: endpoint.id,
|
|
231
|
+
kind: endpoint.kind ?? "webhook",
|
|
232
|
+
eventType: row.eventType,
|
|
233
|
+
});
|
|
234
|
+
return { status: "delivered" as const, skipped: true };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// (5) POST with an AbortController timeout. A network error / timeout leaves
|
|
238
|
+
// `responseStatus` null (a retryable failure, handled below). Skipped when
|
|
239
|
+
// the transform threw (permanent config failure → straight to DLQ).
|
|
240
|
+
if (req) {
|
|
241
|
+
const controller = new AbortController();
|
|
242
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
243
|
+
try {
|
|
244
|
+
const res = await fetch(req.url, {
|
|
245
|
+
method: req.method ?? "POST",
|
|
246
|
+
headers: req.headers,
|
|
247
|
+
body: req.body,
|
|
248
|
+
signal: controller.signal,
|
|
249
|
+
});
|
|
250
|
+
responseStatus = res.status;
|
|
251
|
+
const text = await res.text().catch(() => "");
|
|
252
|
+
responseBodySnippet = text ? text.slice(0, SNIPPET_MAX) : null;
|
|
253
|
+
const ok =
|
|
254
|
+
req.isSuccess?.(responseStatus, responseBodySnippet ?? "") ??
|
|
255
|
+
(responseStatus >= 200 && responseStatus < 300);
|
|
256
|
+
if (!ok) {
|
|
257
|
+
lastError = `HTTP ${responseStatus}`;
|
|
258
|
+
}
|
|
259
|
+
} catch (err) {
|
|
260
|
+
lastError =
|
|
261
|
+
err instanceof Error
|
|
262
|
+
? controller.signal.aborted
|
|
263
|
+
? `Timeout after ${TIMEOUT_MS}ms`
|
|
264
|
+
: err.message
|
|
265
|
+
: String(err);
|
|
266
|
+
} finally {
|
|
267
|
+
clearTimeout(timer);
|
|
268
|
+
}
|
|
197
269
|
}
|
|
198
270
|
|
|
199
271
|
const now = new Date();
|
|
200
272
|
|
|
201
|
-
// (6)
|
|
202
|
-
|
|
273
|
+
// (6) success → delivered (TERMINAL). Also bump the endpoint's
|
|
274
|
+
// lastDeliveryAt. The transform's `isSuccess` (or the default 2xx rule) is
|
|
275
|
+
// the authority — re-checked here against the resolved request.
|
|
276
|
+
const delivered =
|
|
277
|
+
req !== null &&
|
|
203
278
|
responseStatus !== null &&
|
|
204
|
-
responseStatus
|
|
205
|
-
|
|
206
|
-
) {
|
|
279
|
+
(req.isSuccess?.(responseStatus, responseBodySnippet ?? "") ??
|
|
280
|
+
(responseStatus >= 200 && responseStatus < 300));
|
|
281
|
+
if (delivered) {
|
|
207
282
|
await db
|
|
208
283
|
.update(webhookDeliveries)
|
|
209
284
|
.set({
|
|
@@ -233,18 +308,22 @@ export const deliverWebhookTask = hatchet.task({
|
|
|
233
308
|
|
|
234
309
|
const attemptCount = row.attemptCount + 1;
|
|
235
310
|
|
|
236
|
-
// (7)
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
311
|
+
// (7) Permanent (non-retryable) failures fast-fail, skipping the remaining
|
|
312
|
+
// retry budget:
|
|
313
|
+
// - a transform THROW (bad/missing destination config) — config is not
|
|
314
|
+
// transient, so a single attempt is enough to declare it dead.
|
|
315
|
+
// - a persistent-4xx: a non-retryable client error (anything 4xx except
|
|
316
|
+
// 408/429) after attempt >= 2 — a `410 Gone` must not burn 8 attempts.
|
|
317
|
+
// The `>= 2` guard tolerates a single transient 4xx blip first.
|
|
240
318
|
const httpFastFail =
|
|
241
319
|
responseStatus !== null &&
|
|
242
320
|
!isRetryableStatus(responseStatus) &&
|
|
243
321
|
attemptCount >= 2;
|
|
322
|
+
const permanentFail = adapterFailed || httpFastFail;
|
|
244
323
|
|
|
245
324
|
// (8) Retryable failure with attempts remaining → back to `pending` with the
|
|
246
325
|
// next backoff deadline; the reaper re-drives it once `nextRetryAt` passes.
|
|
247
|
-
if (!
|
|
326
|
+
if (!permanentFail && attemptCount < MAX_ATTEMPTS) {
|
|
248
327
|
const nextRetryAt = new Date(now.getTime() + backoffMs(attemptCount));
|
|
249
328
|
await db
|
|
250
329
|
.update(webhookDeliveries)
|
|
@@ -298,7 +377,11 @@ export const deliverWebhookTask = hatchet.task({
|
|
|
298
377
|
sourceId: row.id,
|
|
299
378
|
payload: {
|
|
300
379
|
endpointId: endpoint.id,
|
|
301
|
-
url
|
|
380
|
+
// The adapter-RESOLVED url + the endpoint kind, so a failed keyed
|
|
381
|
+
// delivery is debuggable (NOT the raw endpoint.url, which for a keyed
|
|
382
|
+
// destination is not the URL actually POSTed to).
|
|
383
|
+
url: adapterUrl,
|
|
384
|
+
kind: endpoint.kind ?? "webhook",
|
|
302
385
|
eventType: row.eventType,
|
|
303
386
|
webhookId: row.webhookId,
|
|
304
387
|
body: row.payload,
|
|
@@ -311,13 +394,15 @@ export const deliverWebhookTask = hatchet.task({
|
|
|
311
394
|
logger.error("deliver-webhook: failed (dead-lettered)", {
|
|
312
395
|
deliveryId: row.id,
|
|
313
396
|
endpointId: endpoint.id,
|
|
397
|
+
kind: endpoint.kind ?? "webhook",
|
|
314
398
|
eventType: row.eventType,
|
|
315
399
|
attemptCount,
|
|
316
400
|
responseStatus,
|
|
317
|
-
fastFail:
|
|
401
|
+
fastFail: permanentFail,
|
|
402
|
+
adapterFailed,
|
|
318
403
|
error: lastError,
|
|
319
404
|
});
|
|
320
|
-
return { status: "failed" as const, attemptCount, fastFail:
|
|
405
|
+
return { status: "failed" as const, attemptCount, fastFail: permanentFail };
|
|
321
406
|
},
|
|
322
407
|
});
|
|
323
408
|
|