> ## 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.

# Handle errors and retries

> Catch typed errors, back off correctly, and use idempotency keys so retries never double-charge.

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 PageMeta = ({audience, readingTime, prereqs, level}) => {
  const items = [audience && ({
    key: "audience",
    label: "AUDIENCE",
    value: audience
  }), readingTime && ({
    key: "time",
    label: "READ",
    value: readingTime
  }), level && ({
    key: "level",
    label: "LEVEL",
    value: level
  }), prereqs && prereqs.length > 0 && ({
    key: "prereqs",
    label: "PREREQS",
    value: prereqs.join(" · ")
  })].filter(Boolean);
  if (items.length === 0) return null;
  return <div className="gv-page-meta" role="group" aria-label="Page metadata">
      {items.map(item => <div className="gv-page-meta__item" key={item.key}>
          <span className="gv-page-meta__label">{item.label}</span>
          <span className="gv-page-meta__value">{item.value}</span>
        </div>)}
    </div>;
};

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 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 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="GUIDE" />

<PageMeta audience="Developers" readingTime="12 min" level="Intermediate" prereqs={["@gavai/sdk installed", "HTTP status code basics"]} />

By the end of this guide your integration catches typed errors instead of strings, retries only the errors that are safe to retry, backs off with jitter so a rate limit does not compound, and attaches an idempotency key on every write so a retried call never creates a duplicate.

## Prerequisites

<ArrowList>
  <ArrowItem>The `@gavai/sdk` package installed. See [Your first integration](/guides/first-integration) if you have not set it up.</ArrowItem>
  <ArrowItem>Basic familiarity with HTTP status codes.</ArrowItem>
</ArrowList>

## Steps

<NumberedSteps>
  <NumberedStep title="Catch typed errors">
    The SDK throws `GavaiError` for every API-level failure. Branch on `e.code`, not on string matching against `e.message`.

    ```ts theme={null}
    import { Gavai, GavaiError } from "@gavai/sdk";

    const gv = new Gavai({ apiKey: process.env.GAVAI_API_KEY });

    try {
      const page = await gv.pages.get({
        workspace: "acme",
        id: "page_01H8X4N5P6Q7R8S9T0U1V2W3X4",
      });
    } catch (e) {
      if (e instanceof GavaiError) {
        console.error("API error:", e.code, e.message);

        if (e.code === "rate_limited") {
          // back off and retry — see step 3
        } else if (e.code === "token_expired") {
          // refresh the token, then retry — see step 2
        } else {
          throw e; // re-throw anything you do not handle
        }
      } else {
        throw e; // not an API error — propagate as-is
      }
    }
    ```

    See [SDK error handling](/sdks/error-handling) for the full code list and the properties on `GavaiError`.
  </NumberedStep>

  <NumberedStep title="Decide what is safe to retry">
    Retrying a `not_found` or `insufficient_scope` wastes time and masks configuration bugs. Use this table.

    <FeatureTable
      columns={["Code", "Retryable?", "Action"]}
      rows={[
["rate_limited", "Yes — back off", "Wait the backoff interval, then retry. See step 3 for the schedule."],
["token_expired", "No — fix first", "Refresh or reissue the token before retrying the original call."],
["insufficient_scope", "No — fix first", "The token lacks the required scope. Update scopes in the Builder, then reissue."],
["not_found", "No", "The resource does not exist. Verify id or slug and fix the call before retrying."],
["5xx (server error)", "Yes — back off", "Transient server error. Same backoff schedule as rate_limited."],
["Network error (no response)", "Yes — back off", "The request never reached gavAI. Safe to retry with backoff and an idempotency key."],
]}
    />

    The rule of thumb: retry anything transient and outside your control (`rate_limited`, server errors, network errors). Do not retry anything that needs you to change something first (`token_expired`, `insufficient_scope`, `not_found`, `invalid_input`).
  </NumberedStep>

  <NumberedStep title="Back off exponentially with jitter">
    When you do retry, space attempts exponentially and add a small random jitter so clients do not all hammer the API at the same moment after a rate limit clears.

    ```ts theme={null}
    async function withRetry<T>(fn: () => Promise<T>, maxAttempts = 3): Promise<T> {
      const delaysMs = [1_000, 5_000, 30_000]; // 1s, 5s, 30s

      for (let attempt = 0; attempt < maxAttempts; attempt++) {
        try {
          return await fn();
        } catch (e) {
          if (!(e instanceof GavaiError)) throw e;

          const retryable = ["rate_limited"].includes(e.code) || e.status >= 500;
          if (!retryable || attempt === maxAttempts - 1) throw e;

          const baseMs = delaysMs[attempt] ?? 30_000;
          const jitterMs = Math.random() * 1_000; // 0–1s jitter
          await new Promise((r) => setTimeout(r, baseMs + jitterMs));
        }
      }
      throw new Error("unreachable");
    }

    const page = await withRetry(() =>
      gv.pages.get({ workspace: "acme", id: "page_01H8X4N5P6Q7R8S9T0U1V2W3X4" })
    );
    ```

    Three attempts at roughly 1 s, 5 s, and 30 s gives about 36 seconds of total retry window — enough for a transient rate limit or a brief server hiccup, short enough that a request does not silently block forever.
  </NumberedStep>

  <NumberedStep title="Attach an idempotency key on every write">
    Before retrying any mutating call, attach an idempotency key. The platform caches the first response for that key for **24 hours**; replays return the original response rather than executing the operation again.

    ```ts theme={null}
    import crypto from "node:crypto";

    // Generate the key ONCE, before the first attempt.
    // Reuse the same key for every retry of the same operation.
    const idempotencyKey = crypto.randomUUID();

    const token = await withRetry(() =>
      gv.workspaces.tokens.create(
        { workspace: "acme" },
        { name: "ci-token", scopes: ["pages:read"] },
        { idempotencyKey }
      )
    );
    ```

    The SDK auto-generates an idempotency key for every `POST`, `PATCH`, `PUT`, and `DELETE` call. Pass `{ idempotencyKey }` explicitly when you implement your own retry envelope so the same key is reused across attempts.

    See [Idempotency](/api-reference/idempotency) for the cache key construction and the `Idempotent-Replayed: true` response header.
  </NumberedStep>

  <NumberedStep title="Never retry payment-class operations without a key">
    Some operations are dangerous to execute twice.

    <Warning>
      Double-creating a payment, a member invitation, or an API key can have serious consequences — a customer charged twice, two conflicting invitations to the same email, or a second API key whose secret is unrecoverable (it was returned only in the duplicate response). **Never retry these operations without an idempotency key.** The key is what makes the retry safe.
    </Warning>

    The three must-attach paths:

    <ArrowList>
      <ArrowItem>`POST /v1/workspaces/{slug}/tokens` — mints a new API key. Each call without a key creates a distinct key.</ArrowItem>
      <ArrowItem>`POST /v1/workspaces/{slug}/members` — creates a new invitation row for each call.</ArrowItem>
      <ArrowItem>Any call to `collectPayment` — opens a new Stripe Checkout session.</ArrowItem>
    </ArrowList>

    Generate the idempotency key before the first attempt and include it on every retry of these calls.

    <Tip>
      Log the request id on every error so issues are traceable to a specific call. The exact header name is not enumerated in the public source (likely `x-request-id` or similar); confirm with the platform team and pass it to your error tracker alongside `e.code` and `e.message`.
    </Tip>
  </NumberedStep>
</NumberedSteps>

## Troubleshooting

<ErrorTable
  errors={[
{
  code: "rate_limited on every retry",
  cause: "Backoff is too short, or every client is retrying at the same moment.",
  fix: "Add jitter (step 3) and increase the base delay between attempts.",
},
{
  code: "token_expired after refresh",
  cause: "Refresh issued a new token but the client instance still holds the old one.",
  fix: "Update the active client with the new key — do not rely on the previous instance.",
},
{
  code: "Duplicate side effects from a retry",
  cause: "The idempotency key was regenerated between attempts.",
  fix: "Generate the key once, outside the retry loop, and pass the same value on every attempt.",
},
{
  code: "Idempotent-Replayed: true on first call",
  cause: "The key was reused from an earlier call with different args.",
  fix: "Generate a fresh key per logical operation.",
},
]}
/>

## What you built

You set up typed error catching with `GavaiError`, learned which codes are safe to retry and which require fixing first, implemented exponential backoff with jitter, and attached idempotency keys to writes so retries never cause duplicate side effects. The integration now survives transient failures without double-executing irreversible operations.

## Next steps

<Columns cols={2}>
  <BrandCard title="Errors reference" href="/api-reference/errors" cta="Read reference">
    Every error code, its HTTP status, and what it means.
  </BrandCard>

  <BrandCard title="Idempotency reference" href="/api-reference/idempotency" cta="Read reference">
    How idempotency keys work, which routes require them, and the 24-hour cache window.
  </BrandCard>

  <BrandCard title="SDK error handling" href="/sdks/error-handling" cta="Read reference">
    GavaiError properties, typed error subclasses, and framework-specific patterns.
  </BrandCard>
</Columns>
