Rebounder Tech Blog

Written by the people who actually run these systems in production.

AbortSignal.timeout Shorter Than the Server's Own Timeout

Published About 8 min readBy the Rebounder engineering team — the people who operate these systems

This article may contain affiliate links. Its content is not affected by advertising.

In short

If the receiver takes 15 seconds inside the same request while the sender's AbortSignal.timeout is 8 seconds, the sender gives up and logs a failure — even though the receiver went on to succeed.

If the receiver takes 15 seconds inside the same request while the sender’s AbortSignal.timeout is 8 seconds, the sender gives up and logs a failure — even though the receiver went on to succeed.

The short version

In the ad-delivery integration (a POST from portal to v2), the sender was cutting the request after AbortSignal.timeout(8000) — 8 seconds — while the receiver (v2) was, inside that same request, fetching the creative asset and re-hosting it to GCS, which could take up to 15 seconds. Because the receiver’s own work was structurally longer than the sender’s timeout, v2 could genuinely finish the delivery while portal had already cut the connection itself and logged the result as “transient” — a temporary failure a retry should fix. The fix was simply raising the timeout to 30 seconds; nothing about the receiver’s processing or the error-classification logic changed.

What it looks like

  • Every ad delivery from portal to v2 — whether triggered by immediate delivery right after approval or by a cron retry — goes through the same sendV2Delivery function and POSTs to v2
  • Inside that request, v2 fetches the creative’s signed URL (assetFetchUrl) and re-hosts it to GCS before returning 200. That fetch step alone has its own 15-second timeout
  • portal aborts the POST itself after 8 seconds, falls into its catch block, and records the result as “network error or timeout = transient”
  • Even past the 8-second mark, v2’s fetch-and-rehost keeps running and eventually returns 200 — but by then portal has already closed the connection, so nobody receives it
  • Every retry fails for the identical reason (8 seconds elapsed) and gets logged as the same transient failure, while v2 may have been quietly succeeding on the same delivery, repeatedly, the whole time

Why

Before the fix, v2-delivery.ts hard-coded the POST’s own timeout at 8 seconds:

// v2-delivery.ts (2bed6a9f3c, lines 57-77)
// POST to v2. Also used for immediate delivery (right after approval, inside a user
// request), so the timeout is explicit (if v2 hangs, don't block the caller's own
// operation; failures get picked up by the cron retry).
let res: Response;
try {
  res = await fetch(`${v2Url}/api/partner/delivery`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${secret}`,
    },
    body: JSON.stringify(built.payload),
    signal: AbortSignal.timeout(8000),
  });
} catch (e) {
  // Network error or timeout = transient (a retry has a chance of recovering)
  return {
    ok: false,
    fatal: false,
    error: `network error: ${e instanceof Error ? e.message : String(e)}`,
  };
}

As the comment states, 8 seconds was chosen so that “if v2 hangs, it won’t block the caller’s own operation” — the user request behind an immediate delivery. That’s a reasonable goal on its own, but it never factored in how much work v2 does inside that same request.

On the receiving side, v2’s delivery endpoint fetches and re-hosts the asset with its own timeout, independently of whatever the sender is doing:

// asset-rehost.ts (e8092c09, lines 116-120)
/** Practical cap on bytes fetched (guards against runaway input). Ad creatives are expected
 *  to be images or short-form video. */
const MAX_ASSET_BYTES = 50 * 1024 * 1024; // 50 MiB

/** Timeout for fetching the asset (guards against a hang on an internal destination or an
 *  oversized fetch). */
const FETCH_TIMEOUT_MS = 15_000;
// asset-rehost.ts (e8092c09, lines 182-192)
// 1. Fetch (transient: expired signed URL, network drop → AssetRehostError → route 5xx).
//    redirect: 'manual' so we don't follow redirects (blocks a public→internal-IP 30x
//    detour; SSRF).
let res: Response;
try {
  res = await doFetch(target, {
    redirect: "manual",
    signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
  });
} catch (cause) {
  throw new AssetRehostError("asset fetch failed", { cause });
}

v2’s FETCH_TIMEOUT_MS (15 seconds) covers only the asset fetch itself — the total time from receiving portal’s POST to returning a response adds the GCS upload on top of that. portal’s 8-second budget was structurally shorter than that total.

Even when v2 does return 200 past the 8-second mark, portal’s fetch has already been aborted, so execution never reaches the res.ok branch — the one responsible for writing back the K1 advertiser ID using v2’s confirmed advertiser UUID:

// v2-delivery.ts (cf19ac2424, line 90 onward)
if (res.ok) {
  // K1 auto-linking: write v2's confirmed advertiser UUID into companies.kimiteras_v2_advertiser_id ...

When a timeout falls into catch, this branch never runs at all. Further downstream, the outbox worker only calls a function that records “delivery actually started showing” the first time a delivery reaches "sent" status:

// outbox-worker.ts (45339105c2, lines 88-100)
await admin.from("sync_outbox").update(patch).eq("id", row.id);

// Record the moment delivery "actually started showing" on the contract (first time only,
// 0095). We use this point — when v2 accepted it — rather than the approval time, because
// approval can succeed while the sync itself still fails and nothing is actually showing.
// The gap against period_start becomes exactly "billed but not yet showing" time.
// Best-effort (delivery itself has already succeeded).
if (d.status === "sent" && row.kind === "delivery") {
  try {
    const payload = (row.payload ?? {}) as { placement_id?: string | null };
    await markDeliveryStarted(admin, payload.placement_id ?? null);
  } catch (e) {
    console.error("[outbox] failed to mark delivery start:", e);
  }
}

d.status is derived from sendV2Delivery’s own result, so as long as the outcome is transient (timeouts included), it never becomes "sent", and markDeliveryStarted is never called. That function’s entire job is to stamp delivery_started_at — “the moment it actually started showing” — onto the contract, exactly once:

// delivery-schedule.ts (4257484719, lines 51-75)
/**
 * Record the start of actual delivery on the contract (first time only, immutable).
 * Called the moment the outbox worker confirms a successful delivery to v2 — i.e. the
 * point it "actually started showing." We use delivery success rather than approval time,
 * because approval can succeed while the sync itself still fails and nothing shows.
 * Best-effort (delivery itself has already succeeded even if this write fails).
 */
export async function markDeliveryStarted(
  admin: Admin,
  placementId: string | null | undefined
): Promise<void> {
  if (!placementId) return;
  const { data: placement } = await admin
    .from("placements")
    .select("contract_id")
    .eq("id", placementId)
    .maybeSingle();
  const contractId = (placement?.contract_id as string | null) ?? null;
  if (!contractId) return;
  // Write only the first time (is-null condition) — don't overwrite on a creative swap or redelivery.
  await admin
    .from("contracts")
    .update({ delivery_started_at: new Date().toISOString() })
    .eq("id", contractId)
    .is("delivery_started_at", null);
}

The upshot: even when v2 genuinely succeeded, portal’s premature timeout meant the condition for calling markDeliveryStarted (d.status === "sent") was never reached, so delivery_started_at — the field meant to measure “billed but not yet showing” time — never got stamped at all.

Fixing it

The fix raised the sender’s timeout to 30 seconds and left a comment explaining why; nothing else changed.

// v2-delivery.ts (cf19ac2424, lines 64-88)
// POST to v2. Also used for immediate delivery (right after approval, inside a user
// request), so the timeout is explicit (if v2 hangs, don't block the caller's own
// operation; failures get picked up by the cron retry).
let res: Response;
try {
  res = await fetch(`${v2Url}/api/partner/delivery`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${secret}`,
    },
    body: JSON.stringify(built.payload),
    // v2 fetches assetFetchUrl (15s timeout) and re-hosts to GCS **inside the same request**,
    // so for larger video assets normal processing structurally exceeds 8s. If the sender cuts
    // first, it becomes "v2 delivered fine but portal saw failed → times out every time → dead,"
    // and because the 200 response is never read, the K1 advertiserId write-back and the
    // delivery_started_at stamp never fire either.
    signal: AbortSignal.timeout(30_000),
  });
} catch (e) {
  // Network error or timeout = transient (a retry has a chance of recovering)
  return {
    ok: false,
    fatal: false,
    error: `network error: ${e instanceof Error ? e.message : String(e)}`,
  };
}

v2’s FETCH_TIMEOUT_MS (15 seconds) and its fetch-then-rehost-then-respond flow are untouched. The only thing that changed is that the sender’s timeout now correctly reflects how long the other side actually needs. The classification logic — treating a timeout and a plain network drop as the same “transient” outcome — is also unchanged, so if the same root cause produces a failure again in the future, it will still fall into the same auto-retry path.

Preventing a repeat

The source files have no record of any mechanism — a test or a contract check comparing sender and receiver timeout values — meant to catch this kind of timeout mismatch. This fix was part of an audit commit that cleaned up nine issues at once, and no specific repeat-prevention measure is documented for this one individually.

Structurally, this mismatch went unnoticed for a long time because a timeout and an ordinary network drop were recorded as the identical “temporary failure a retry should fix.” Had the abort reason been distinguished and logged separately, a pattern like “fails at exactly 8 seconds, every single time” would have stood out, and the gap against the receiver’s own processing time might have surfaced sooner. Whenever two services in separate repositories exchange one synchronous, heavy round of work inside a single request, the caller’s timeout needs to be designed not as “the ceiling for the other side hanging,” but as “the ceiling for how long the other side actually takes to finish normally.”

Frequently asked questions

Q1If delivery actually succeeded on the receiving side, why was it logged as an error?

The sender aborted its own fetch after AbortSignal.timeout(8000) and logged it as transient — a blip a retry should fix. In reality v2's own processing (up to 15s) kept running in the background and later returned 200, but the sender had already cut the socket, so nobody was listening.

Q2If a longer timeout fixes it, why wasn't it longer from the start?

The source files don't say. What they show: 8 seconds was chosen so a hung receiver wouldn't block the caller's request, not because of how long a normal delivery takes. It never accounted for the receiver fetching and re-hosting an asset to GCS inside that request — up to 15 seconds.

Q3Would an error screen or alert have surfaced this timeout mismatch?

No. The timeout was treated like an ordinary network drop — both fed the same cron auto-retry. Every retry failed for the same reason (8 seconds elapsed) and logged identically, so it looked like 'a delivery that sometimes fails,' with no sign the receiver had actually succeeded.

Environment verified

  • kimiteras-portal: next 16.2.7 / @supabase/supabase-js ^2.106.2
  • Sender-side (portal) AbortSignal.timeout fix: 2026-07-25
  • Receiver-side (v2) FETCH_TIMEOUT_MS=15_000 already existed as of commit e8092c09

What this article is based on

  • TypeScript file lines 57-77commit 2bed6a9
  • TypeScript file lines 64-88commit cf19ac2
  • TypeScript file lines 116-120commit e8092c0
  • TypeScript file lines 182-192commit e8092c0
  • TypeScript file lines 88-100commit 4533910
  • TypeScript file lines 51-75commit 4257484

Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.