> ## Documentation Index
> Fetch the complete documentation index at: https://docs.gavai.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Retry behavior and circuit breaker

> The exact retry schedule, what counts as failure, and how to recover from an open breaker.

export const ErrorTable = ({errors}) => <div className="gv-error-table" role="table" aria-label="Errors and fixes">
    <div className="gv-error-table__head" role="row">
      <span className="gv-error-table__col-head" role="columnheader">Error</span>
      <span className="gv-error-table__col-head" role="columnheader">Cause</span>
      <span className="gv-error-table__col-head" role="columnheader">Fix</span>
    </div>
    {errors.map((err, i) => <div className="gv-error-table__row" role="row" key={i}>
        <code className="gv-error-table__code" role="cell">{err.code}</code>
        <div className="gv-error-table__cause" role="cell">{err.cause}</div>
        <div className="gv-error-table__fix" role="cell">{err.fix}</div>
      </div>)}
  </div>;

export const BrandCard = ({eyebrow, title, href, icon, badge, cta, featured = false, horizontal = false, children}) => {
  const classes = ["gv-card", featured && "gv-card--featured", horizontal && "gv-card--horizontal"].filter(Boolean).join(" ");
  const inner = <>
      {icon && <span className="gv-card__icon" aria-hidden="true">
          <Icon icon={icon} />
        </span>}
      <div className="gv-card__main">
        {(eyebrow || badge) && <div className="gv-card__meta">
            {eyebrow && <Eyebrow label={eyebrow} tone="muted" />}
            {badge && <span className="gv-card__badge">{badge}</span>}
          </div>}
        <h3 className="gv-card__title">{title}</h3>
        {children && <div className="gv-card__body">{children}</div>}
        {cta && <span className="gv-card__cta">
            {cta}
            <svg className="gv-card__cta-arrow" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <line x1="7" y1="17" x2="17" y2="7" />
              <polyline points="7 7 17 7 17 17" />
            </svg>
          </span>}
      </div>
    </>;
  return href ? <a className={classes} href={href}>
      {inner}
    </a> : <div className={classes}>{inner}</div>;
};

export const NumberedStep = ({n, title, children}) => <li className="gv-step">
    <span className="gv-step__index">{n}</span>
    <div className="gv-step__body">
      <h3 className="gv-step__title">{title}</h3>
      <div className="gv-step__content">{children}</div>
    </div>
  </li>;

export const NumberedSteps = ({children}) => <ol className="gv-steps">{children}</ol>;

export const FeatureTable = ({columns, rows, featuredRow, caption, variant}) => <div className={`gv-feature-table ${variant ? `gv-feature-table--${variant}` : ""}`}>
    <table>
      {caption && <caption className="gv-feature-table__caption">{caption}</caption>}
      <thead>
        <tr>
          {columns.map(col => <th key={col.key} scope="col">
              <span className="gv-feature-table__header">{col.label}</span>
            </th>)}
        </tr>
      </thead>
      <tbody>
        {rows.map((row, i) => {
  const rowClass = [i === featuredRow ? "gv-feature-table__row--featured" : "", row.chosen ? "gv-feature-table__row--chosen" : ""].filter(Boolean).join(" ");
  return <tr key={i} className={rowClass}>
              {columns.map(col => {
    const v = row[col.key];
    if (variant === "tradeoff" && Array.isArray(v)) {
      const pill = col.key === "pros" ? "gv-feature-table__pill--pro" : col.key === "cons" ? "gv-feature-table__pill--con" : "";
      return <td key={col.key}>
                      {v.map((item, j) => <span key={j} className={`gv-feature-table__pill ${pill}`}>{item}</span>)}
                    </td>;
    }
    return <td key={col.key}>{v}</td>;
  })}
            </tr>;
})}
      </tbody>
    </table>
  </div>;

export const ArrowItem = ({children}) => <li className="gv-arrow-item">
    <span className="gv-arrow-item__arrow" aria-hidden="true">{"↳︎"}</span>
    <span className="gv-arrow-item__content">{children}</span>
  </li>;

export const ArrowList = ({children}) => <ul className="gv-arrow-list">{children}</ul>;

export const Eyebrow = ({label, tone = "default"}) => <div className={`gv-eyebrow gv-eyebrow--${tone}`}>
    <span className="gv-eyebrow__bar" aria-hidden="true" />
    <span className="gv-eyebrow__label">{label}</span>
  </div>;

<Eyebrow label="WEBHOOKS" />

When your endpoint doesn't respond `2xx` within 10 seconds, the gavAI dispatcher retries the delivery on a fixed exponential schedule. After enough consecutive deliveries have exhausted all their retries, a circuit breaker opens and pauses traffic to that endpoint for an hour. This page is the spec for both behaviors — the exact schedule, what counts as failure, and what to do when the breaker trips. By the end you'll know how long you have to recover from an outage before deliveries start getting dropped.

## A delivery's lifecycle

```mermaid theme={null}
stateDiagram-v2
    [*] --> Pending
    Pending --> Delivered: 2xx within 10 s
    Pending --> Retrying: non-2xx, timeout, or connection error
    Retrying --> Pending: backoff delay elapses (attempts < 6)
    Retrying --> Exhausted: 6 attempts failed
    Delivered --> [*]
    Exhausted --> [*]
```

A single delivery (`whd_01H...`) walks this graph. Each transition through `Pending → Retrying → Pending` is one retry attempt, and the dispatcher gives up after the sixth attempt fails. Exhausted deliveries are recorded in the delivery log but not re-attempted automatically — you can replay them from the console for up to 7 days.

## The retry schedule

A single event gets up to 6 delivery attempts. The delay before each attempt is fixed:

<FeatureTable
  caption="Per-delivery retry schedule (6 attempts max)"
  featuredRow={5}
  columns={[
{ key: "attempt", label: "Attempt" },
{ key: "delay", label: "Delay from previous attempt" },
{ key: "cumulative", label: "Cumulative time from initial dispatch" },
]}
  rows={[
{ attempt: "1 (initial)", delay: "Immediate", cumulative: "0 s" },
{ attempt: "2", delay: "1 second", cumulative: "~1 s" },
{ attempt: "3", delay: "5 seconds", cumulative: "~6 s" },
{ attempt: "4", delay: "30 seconds", cumulative: "~36 s" },
{ attempt: "5", delay: "5 minutes", cumulative: "~6 min" },
{ attempt: "6", delay: "1 hour", cumulative: "~1 h 6 min" },
]}
/>

End to end, a single delivery has roughly 1 hour 6 minutes from initial dispatch to the final attempt. That's your maximum recovery window for a transient outage. Anything longer and the dispatcher gives up on that specific delivery — though new events keep flowing, see the circuit breaker below.

## What counts as a delivery failure

The dispatcher treats any of these as a failed attempt and schedules the next retry:

<ErrorTable
  errors={[
{
  code: "non-2xx status",
  cause: "Any response code outside the 2xx family, including `3xx` redirects — the dispatcher does not follow them.",
  fix: "Return `2xx` once your handler accepts the event. A `301` is a failure, not a forwarding instruction.",
},
{
  code: "timeout (10 s)",
  cause: "No response within 10 seconds. Clock runs from request send to response status line.",
  fix: "Return `200` fast and push heavy work to a queue. Slow handlers fail under load even when they eventually succeed.",
},
{
  code: "connection refused",
  cause: "The dispatcher could not establish a TCP connection — server down, port closed, firewall rule.",
  fix: "Check that the endpoint is reachable on port 443 from the public internet; verify the host record.",
},
{
  code: "TLS handshake",
  cause: "Certificate expired, hostname mismatch, or unsupported cipher suite.",
  fix: "Renew the cert (Let's Encrypt auto-renewal is the most common failure), confirm the chain, and confirm SAN matches the host.",
},
]}
/>

A `2xx` is the only thing that closes the delivery successfully — including `204 No Content` and `202 Accepted`. Return whatever 2xx is most truthful for your handler.

## The circuit breaker

Six attempts on a single event is one kind of failure. A persistently broken endpoint is a different kind, and the dispatcher handles it separately so one bad endpoint can't burn the retry queue for the whole workspace.

After **5 consecutive deliveries each exhaust all 6 attempts** (5 × 6 = 30 total failed attempts) on the same subscription, the circuit breaker opens. While it's open:

<ArrowList>
  <ArrowItem>**No events are delivered** to that endpoint. The dispatcher records the events but does not attempt to push them.</ArrowItem>
  <ArrowItem>**Events that fire during this window are dropped, not queued.** This is the most important sentence on this page. If your application can't tolerate gaps, the recovery story is reconciliation via the [Audit log API](/api-reference/resources/logs), not waiting for the dispatcher to replay them.</ArrowItem>
  <ArrowItem>**The breaker resets automatically after 1 hour.** No manual action needed for the reset itself.</ArrowItem>
</ArrowList>

The 1-hour cool-down is deliberate — it gives a deploy or cert renewal time to land without you having to chase a button to re-enable the subscription.

## Recovering from an open breaker

<NumberedSteps>
  <NumberedStep n="01" title="Fix the underlying failure">
    Look at the delivery log at `GET /v1/workspaces/acme/webhook-deliveries` (or the **Deliveries** tab in the console). Each failed attempt records the response status, the first 256 bytes of the response body, and the error class. The most common causes are TLS expiry, a deploy that broke the handler, and a missing raw-body reader after a framework upgrade. Verify your endpoint returns `2xx` to a manual `curl` before doing anything else.
  </NumberedStep>

  <NumberedStep n="02" title="Wait for the cool-down or send a test event">
    The breaker resets on its own after 1 hour. To verify the fix without waiting, fire a test event from `/console/settings/webhooks → your subscription → Send test event` once the cool-down is past. Test events bypass the breaker only after it has reset.
  </NumberedStep>

  <NumberedStep n="03" title="One success closes the breaker">
    The first successful `2xx` delivery after the breaker resets closes it. Normal traffic resumes immediately.
  </NumberedStep>

  <NumberedStep n="04" title="Reconcile the gap if you need to">
    Events that fired while the breaker was open are not replayed. If your application's correctness depends on having seen every event, query the [Audit log API](/api-reference/resources/logs) for events between the first failed delivery and the recovery moment, and process them as if they had arrived via webhook.
  </NumberedStep>
</NumberedSteps>

<Warning>
  Dropped-during-breaker events are gone from the delivery pipeline. The audit log keeps them, but you have to ask for them — there is no automatic replay. Decide ahead of time whether your handler is the kind that needs reconciliation, and write the reconciliation script before you need it at 2 a.m.
</Warning>

## Operating the endpoint

<ArrowList>
  <ArrowItem>**Synthetic checks beat alerts.** A health check that fires every minute against your webhook endpoint catches a TLS expiry or a bad deploy before the dispatcher does. Hook it to your pager.</ArrowItem>
  <ArrowItem>**Alert at 2–3 consecutive failures, not at the breaker.** The delivery log exposes per-attempt status. Alerting at the breaker means you find out 30 attempts and an hour late.</ArrowItem>
  <ArrowItem>**Run a test event after every deploy.** The console's Send test event button is the cheapest possible post-deploy check — fire it before real traffic hits the new version.</ArrowItem>
  <ArrowItem>**Respond fast, queue the work.** A handler that returns `200` in 50 ms and writes to a background queue will never hit the timeout. A handler that does the full work inline will, the first time the database has a slow afternoon.</ArrowItem>
</ArrowList>

## Where to next

<Columns cols={2}>
  <BrandCard eyebrow="WEBHOOKS" title="Idempotency for receivers" href="/webhooks/idempotency">
    Retries mean duplicates. Here's the dedupe pattern that handles them.
  </BrandCard>

  <BrandCard eyebrow="GUIDES" title="Handle webhooks end-to-end" href="/guides/handle-webhooks">
    Build a handler from scratch — register, verify, dedupe, and test.
  </BrandCard>
</Columns>
