@ultimat3/jobs 11.3.0 → 13.0.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/CLAUDE.md +80 -0
- package/README.md +149 -0
- package/package.json +5 -5
- package/src/driver-memory.ts +7 -3
- package/src/driver-pg.ts +3 -3
- package/src/driver.ts +21 -0
- package/src/errors.ts +66 -0
- package/src/export-errors.ts +50 -0
- package/src/export-format.ts +132 -0
- package/src/export-pass.ts +178 -0
- package/src/export-sink.ts +105 -0
- package/src/export.ts +187 -0
- package/src/index.ts +52 -1
- package/src/webhook-errors.ts +170 -0
- package/src/webhook-ledger.ts +102 -0
- package/src/webhook.ts +378 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// The eight `X_WEBHOOK_*` codes an outbound delivery can end on, apart from `errors.ts` for the
|
|
2
|
+
// reason `backfill-errors.ts` is: one file, one job, and `errors.ts` holds the registry. The codes,
|
|
3
|
+
// the titles and the single `registerErrorCodes()` call stay there; only the classes live here.
|
|
4
|
+
//
|
|
5
|
+
// THE RULE THIS FILE EXISTS TO KEEP: no secret and no signature reaches a `cause`, a `fix` or a
|
|
6
|
+
// `meta`. A refusal is read by the caller, the log store and the dead-letter row, and a credential
|
|
7
|
+
// in any of the three is a leak wearing a diagnostic's clothes. The URL is redacted for the same
|
|
8
|
+
// reason one step down — several senders in the wild put a token in the query string, so only the
|
|
9
|
+
// origin and path travel.
|
|
10
|
+
|
|
11
|
+
import { UltimateError } from '@ultimat3/core';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Origin + path, never the query and never any userinfo. A webhook URL is an operator's fact and
|
|
15
|
+
* a `cause` must carry enough of it to act on, but `https://hooks.example/x?token=…` is a
|
|
16
|
+
* credential in a field that reaches the log store unredactable.
|
|
17
|
+
*/
|
|
18
|
+
export function webhookTarget(url: string): string {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = new URL(url);
|
|
21
|
+
return `${parsed.origin}${parsed.pathname}`;
|
|
22
|
+
} catch {
|
|
23
|
+
// Never `${error}` and never the URL itself: an unparseable value is exactly the one whose
|
|
24
|
+
// shape is unknown, and this string is going into a durable row.
|
|
25
|
+
return '<unparseable url>';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The endpoint seam answered nothing. Terminal: a queued delivery cannot invent a destination. */
|
|
30
|
+
export class WebhookEndpointUnknownError extends UltimateError {
|
|
31
|
+
constructor(input: { webhook: string; endpointId: string }) {
|
|
32
|
+
super({
|
|
33
|
+
code: 'X_WEBHOOK_ENDPOINT_UNKNOWN',
|
|
34
|
+
cause: `webhook "${input.webhook}" has no endpoint ${input.endpointId}`,
|
|
35
|
+
fix: `return the endpoint row for ${input.endpointId} from webhook("${input.webhook}").endpoint, or stop enqueueing deliveries for endpoints your app has deleted`,
|
|
36
|
+
meta: { endpointId: input.endpointId },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The endpoint exists and cannot be delivered to as declared. */
|
|
42
|
+
export class WebhookEndpointInvalidError extends UltimateError {
|
|
43
|
+
constructor(input: { webhook: string; endpointId: string; reason: string }) {
|
|
44
|
+
super({
|
|
45
|
+
code: 'X_WEBHOOK_ENDPOINT_INVALID',
|
|
46
|
+
cause: `webhook "${input.webhook}" endpoint ${input.endpointId} ${input.reason}`,
|
|
47
|
+
fix: `give the endpoint an https:// url and a non-empty secret before webhook("${input.webhook}").endpoint returns it — a delivery with neither is an unsigned POST to wherever the row points`,
|
|
48
|
+
meta: { endpointId: input.endpointId },
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The endpoint takes nothing, either because the app disabled it or because this delivery is the
|
|
55
|
+
* one that reached `disableAfter`. Terminal by construction: the next attempt reads the same row.
|
|
56
|
+
*/
|
|
57
|
+
export class WebhookEndpointDisabledError extends UltimateError {
|
|
58
|
+
constructor(input: {
|
|
59
|
+
webhook: string;
|
|
60
|
+
endpointId: string;
|
|
61
|
+
consecutiveFailures?: number;
|
|
62
|
+
disableAfter?: number;
|
|
63
|
+
}) {
|
|
64
|
+
const why =
|
|
65
|
+
input.consecutiveFailures === undefined
|
|
66
|
+
? 'is disabled'
|
|
67
|
+
: `failed ${input.consecutiveFailures} deliveries in a row and disableAfter is ${String(input.disableAfter)}`;
|
|
68
|
+
super({
|
|
69
|
+
code: 'X_WEBHOOK_ENDPOINT_DISABLED',
|
|
70
|
+
cause: `webhook "${input.webhook}" endpoint ${input.endpointId} ${why}`,
|
|
71
|
+
fix: `fix the receiver, then re-enable the endpoint in your own table and enqueue the deliveries it missed — the framework never re-enables one on its own, because a dead endpoint that heals itself is a retry loop with no end`,
|
|
72
|
+
meta: { endpointId: input.endpointId },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The event seam answered nothing for a queued id. */
|
|
78
|
+
export class WebhookEventUnknownError extends UltimateError {
|
|
79
|
+
constructor(input: { webhook: string; eventId: string }) {
|
|
80
|
+
super({
|
|
81
|
+
code: 'X_WEBHOOK_EVENT_UNKNOWN',
|
|
82
|
+
cause: `webhook "${input.webhook}" has no event ${input.eventId}`,
|
|
83
|
+
fix: `write the event row before enqueueing its delivery — inside the same transaction, so webhook("${input.webhook}").event can never be asked for a row that is not committed yet`,
|
|
84
|
+
meta: { eventId: input.eventId },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The event's id or topic cannot be put in the canonical string without making it ambiguous. This
|
|
91
|
+
* is refused BEFORE the mac is taken, because a mac over an ambiguous string is a valid signature
|
|
92
|
+
* for a delivery the sender did not write.
|
|
93
|
+
*/
|
|
94
|
+
export class WebhookEventInvalidError extends UltimateError {
|
|
95
|
+
constructor(input: { webhook: string; eventId: string; field: string; max: number }) {
|
|
96
|
+
super({
|
|
97
|
+
code: 'X_WEBHOOK_EVENT_INVALID',
|
|
98
|
+
cause: `webhook "${input.webhook}" event ${input.eventId} has a ${input.field} that cannot be signed: 1-${input.max} characters, no ":" and no control characters`,
|
|
99
|
+
fix: `change how your app mints a ${input.field} — one mac over "v1:<t>:<id>:<topic>:<body>" would authenticate two different id/topic splits, so the separator can never appear in either`,
|
|
100
|
+
meta: { eventId: input.eventId, field: input.field },
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The delivery did not land and the same request, unchanged, might: a 5xx, a throttle that named
|
|
107
|
+
* no delay, or a connection that never opened. Retryable — the job's own policy spends the
|
|
108
|
+
* backoff, which is core's one curve.
|
|
109
|
+
*/
|
|
110
|
+
export class WebhookDeliveryFailedError extends UltimateError {
|
|
111
|
+
constructor(input: {
|
|
112
|
+
webhook: string;
|
|
113
|
+
endpointId: string;
|
|
114
|
+
url: string;
|
|
115
|
+
status: number | null;
|
|
116
|
+
detail: string;
|
|
117
|
+
}) {
|
|
118
|
+
const answer = input.status === null ? 'never answered' : `answered ${input.status}`;
|
|
119
|
+
super({
|
|
120
|
+
code: 'X_WEBHOOK_DELIVERY_FAILED',
|
|
121
|
+
cause: `webhook "${input.webhook}" delivery to ${webhookTarget(input.url)} ${answer}: ${input.detail}`,
|
|
122
|
+
fix: 'no edit here — the job retries on its declared backoff; watch the endpoint with x jobs ls --json and fix the receiver before it reaches disableAfter',
|
|
123
|
+
meta: { endpointId: input.endpointId, status: input.status },
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The receiver said "later" AND said when. Its own code because the answer is different work: the
|
|
130
|
+
* nack waits the delay the receiver named (`statedDelayMs` reads `meta.retryAfterSeconds`, clamped
|
|
131
|
+
* by the policy's `maxDelay`) instead of spending a curve against a number it was already given.
|
|
132
|
+
*/
|
|
133
|
+
export class WebhookDeliveryThrottledError extends UltimateError {
|
|
134
|
+
constructor(input: {
|
|
135
|
+
webhook: string;
|
|
136
|
+
endpointId: string;
|
|
137
|
+
url: string;
|
|
138
|
+
status: number;
|
|
139
|
+
retryAfterSeconds: number;
|
|
140
|
+
}) {
|
|
141
|
+
super({
|
|
142
|
+
code: 'X_WEBHOOK_DELIVERY_THROTTLED',
|
|
143
|
+
cause: `webhook "${input.webhook}" delivery to ${webhookTarget(input.url)} answered ${input.status} and asked for ${input.retryAfterSeconds}s`,
|
|
144
|
+
fix: 'no edit here — the next attempt waits the delay the receiver named; if this endpoint throttles constantly, raise its retry.maxDelay or fan out fewer events to it',
|
|
145
|
+
meta: {
|
|
146
|
+
endpointId: input.endpointId,
|
|
147
|
+
status: input.status,
|
|
148
|
+
// The one spelling `statedDelayMs` reads. Never a second key.
|
|
149
|
+
retryAfterSeconds: input.retryAfterSeconds,
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The receiver refused in a way a retry cannot change — a 4xx that is not a throttle, or a
|
|
157
|
+
* redirect. Its own code rather than an instance flag on the one above: `classifyThrown` reads a
|
|
158
|
+
* per-instance `terminal` on an unregistered code as UNCLASSIFIED, so the distinction has to be a
|
|
159
|
+
* registered code or it does not exist.
|
|
160
|
+
*/
|
|
161
|
+
export class WebhookDeliveryRejectedError extends UltimateError {
|
|
162
|
+
constructor(input: { webhook: string; endpointId: string; url: string; status: number }) {
|
|
163
|
+
super({
|
|
164
|
+
code: 'X_WEBHOOK_DELIVERY_REJECTED',
|
|
165
|
+
cause: `webhook "${input.webhook}" delivery to ${webhookTarget(input.url)} answered ${input.status}, which no retry changes`,
|
|
166
|
+
fix: 'x jobs show <jobId> --json # meta.status is the answer; then correct the url or the secret on this endpoint row in your own table — a 401 or 403 is a secret the two sides disagree about, a 404 is a path that moved',
|
|
167
|
+
meta: { endpointId: input.endpointId, status: input.status },
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// The delivery ledger: what every outbound attempt is recorded in, and the one fact the mechanism
|
|
2
|
+
// reads back out of it — how many times in a row this endpoint has failed.
|
|
3
|
+
//
|
|
4
|
+
// A SEAM and not a table, exactly like `PurgeTarget`: an app already has a place for this and the
|
|
5
|
+
// retention on it is seven years for one business and thirty days for the next, so shipping a
|
|
6
|
+
// schema would be shipping one of those answers (axiom 8). What the framework owns is that every
|
|
7
|
+
// attempt IS recorded and that the count is what disables an endpoint.
|
|
8
|
+
|
|
9
|
+
import { assert } from '@ultimat3/core';
|
|
10
|
+
|
|
11
|
+
/** One outbound attempt, as it happened. Bounded and JSON-safe, so a row can hold it verbatim. */
|
|
12
|
+
export interface WebhookAttempt {
|
|
13
|
+
/** The `webhook()` declaration's name — the durable queue key deliveries are routed under. */
|
|
14
|
+
readonly webhook: string;
|
|
15
|
+
readonly endpointId: string;
|
|
16
|
+
readonly eventId: string;
|
|
17
|
+
readonly topic: string;
|
|
18
|
+
/** The JOB's attempt, 1-based: attempt 3 is the third time the queue handed this delivery out. */
|
|
19
|
+
readonly attempt: number;
|
|
20
|
+
readonly ok: boolean;
|
|
21
|
+
/** The response status, or `null` when there was no response at all. */
|
|
22
|
+
readonly status: number | null;
|
|
23
|
+
/** When the attempt finished, in ms on the declaration's clock. */
|
|
24
|
+
readonly at: number;
|
|
25
|
+
readonly durationMs: number;
|
|
26
|
+
/**
|
|
27
|
+
* Why it failed, rendered by the framework and never by the receiver: the response BODY is not
|
|
28
|
+
* here, because an endpoint that echoes the request has just written the payload into a second
|
|
29
|
+
* table with a different retention. Absent on a success.
|
|
30
|
+
*/
|
|
31
|
+
readonly error?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface WebhookLedger {
|
|
35
|
+
/**
|
|
36
|
+
* Append this attempt and answer how many CONSECUTIVE failures the endpoint now has — 0 when
|
|
37
|
+
* `attempt.ok`. One method rather than an append plus a separate count, so the number the
|
|
38
|
+
* mechanism disables on cannot be read from before the row it is deciding about.
|
|
39
|
+
*/
|
|
40
|
+
record(attempt: WebhookAttempt): Promise<number>;
|
|
41
|
+
/**
|
|
42
|
+
* Stop delivering to this endpoint. Called once, on the attempt whose count reached
|
|
43
|
+
* `disableAfter`. Re-enabling is the app's — a dead endpoint that heals itself is a retry loop
|
|
44
|
+
* with no end.
|
|
45
|
+
*/
|
|
46
|
+
disable(endpointId: string, reason: string): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Attempts one process keeps before the oldest go. A dev ledger that grows forever is a leak. */
|
|
50
|
+
export const DEFAULT_MAX_WEBHOOK_ATTEMPTS = 1_000;
|
|
51
|
+
|
|
52
|
+
export interface MemoryWebhookLedger extends WebhookLedger {
|
|
53
|
+
/** Newest last, bounded by `maxAttempts`. */
|
|
54
|
+
attempts(): readonly WebhookAttempt[];
|
|
55
|
+
/** Endpoint id -> the reason it was disabled. */
|
|
56
|
+
disabled(): ReadonlyMap<string, string>;
|
|
57
|
+
reset(): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The dev and test ledger, and honest about being one: a bounded ring in one heap, so nothing here
|
|
62
|
+
* survives a restart and nothing here is shared between replicas. The consecutive count is derived
|
|
63
|
+
* from a counter rather than by scanning the ring — a ring that evicted the failures would answer
|
|
64
|
+
* a smaller number the longer an outage ran, which is the direction that never disables anything.
|
|
65
|
+
*/
|
|
66
|
+
export function memoryWebhookLedger(
|
|
67
|
+
options: { readonly maxAttempts?: number } = {},
|
|
68
|
+
): MemoryWebhookLedger {
|
|
69
|
+
const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_WEBHOOK_ATTEMPTS;
|
|
70
|
+
assert(
|
|
71
|
+
Number.isSafeInteger(maxAttempts) && maxAttempts >= 1,
|
|
72
|
+
`memoryWebhookLedger maxAttempts is ${String(maxAttempts)}, which keeps no attempt at all`,
|
|
73
|
+
`pass memoryWebhookLedger({ maxAttempts: ${DEFAULT_MAX_WEBHOOK_ATTEMPTS} }) or leave it out — a ledger that holds nothing cannot disable an endpoint`,
|
|
74
|
+
);
|
|
75
|
+
const attempts: WebhookAttempt[] = [];
|
|
76
|
+
const consecutive = new Map<string, number>();
|
|
77
|
+
const off = new Map<string, string>();
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
record(attempt: WebhookAttempt): Promise<number> {
|
|
81
|
+
attempts.push(attempt);
|
|
82
|
+
if (attempts.length > maxAttempts) attempts.splice(0, attempts.length - maxAttempts);
|
|
83
|
+
const failures = attempt.ok ? 0 : (consecutive.get(attempt.endpointId) ?? 0) + 1;
|
|
84
|
+
// Deleted at zero rather than written as one, the bound `limits.ts` keeps for the same
|
|
85
|
+
// reason: a healthy endpoint must not cost a permanent map entry per org that creates one.
|
|
86
|
+
if (failures === 0) consecutive.delete(attempt.endpointId);
|
|
87
|
+
else consecutive.set(attempt.endpointId, failures);
|
|
88
|
+
return Promise.resolve(failures);
|
|
89
|
+
},
|
|
90
|
+
disable(endpointId: string, reason: string): Promise<void> {
|
|
91
|
+
off.set(endpointId, reason);
|
|
92
|
+
return Promise.resolve();
|
|
93
|
+
},
|
|
94
|
+
attempts: (): readonly WebhookAttempt[] => [...attempts],
|
|
95
|
+
disabled: (): ReadonlyMap<string, string> => new Map(off),
|
|
96
|
+
reset(): void {
|
|
97
|
+
attempts.length = 0;
|
|
98
|
+
consecutive.clear();
|
|
99
|
+
off.clear();
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
package/src/webhook.ts
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// `webhook()` — one outbound delivery to one endpoint, declared as a `job` and NOT as a ninth
|
|
2
|
+
// primitive. A delivery is durable background work with an input schema, a retry policy, an
|
|
3
|
+
// idempotency key and a queue, which is the definition of a `job` — so this file is a FACTORY over
|
|
4
|
+
// `job()`, exactly as `backfill()` and `purge()` are and as `llm()` is over `action()`. That gives
|
|
5
|
+
// it `.enqueue()`, the retry backoff, the worker's cancellation, the dead-letter path,
|
|
6
|
+
// `x jobs show` and a manifest row without a line here.
|
|
7
|
+
//
|
|
8
|
+
// ONE ENDPOINT PER JOB, deliberately. Retry, backoff and disable-after-N are all per-endpoint
|
|
9
|
+
// facts, and a job that fanned out inside one body would retry every endpoint because one of them
|
|
10
|
+
// was down — the same defect `docs/architecture/15-adding-a-feature.md` names for a mail loop
|
|
11
|
+
// under a single step. WHICH endpoints exist is the app's (axiom 8), so the fan-out is the app's
|
|
12
|
+
// `for` loop over its own subscription table, one `enqueue` per endpoint.
|
|
13
|
+
//
|
|
14
|
+
// WHAT THIS MECHANISM OWNS: signing, a timestamped signature, retry with core's backoff,
|
|
15
|
+
// disable-after-N-consecutive-failures, and a ledger row per attempt. WHAT IT NEVER OWNS: the
|
|
16
|
+
// event taxonomy, which endpoints exist, and what a payload means.
|
|
17
|
+
|
|
18
|
+
import type { Clock, Ctx } from '@ultimat3/core';
|
|
19
|
+
import {
|
|
20
|
+
isCanonicalWebhookField,
|
|
21
|
+
renderThrowable,
|
|
22
|
+
systemClock,
|
|
23
|
+
WEBHOOK_FIELD_MAX,
|
|
24
|
+
webhookHeaders,
|
|
25
|
+
} from '@ultimat3/core';
|
|
26
|
+
import { t } from '@ultimat3/schema';
|
|
27
|
+
import type { DurationInput } from './clock';
|
|
28
|
+
import { nowMs } from './clock';
|
|
29
|
+
import type { JobHandle } from './job';
|
|
30
|
+
import { job } from './job';
|
|
31
|
+
import type { RetryPolicy } from './retry';
|
|
32
|
+
import { DEFAULT_RETRY } from './retry';
|
|
33
|
+
import type { JobTenant } from './tenant';
|
|
34
|
+
import {
|
|
35
|
+
WebhookDeliveryFailedError,
|
|
36
|
+
WebhookDeliveryRejectedError,
|
|
37
|
+
WebhookDeliveryThrottledError,
|
|
38
|
+
WebhookEndpointDisabledError,
|
|
39
|
+
WebhookEndpointInvalidError,
|
|
40
|
+
WebhookEndpointUnknownError,
|
|
41
|
+
WebhookEventInvalidError,
|
|
42
|
+
WebhookEventUnknownError,
|
|
43
|
+
} from './webhook-errors';
|
|
44
|
+
import type { WebhookLedger } from './webhook-ledger';
|
|
45
|
+
|
|
46
|
+
/** Just the call. `typeof fetch` also carries `preconnect`, which no test double should have to. */
|
|
47
|
+
export type WebhookFetch = (url: string, init: RequestInit) => Promise<Response>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Consecutive failures before an endpoint stops taking deliveries. Ten is roughly a day of a
|
|
51
|
+
* retrying queue against a dead receiver, which is long enough for an outage and short enough that
|
|
52
|
+
* a decommissioned endpoint does not cost the fleet forever.
|
|
53
|
+
*/
|
|
54
|
+
export const DEFAULT_WEBHOOK_DISABLE_AFTER = 10;
|
|
55
|
+
|
|
56
|
+
/** The one content type a delivery announces. The BODY is the app's; how it is framed is not. */
|
|
57
|
+
export const WEBHOOK_CONTENT_TYPE = 'application/json';
|
|
58
|
+
|
|
59
|
+
/** `Retry-After` in seconds. The HTTP-date form is ignored on purpose — see `retryAfterSeconds`. */
|
|
60
|
+
const RETRY_AFTER_SECONDS = /^\d{1,7}$/;
|
|
61
|
+
|
|
62
|
+
export interface WebhookEndpoint {
|
|
63
|
+
readonly id: string;
|
|
64
|
+
/** `https://…` in production, `http://…` for a dev receiver. Nothing else is opened. */
|
|
65
|
+
readonly url: string;
|
|
66
|
+
/** The shared secret. Never logged, never in a `cause`, never on the ledger row. */
|
|
67
|
+
readonly secret: string;
|
|
68
|
+
/** True stops every delivery before the socket opens. Set by the app, or by `disableAfter`. */
|
|
69
|
+
readonly disabled?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Extra headers this receiver asked for. Merged UNDER the framework's, so nothing here can
|
|
72
|
+
* overwrite the signature, the id or the topic — an endpoint row that could set its own
|
|
73
|
+
* `x-ultimate-webhook-signature` is an endpoint row that can forge one.
|
|
74
|
+
*/
|
|
75
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface WebhookEvent {
|
|
79
|
+
/**
|
|
80
|
+
* The sender's routing label, signed and carried and never interpreted. Which topics exist is
|
|
81
|
+
* the app's taxonomy (axiom 8); that a delivery HAS one is the mechanism.
|
|
82
|
+
*/
|
|
83
|
+
readonly topic: string;
|
|
84
|
+
/**
|
|
85
|
+
* The exact text to sign and send. Serialised by the app, because what a payload means is the
|
|
86
|
+
* app's — and byte-stable within one attempt, which is all the signature needs.
|
|
87
|
+
*/
|
|
88
|
+
readonly body: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The queue row's payload: a POINTER, never a record. The event's bytes live in the app's own
|
|
93
|
+
* table and are read once per attempt, so nothing durable here holds a payload and nothing here
|
|
94
|
+
* holds a secret.
|
|
95
|
+
*/
|
|
96
|
+
export interface WebhookDeliveryInput {
|
|
97
|
+
readonly endpointId: string;
|
|
98
|
+
/** Also the id the receiver dedupes on — it is signed, so it cannot be moved in transit. */
|
|
99
|
+
readonly eventId: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** What one landed delivery reports. Bounded, so `x jobs show` can print it. */
|
|
103
|
+
export interface WebhookReport {
|
|
104
|
+
readonly endpointId: string;
|
|
105
|
+
readonly eventId: string;
|
|
106
|
+
readonly status: number;
|
|
107
|
+
readonly durationMs: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface WebhookDefinition {
|
|
111
|
+
/**
|
|
112
|
+
* REQUIRED, unlike a job's. A delivery's name is a durable queue key — every queued, retrying
|
|
113
|
+
* and dead-lettered row carries it — so it is never left to whichever export name a module used.
|
|
114
|
+
*/
|
|
115
|
+
readonly name: string;
|
|
116
|
+
/**
|
|
117
|
+
* REQUIRED, exactly as on `job()`: `tenant: ({ endpointId }) => …` for a delivery scoped to the
|
|
118
|
+
* org that owns the endpoint, or the explicit `tenant: 'none'`. A delivery reads the app's own
|
|
119
|
+
* endpoint and event rows through the seams below, so the org those reads run under is a fact
|
|
120
|
+
* about the WORK and is declared here rather than inherited from whichever worker claimed it.
|
|
121
|
+
*/
|
|
122
|
+
readonly tenant: JobTenant<WebhookDeliveryInput>;
|
|
123
|
+
/**
|
|
124
|
+
* The endpoint this delivery is for. Read once PER ATTEMPT and never checkpointed: it carries a
|
|
125
|
+
* secret, and a `step.run` output is written to `x_job_steps` — a credential in a durable table
|
|
126
|
+
* the queue keeps for the life of the run.
|
|
127
|
+
*/
|
|
128
|
+
endpoint(args: {
|
|
129
|
+
readonly endpointId: string;
|
|
130
|
+
readonly ctx: Ctx;
|
|
131
|
+
}): Promise<WebhookEndpoint | null> | WebhookEndpoint | null;
|
|
132
|
+
/** The event's bytes. Read per attempt as well, out of the app's own table. */
|
|
133
|
+
event(args: {
|
|
134
|
+
readonly eventId: string;
|
|
135
|
+
readonly ctx: Ctx;
|
|
136
|
+
}): Promise<WebhookEvent | null> | WebhookEvent | null;
|
|
137
|
+
/** Where every attempt is recorded, and where the consecutive-failure count comes from. */
|
|
138
|
+
readonly ledger: WebhookLedger;
|
|
139
|
+
/**
|
|
140
|
+
* Consecutive failures before the endpoint is disabled. Defaults to
|
|
141
|
+
* `DEFAULT_WEBHOOK_DISABLE_AFTER`. Re-enabling is always the app's — an endpoint the framework
|
|
142
|
+
* un-disabled on its own is a retry loop with no end.
|
|
143
|
+
*/
|
|
144
|
+
readonly disableAfter?: number;
|
|
145
|
+
/** The clock the signature's timestamp and the ledger's instants are read from. */
|
|
146
|
+
readonly clock?: Clock;
|
|
147
|
+
/** Injected so a test can drive the transport. The network is sealed in this repo's suites. */
|
|
148
|
+
readonly fetch?: WebhookFetch;
|
|
149
|
+
readonly queue?: string;
|
|
150
|
+
readonly retry?: RetryPolicy;
|
|
151
|
+
/**
|
|
152
|
+
* The ceiling for ONE attempt, and therefore for the request inside it. Deliberately not a
|
|
153
|
+
* second per-request timeout: `ctx.signal` already carries this deadline into `fetch`, and two
|
|
154
|
+
* numbers for one wait is the ambiguity axiom 1 refuses.
|
|
155
|
+
*/
|
|
156
|
+
readonly timeout?: DurationInput;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** `Retry-After: 120`. The HTTP-date form needs the receiver's clock, which is what we do not trust. */
|
|
160
|
+
const retryAfterSeconds = (response: Response): number | undefined => {
|
|
161
|
+
const header = response.headers.get('retry-after');
|
|
162
|
+
if (header === null || !RETRY_AFTER_SECONDS.test(header.trim())) return undefined;
|
|
163
|
+
return Number(header.trim());
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** A status the same request, unchanged, can still land on. Everything else is somebody's edit. */
|
|
167
|
+
const isRetryableStatus = (status: number): boolean =>
|
|
168
|
+
status >= 500 || status === 408 || status === 425 || status === 429;
|
|
169
|
+
|
|
170
|
+
export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliveryInput> {
|
|
171
|
+
const clock = definition.clock ?? systemClock;
|
|
172
|
+
const disableAfter = definition.disableAfter ?? DEFAULT_WEBHOOK_DISABLE_AFTER;
|
|
173
|
+
const send = definition.fetch ?? ((url, init) => fetch(url, init));
|
|
174
|
+
|
|
175
|
+
return job<WebhookDeliveryInput>({
|
|
176
|
+
name: definition.name,
|
|
177
|
+
input: t.object({ endpointId: t.string, eventId: t.string }),
|
|
178
|
+
// Endpoint AND event: the same event fans out to every subscribed endpoint, so a key on the
|
|
179
|
+
// event alone would dedupe every one of those deliveries into the first endpoint's row.
|
|
180
|
+
idempotencyKey: ({ endpointId, eventId }) => `${definition.name}:${endpointId}:${eventId}`,
|
|
181
|
+
tenant: definition.tenant,
|
|
182
|
+
retry: definition.retry ?? DEFAULT_RETRY,
|
|
183
|
+
...(definition.queue === undefined ? {} : { queue: definition.queue }),
|
|
184
|
+
...(definition.timeout === undefined ? {} : { timeout: definition.timeout }),
|
|
185
|
+
async run({ input, ctx, attempt }): Promise<WebhookReport> {
|
|
186
|
+
const endpoint = await definition.endpoint({ endpointId: input.endpointId, ctx });
|
|
187
|
+
if (endpoint === null) {
|
|
188
|
+
throw new WebhookEndpointUnknownError({
|
|
189
|
+
webhook: definition.name,
|
|
190
|
+
endpointId: input.endpointId,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
// Before the ledger and before the socket: a disabled endpoint costs nothing, which is the
|
|
194
|
+
// whole point of disabling one.
|
|
195
|
+
if (endpoint.disabled === true) {
|
|
196
|
+
throw new WebhookEndpointDisabledError({
|
|
197
|
+
webhook: definition.name,
|
|
198
|
+
endpointId: endpoint.id,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
assertDeliverable(definition.name, endpoint);
|
|
202
|
+
|
|
203
|
+
const event = await definition.event({ eventId: input.eventId, ctx });
|
|
204
|
+
if (event === null) {
|
|
205
|
+
throw new WebhookEventUnknownError({ webhook: definition.name, eventId: input.eventId });
|
|
206
|
+
}
|
|
207
|
+
// Refused BEFORE the mac is taken. A mac over an ambiguous canonical string is a valid
|
|
208
|
+
// signature for a delivery the sender never wrote — see `isCanonicalWebhookField`.
|
|
209
|
+
for (const [field, value] of [
|
|
210
|
+
['event id', input.eventId],
|
|
211
|
+
['topic', event.topic],
|
|
212
|
+
] as const) {
|
|
213
|
+
if (isCanonicalWebhookField(value)) continue;
|
|
214
|
+
throw new WebhookEventInvalidError({
|
|
215
|
+
webhook: definition.name,
|
|
216
|
+
eventId: input.eventId,
|
|
217
|
+
field,
|
|
218
|
+
max: WEBHOOK_FIELD_MAX,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Seconds, at SEND time and not at event time: a delivery retried three days later is signed
|
|
223
|
+
// again now, so a receiver's freshness window measures the request in front of it rather
|
|
224
|
+
// than the age of the fact behind it.
|
|
225
|
+
const timestampSeconds = Math.floor(nowMs(clock) / 1_000);
|
|
226
|
+
const startedAt = clock.monotonic();
|
|
227
|
+
const outcome = await attemptDelivery(send, endpoint, {
|
|
228
|
+
secret: endpoint.secret,
|
|
229
|
+
timestampSeconds,
|
|
230
|
+
eventId: input.eventId,
|
|
231
|
+
topic: event.topic,
|
|
232
|
+
body: event.body,
|
|
233
|
+
});
|
|
234
|
+
const durationMs = Math.max(0, clock.monotonic() - startedAt);
|
|
235
|
+
|
|
236
|
+
// Recorded whatever happened, and BEFORE the throw: a failure that is not on the ledger is a
|
|
237
|
+
// failure the consecutive count cannot see, which is an endpoint that never gets disabled.
|
|
238
|
+
const failures = await definition.ledger.record({
|
|
239
|
+
webhook: definition.name,
|
|
240
|
+
endpointId: endpoint.id,
|
|
241
|
+
eventId: input.eventId,
|
|
242
|
+
topic: event.topic,
|
|
243
|
+
attempt,
|
|
244
|
+
ok: outcome.ok,
|
|
245
|
+
status: outcome.status,
|
|
246
|
+
at: nowMs(clock),
|
|
247
|
+
durationMs,
|
|
248
|
+
...(outcome.ok ? {} : { error: outcome.detail }),
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
if (outcome.ok) {
|
|
252
|
+
return {
|
|
253
|
+
endpointId: endpoint.id,
|
|
254
|
+
eventId: input.eventId,
|
|
255
|
+
status: outcome.status,
|
|
256
|
+
durationMs,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (failures >= disableAfter) {
|
|
261
|
+
const reason = `${failures} consecutive failed deliveries, disableAfter is ${disableAfter}`;
|
|
262
|
+
await definition.ledger.disable(endpoint.id, reason);
|
|
263
|
+
// The endpoint's own code and not this attempt's: what an operator needs to see is that
|
|
264
|
+
// deliveries have STOPPED, not the status of the one that tipped it over.
|
|
265
|
+
throw new WebhookEndpointDisabledError({
|
|
266
|
+
webhook: definition.name,
|
|
267
|
+
endpointId: endpoint.id,
|
|
268
|
+
consecutiveFailures: failures,
|
|
269
|
+
disableAfter,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
throw deliveryError(definition.name, endpoint, outcome);
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** A URL no delivery may open, or a secret that would make the POST unsigned. */
|
|
278
|
+
function assertDeliverable(name: string, endpoint: WebhookEndpoint): void {
|
|
279
|
+
if (endpoint.secret.length === 0) {
|
|
280
|
+
throw new WebhookEndpointInvalidError({
|
|
281
|
+
webhook: name,
|
|
282
|
+
endpointId: endpoint.id,
|
|
283
|
+
reason: 'has an empty secret, so the delivery would carry no proof of who sent it',
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
let protocol: string;
|
|
287
|
+
try {
|
|
288
|
+
protocol = new URL(endpoint.url).protocol;
|
|
289
|
+
} catch {
|
|
290
|
+
// Never the caught value and never the url: an unparseable value is exactly the one whose
|
|
291
|
+
// shape is unknown, and this reason reaches a durable dead-letter row.
|
|
292
|
+
throw new WebhookEndpointInvalidError({
|
|
293
|
+
webhook: name,
|
|
294
|
+
endpointId: endpoint.id,
|
|
295
|
+
reason: 'has a url that is not a url',
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
// `file:`, `data:` and the rest are refused by NAME rather than by a blocklist: a delivery opens
|
|
299
|
+
// an HTTP conversation, and anything else is a row in a table reaching the worker's filesystem.
|
|
300
|
+
if (protocol !== 'https:' && protocol !== 'http:') {
|
|
301
|
+
throw new WebhookEndpointInvalidError({
|
|
302
|
+
webhook: name,
|
|
303
|
+
endpointId: endpoint.id,
|
|
304
|
+
reason: `has a ${protocol} url, and a delivery only ever opens http: or https:`,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
type Outcome =
|
|
310
|
+
| { readonly ok: true; readonly status: number }
|
|
311
|
+
| {
|
|
312
|
+
readonly ok: false;
|
|
313
|
+
readonly status: number | null;
|
|
314
|
+
readonly detail: string;
|
|
315
|
+
readonly retryAfterSeconds?: number;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
async function attemptDelivery(
|
|
319
|
+
send: WebhookFetch,
|
|
320
|
+
endpoint: WebhookEndpoint,
|
|
321
|
+
signing: Parameters<typeof webhookHeaders>[0],
|
|
322
|
+
): Promise<Outcome> {
|
|
323
|
+
let response: Response;
|
|
324
|
+
try {
|
|
325
|
+
response = await send(endpoint.url, {
|
|
326
|
+
method: 'POST',
|
|
327
|
+
headers: {
|
|
328
|
+
...endpoint.headers,
|
|
329
|
+
'content-type': WEBHOOK_CONTENT_TYPE,
|
|
330
|
+
// LAST, so an endpoint row's own headers can never overwrite the signature it is proved by.
|
|
331
|
+
...webhookHeaders(signing),
|
|
332
|
+
},
|
|
333
|
+
body: signing.body,
|
|
334
|
+
// Never followed: a 3xx would re-POST a body signed for one host to whatever the receiver
|
|
335
|
+
// named, and the signature would travel with it.
|
|
336
|
+
redirect: 'manual',
|
|
337
|
+
});
|
|
338
|
+
} catch (error) {
|
|
339
|
+
// `renderThrowable`, never `String(error)` or `${error}`: this string lands in a `cause` and on
|
|
340
|
+
// a durable ledger row, and a null-prototype throwable makes both of those a `TypeError`.
|
|
341
|
+
return { ok: false, status: null, detail: renderThrowable(error) };
|
|
342
|
+
}
|
|
343
|
+
if (response.ok) return { ok: true, status: response.status };
|
|
344
|
+
const stated = retryAfterSeconds(response);
|
|
345
|
+
return {
|
|
346
|
+
ok: false,
|
|
347
|
+
status: response.status,
|
|
348
|
+
detail: `status ${response.status}`,
|
|
349
|
+
...(stated === undefined ? {} : { retryAfterSeconds: stated }),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Which of the three failure codes this outcome is. The split is "can the same request land?". */
|
|
354
|
+
function deliveryError(
|
|
355
|
+
name: string,
|
|
356
|
+
endpoint: WebhookEndpoint,
|
|
357
|
+
outcome: Extract<Outcome, { ok: false }>,
|
|
358
|
+
): Error {
|
|
359
|
+
const base = { webhook: name, endpointId: endpoint.id, url: endpoint.url };
|
|
360
|
+
if (outcome.status === null) {
|
|
361
|
+
return new WebhookDeliveryFailedError({ ...base, status: null, detail: outcome.detail });
|
|
362
|
+
}
|
|
363
|
+
if (!isRetryableStatus(outcome.status)) {
|
|
364
|
+
return new WebhookDeliveryRejectedError({ ...base, status: outcome.status });
|
|
365
|
+
}
|
|
366
|
+
if (outcome.retryAfterSeconds !== undefined) {
|
|
367
|
+
return new WebhookDeliveryThrottledError({
|
|
368
|
+
...base,
|
|
369
|
+
status: outcome.status,
|
|
370
|
+
retryAfterSeconds: outcome.retryAfterSeconds,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
return new WebhookDeliveryFailedError({
|
|
374
|
+
...base,
|
|
375
|
+
status: outcome.status,
|
|
376
|
+
detail: outcome.detail,
|
|
377
|
+
});
|
|
378
|
+
}
|