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

# Idempotency and retries

> Send an Idempotency-Key with every write, and you can retry any failure without doing anything twice.

Networks fail. A request can time out after we've done the work but before you got the answer. An `Idempotency-Key` makes the retry safe: we recognize the key, and instead of running the request again we return the original response.

## Send a key on every write

`POST /v1/quotes` and `POST /v1/quotes/{id}/hosted-link` accept an `Idempotency-Key` header. It's optional today, and we recommend it on every call. `GET` requests are naturally safe to repeat and ignore it.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://portal.bridgelinepf.com/api/v1/quotes/qte_2lo4LFk5rf8toDFJHSUZ2O/hosted-link \
    -H "Authorization: Bearer $BRIDGELINE_API_KEY" \
    -H "Idempotency-Key: $(uuidgen)"
  ```

  ```javascript Node theme={null}
  const res = await fetch(`https://portal.bridgelinepf.com/api/v1/quotes/${quoteId}/hosted-link`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.BRIDGELINE_API_KEY}`,
      'Idempotency-Key': crypto.randomUUID(),
    },
  })
  const data = await res.json()
  ```

  ```python Python theme={null}
  import os
  import uuid
  import requests

  res = requests.post(
      f"https://portal.bridgelinepf.com/api/v1/quotes/{quote_id}/hosted-link",
      headers={
          "Authorization": f"Bearer {os.environ['BRIDGELINE_API_KEY']}",
          "Idempotency-Key": str(uuid.uuid4()),
      },
      timeout=30,
  )
  data = res.json()
  ```
</CodeGroup>

* **One key per operation.** Generate a new key (a UUID v4 is ideal) when you decide to do something, and reuse that same key for every retry of it.
* **Format:** 1 to 255 printable ASCII characters. Anything else is [`400 IDEMPOTENCY_KEY_INVALID`](/errors/IDEMPOTENCY_KEY_INVALID).
* **Scope:** keys belong to the API key that sent them, so two integrations can't collide.
* **Lifetime:** 24 hours. After that the key is forgotten and can be used again.

## What a retry gets back

| The retry | What happens |
| - | - |
| Same key, same request, original finished | The original status, body and headers, plus `Idempotent-Replayed: true`. Nothing runs again. A replayed quote carries the same `hosted_url` |
| Same key, original still running | [`409 IDEMPOTENCY_REQUEST_IN_PROGRESS`](/errors/IDEMPOTENCY_REQUEST_IN_PROGRESS) with `Retry-After: 1`. Wait and retry with the same key |
| Same key, different request | [`409 IDEMPOTENCY_KEY_REUSED`](/errors/IDEMPOTENCY_KEY_REUSED). Use a new key for a new request |
| Same key, original's response was lost | [`500 IDEMPOTENCY_RECORD_LOST`](/errors/IDEMPOTENCY_RECORD_LOST). This key will never succeed; see below |

"Same request" means the same method, path, query string and **body bytes**, sent by the same API key acting as the same broker. Serialize the body once and resend those exact bytes; re-serializing can reorder keys or change spacing, and that counts as a different request.

A request we reject before doing any work (a malformed body, a `422`, a `429`) doesn't use up its key. Fix the problem and send it again with the same key.

## When the response was lost

Very rarely, a request finishes its work but we fail to record the response. The key is then stuck: we can't replay an answer we don't have, and we won't run the request again in case it already took effect. A retry with that key, once the original has been gone for two minutes, gets `500 IDEMPOTENCY_RECORD_LOST`. It has no `Retry-After`, because retrying the same key won't help.

What to do depends on the endpoint:

* **`POST /v1/quotes`:** the quote may or may not exist, and there's no way to look it up yet. Create it again with a new key. An extra quote is harmless: nothing is sent to anyone, and it expires on its own. It does count toward your daily quote limit.
* **`POST /v1/quotes/{id}/hosted-link`:** the link may or may not have been replaced. Call it again with a new key; that revokes whichever link is current and gives you a fresh one.

## Retry rules

| Response | Retry? |
| - | - |
| Network error or timeout | Yes, same key, with backoff |
| `409 IDEMPOTENCY_REQUEST_IN_PROGRESS` | Yes, same key, after `Retry-After` |
| `429 RATE_LIMITED` | Yes, same key, after `Retry-After` |
| `503 SERVICE_UNAVAILABLE` | Yes, same key, after `Retry-After` |
| `500 INTERNAL` | Yes, same key, with backoff. If the `500` comes back with `Idempotent-Replayed: true`, it's the stored outcome: stop, and treat it like a lost response |
| `500 IDEMPOTENCY_RECORD_LOST` | Not with this key. See above |
| Any other `4xx` | No. Fix the request first |

A sketch of a retrying client:

<CodeGroup>
  ```javascript Node theme={null}
  const RETRYABLE = new Set(['IDEMPOTENCY_REQUEST_IN_PROGRESS', 'RATE_LIMITED', 'SERVICE_UNAVAILABLE', 'INTERNAL'])

  async function postWithRetries(url, body, { attempts = 5 } = {}) {
    const idempotencyKey = crypto.randomUUID() // one key for every attempt
    const payload = JSON.stringify(body)       // the same bytes on every attempt

    for (let attempt = 1; ; attempt++) {
      let res
      try {
        res = await fetch(url, {
          method: 'POST',
          headers: {
            Authorization: `Bearer ${process.env.BRIDGELINE_API_KEY}`,
            'Content-Type': 'application/json',
            'Idempotency-Key': idempotencyKey,
          },
          body: payload,
        })
      } catch (networkError) {
        if (attempt >= attempts) throw networkError
        await sleep(backoff(attempt))
        continue
      }

      if (res.ok) return res.json()

      const { error } = await res.json()
      const replayed = res.headers.get('Idempotent-Replayed') === 'true'
      if (!RETRYABLE.has(error.code) || replayed || attempt >= attempts) {
        throw Object.assign(new Error(error.message), { code: error.code, requestId: error.request_id })
      }
      const retryAfter = Number(res.headers.get('Retry-After'))
      await sleep(retryAfter > 0 ? retryAfter * 1000 : backoff(attempt))
    }
  }

  const backoff = (attempt) => Math.min(30_000, 500 * 2 ** attempt) * (0.5 + Math.random() / 2)
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
  ```

  ```python Python theme={null}
  import json
  import os
  import random
  import time
  import uuid

  import requests

  RETRYABLE = {"IDEMPOTENCY_REQUEST_IN_PROGRESS", "RATE_LIMITED", "SERVICE_UNAVAILABLE", "INTERNAL"}


  def post_with_retries(url, body, attempts=5):
      idempotency_key = str(uuid.uuid4())  # one key for every attempt
      payload = json.dumps(body)            # the same bytes on every attempt
      headers = {
          "Authorization": f"Bearer {os.environ['BRIDGELINE_API_KEY']}",
          "Content-Type": "application/json",
          "Idempotency-Key": idempotency_key,
      }

      for attempt in range(1, attempts + 1):
          try:
              res = requests.post(url, data=payload, headers=headers, timeout=30)
          except requests.RequestException:
              if attempt >= attempts:
                  raise
              time.sleep(backoff(attempt))
              continue

          if res.ok:
              return res.json()

          error = res.json()["error"]
          replayed = res.headers.get("Idempotent-Replayed") == "true"
          if error["code"] not in RETRYABLE or replayed or attempt >= attempts:
              raise RuntimeError(f"{error['code']}: {error['message']} ({error['request_id']})")
          retry_after = int(res.headers.get("Retry-After") or 0)
          time.sleep(retry_after if retry_after > 0 else backoff(attempt))


  def backoff(attempt):
      return min(30, 0.5 * 2 ** attempt) * random.uniform(0.5, 1.0)
  ```
</CodeGroup>

## Without a key

A write sent without a key is never deduplicated. Retrying a timed-out `POST /v1/quotes` without one can create a second quote, and retrying `POST /v1/quotes/{id}/hosted-link` replaces the link again, so the first new link you might have stored stops working.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.