| 1 | import { hmacSha256Hex } from "./util.js"; |
| 2 | |
| 3 | /** One webhook delivery; failed ones go back on the events queue with a delay. */ |
| 4 | export interface WebhookRetry { |
| 5 | type: "webhook_retry"; |
| 6 | repoId: string; |
| 7 | hookId: string; |
| 8 | event: string; |
| 9 | diffineryEvent: string; |
| 10 | body: string; |
| 11 | delivery: string; |
| 12 | /** Attempts made so far. */ |
| 13 | attempt: number; |
| 14 | } |
| 15 | |
| 16 | export interface WebhookDeps { |
| 17 | /** The hook as it is now (it may have been deleted, disabled or re-keyed), or null. */ |
| 18 | hook(repoId: string, hookId: string): Promise<{ id: string; url: string; secret: string } | null>; |
| 19 | record(repoId: string, hookId: string, status: number): Promise<void>; |
| 20 | requeue(r: WebhookRetry, delaySeconds: number): Promise<void>; |
| 21 | fetch?: typeof fetch; |
| 22 | } |
| 23 | |
| 24 | // Delays after the first, second, ... failed attempt: about five hours in all. |
| 25 | export const WEBHOOK_RETRY_DELAYS = [60, 300, 1800, 3600, 3 * 3600]; |
| 26 | |
| 27 | /** Network errors, timeouts, rate limits and server errors are worth another try. */ |
| 28 | export const retryable = (status: number) => status === 0 || status === 408 || status === 429 || status >= 500; |
| 29 | |
| 30 | /** Send one delivery (to `known` when the caller already has the hook) and schedule a retry if it fails. */ |
| 31 | export async function deliverWebhook(deps: WebhookDeps, r: WebhookRetry, known?: { id: string; url: string; secret: string }): Promise<number | null> { |
| 32 | const h = known ?? (await deps.hook(r.repoId, r.hookId)); |
| 33 | if (!h) return null; |
| 34 | let status = 0; |
| 35 | try { |
| 36 | const res = await (deps.fetch ?? fetch)(h.url, { |
| 37 | method: "POST", |
| 38 | headers: { |
| 39 | "content-type": "application/json", |
| 40 | "user-agent": "Diffinery-Hookshot/1", |
| 41 | "x-github-event": r.event, |
| 42 | "x-github-delivery": r.delivery, |
| 43 | "x-diffinery-event": r.diffineryEvent, |
| 44 | "x-hub-signature-256": `sha256=${await hmacSha256Hex(h.secret, r.body)}`, |
| 45 | }, |
| 46 | body: r.body, |
| 47 | redirect: "manual", |
| 48 | signal: AbortSignal.timeout(10_000), |
| 49 | }); |
| 50 | status = res.status; |
| 51 | } catch { |
| 52 | status = 0; |
| 53 | } |
| 54 | await deps.record(r.repoId, h.id, status); |
| 55 | const delay = WEBHOOK_RETRY_DELAYS[r.attempt]; |
| 56 | if (retryable(status) && delay !== undefined) await deps.requeue({ ...r, attempt: r.attempt + 1 }, delay); |
| 57 | return status; |
| 58 | } |