diff --git a/src/orb/webhook.ts b/src/orb/webhook.ts index d5f0d169e2..f722db77ff 100644 --- a/src/orb/webhook.ts +++ b/src/orb/webhook.ts @@ -78,18 +78,46 @@ export async function handleOrbWebhook(c: Context<{ Bindings: Env }>): Promise { + try { + const relayResult = await forwardOrbEvent(env, args, fetchImpl); + // forwardOrbEvent returns "failed" only for an ENROLLED install (a null/absent id "skips"), so installationId is + // non-null here — persist the failed push so the retry-orb-relay cron re-attempts it. + if (relayResult === "failed") { + await storeRelayFailure(env, { deliveryId: args.deliveryId, eventName: args.eventName, installationId: args.installationId!, rawBody: args.rawBody }); + } + } catch { + /* v8 ignore next -- fail-safe: a forward/persist error must never surface from the deferred task */ + } +} + +/** Run `task` AFTER the response is sent (Cloudflare Workers `waitUntil`), so a slow downstream relay forward can't + * delay the webhook ACK past GitHub's ~10s delivery deadline. Falls back to fire-and-forget where there is no + * execution context (e.g. a unit-test harness); the self-host server provides its own waitUntil shim. */ +function scheduleAfterResponse(c: Context<{ Bindings: Env }>, task: Promise): void { + try { + (c.executionCtx as unknown as { waitUntil(p: Promise): void }).waitUntil(task); + } catch { + void task; + } +} + async function getOrbWebhookEvent(env: Env, deliveryId: string): Promise<{ payloadHash: string; status: string } | null> { const row = await env.DB.prepare("SELECT payload_hash AS payloadHash, status FROM orb_webhook_events WHERE delivery_id = ?") .bind(deliveryId) diff --git a/test/integration/orb-relay.test.ts b/test/integration/orb-relay.test.ts index d70c614558..23f9f63ece 100644 --- a/test/integration/orb-relay.test.ts +++ b/test/integration/orb-relay.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { createApp } from "../../src/api/routes"; import { issueOrbEnrollment } from "../../src/orb/broker"; +import { relayForward } from "../../src/orb/webhook"; import { enqueueRelayPending, forwardOrbEvent, MAX_ORB_RELAY_REGISTER_BODY_BYTES, pullRelayPending, readOrbRelayRegisterBody, registerOrbRelay, relaySignature, relayVerify, retryFailedRelays, storeRelayFailure } from "../../src/orb/relay"; import { createTestEnv, type TestD1Database } from "../helpers/d1"; @@ -197,6 +198,24 @@ describe("forwardOrbEvent", () => { }); }); +describe("relayForward (deferred forward + failure persistence, #orb-ack-fast)", () => { + it("persists a FAILED push to orb_relay_failures so the retry cron re-attempts it", async () => { + const e = brokeredEnv(); + const secret = await enroll(e, 820); + await registerOrbRelay(e, secret, "https://c.example/v1/orb/relay"); + await relayForward(e, { eventName: "pull_request", installationId: 820, deliveryId: "rf-fail", rawBody: "{}" }, (() => Promise.resolve(new Response("no", { status: 503 }))) as typeof fetch); + const row = await db(e).prepare("SELECT installation_id, event_name FROM orb_relay_failures WHERE delivery_id='rf-fail'").first<{ installation_id: number; event_name: string }>(); + expect(row).toMatchObject({ installation_id: 820, event_name: "pull_request" }); + }); + + it("does NOT persist when the forward is skipped (enrolled but no relay) and never throws", async () => { + const e = brokeredEnv(); + await enroll(e, 821); // enrolled, but no relay registered → forwardOrbEvent skips before any fetch + await expect(relayForward(e, { eventName: "pull_request", installationId: 821, deliveryId: "rf-skip", rawBody: "{}" })).resolves.toBeUndefined(); + expect(await db(e).prepare("SELECT delivery_id FROM orb_relay_failures WHERE delivery_id='rf-skip'").first()).toBeFalsy(); + }); +}); + describe("relayVerify", () => { it("accepts a valid signature (sha256= or bare hex) and rejects wrong-secret / malformed / missing", async () => { const body = '{"x":1}'; diff --git a/test/integration/orb-webhook.test.ts b/test/integration/orb-webhook.test.ts index dca8a719d7..63b95aba76 100644 --- a/test/integration/orb-webhook.test.ts +++ b/test/integration/orb-webhook.test.ts @@ -13,11 +13,12 @@ async function sign(body: string, secret: string): Promise { return `sha256=${[...new Uint8Array(signed)].map((b) => b.toString(16).padStart(2, "0")).join("")}`; } -function ctx(e: Env, headers: Record, request: Request): Context<{ Bindings: Env }> { +function ctx(e: Env, headers: Record, request: Request, executionCtx?: { waitUntil(p: Promise): void }): Context<{ Bindings: Env }> { return { req: { raw: request, header: (n: string) => headers[n.toLowerCase()] ?? null }, env: e, json: (payload: unknown, status?: number) => Response.json(payload, status === undefined ? undefined : { status }), + ...(executionCtx ? { executionCtx } : {}), } as unknown as Context<{ Bindings: Env }>; } @@ -97,6 +98,19 @@ describe("handleOrbWebhook (POST /v1/orb/webhook)", () => { expect(await row(e, "ok-1")).toMatchObject({ action: "created", installation_id: 42, repository_full_name: "JSONbored/gittensory", status: "received" }); }); + it("ACKs 202 immediately and SCHEDULES the relay forward via waitUntil (never blocks the ACK on a slow container, #orb-ack-fast)", async () => { + const e = env(); + const scheduled: Promise[] = []; + const PR = JSON.stringify({ action: "opened", installation: { id: 99 }, repository: { full_name: "JSONbored/gittensory" }, number: 7 }); + const request = new Request("https://collector/v1/orb/webhook", { method: "POST", body: PR }); + const headers = { "x-github-delivery": "fwd-1", "x-github-event": "pull_request", "x-hub-signature-256": await sign(PR, SECRET) }; + const res = await handleOrbWebhook(ctx(e, headers, request, { waitUntil: (p) => scheduled.push(p) })); + expect(res.status).toBe(202); + await expect(res.json()).resolves.toMatchObject({ status: "received", eventName: "pull_request" }); + expect(scheduled).toHaveLength(1); // forward DEFERRED past the response, not awaited inline + await Promise.all(scheduled); // drain — install 99 has no enrollment → forward skips cleanly + }); + it("stores null fields for a payload with no action/installation/repository (e.g. ping)", async () => { const e = env(); await post(e, JSON.stringify({ zen: "keep it logically awesome" }), { delivery: "ping-1", event: "ping" });