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

# Handling errors

> Branching patterns and retry rules — how to handle BlooBank errors safely in code.

This page is the **how-to** complement to the [Errors overview](/get-started/errors/overview). It shows the branching pattern in concrete code and the retry rules per error class.

## The branching pattern

The canonical branching pattern in pseudocode:

```text theme={"dark"}
response = http_call(request)

if response.status_code < 400:
  return response.body                            // success

err = response.body.error
switch err.status:
  case "INVALID_ARGUMENT":
    surface_validation_errors(err.details)        // user-facing; do not retry
    return give_up

  case "IDEMPOTENCY_KEY_IN_USE_WITH_DIFFERENT_PARAMS":
    // The key was bound to a different body — pick a new key
    return give_up_or_reconcile

  case "RESOURCE_EXHAUSTED":
    return retry_with_backoff(request, max_attempts=3)

  case "PROVIDER_UNAVAILABLE", "DATABASE_UNAVAILABLE", "UPSTREAM_UNAVAILABLE":
    return retry_with_backoff(request, max_attempts=3)

  case "SIGNATURE_INVALID", "TIMESTAMP_SKEW_EXCEEDED":
    // Fix the root cause (clock, low-S, body bytes) before any retry
    return give_up

  case "REPLAY_DETECTED":
    // Generate a fresh X-Access-Request-Id and re-sign
    return retry_with_fresh_request_id(request, once=true)

  case "WALLET_NOT_FOUND", "PAYMENT_ORDER_NOT_FOUND":
    return give_up                                // not going to appear later

  case "RBAC_DENY":
    log_decision_id(err.details[0].metadata.decisionId)
    return give_up

  case "INTERNAL":
    log_record_id(find_metadata_id(err.details, reason="ERROR_RECORDED"))
    return give_up                                // do not retry blindly

  default:
    return give_up
```

Two rules of thumb:

1. **Never branch on `error.message`** — it is unstable.
2. **Always inspect `error.details[]`** before treating an error as terminal — the specific `reason` often dictates a different recovery path.

## Concrete examples

<CodeGroup>
  ```javascript Node.js theme={"dark"}
  async function createPaymentOrder(client, wallet, body, attempt = 0) {
    try {
      return await client.request('POST', `/wallets/${wallet}/paymentOrders`, body);
    } catch (err) {
      const e = err.body?.error;
      if (!e) throw err;

      switch (e.status) {
        case 'INVALID_ARGUMENT':
          for (const d of e.details) {
            console.error(`Invalid field "${d.metadata?.field}": ${d.description}`);
          }
          throw err;

        case 'IDEMPOTENCY_KEY_IN_USE_WITH_DIFFERENT_PARAMS':
          console.error('Idempotency key already used with different body');
          throw err;

        case 'RESOURCE_EXHAUSTED':
        case 'PROVIDER_UNAVAILABLE':
        case 'DATABASE_UNAVAILABLE':
        case 'UPSTREAM_UNAVAILABLE':
          if (attempt >= 3) throw err;
          await sleep(backoffMs(attempt));
          // KEEP the same idempotencyKey; mint a fresh requestId in the signer
          return createPaymentOrder(client, wallet, body, attempt + 1);

        case 'INTERNAL':
          const recordId = e.details.find(d => d.reason === 'ERROR_RECORDED')?.metadata?.id;
          console.error(`Internal error — reference id ${recordId}`);
          throw err;

        default:
          throw err;
      }
    }
  }

  function backoffMs(attempt) {
    const base = 500, cap = 30_000;
    return Math.random() * Math.min(cap, base * 2 ** attempt);   // full jitter
  }
  const sleep = ms => new Promise(r => setTimeout(r, ms));
  ```

  ```python Python theme={"dark"}
  import time, random
  from requests.exceptions import HTTPError

  def create_payment_order(client, wallet, body, attempt=0):
      try:
          return client.create_payment_order(wallet, body)
      except HTTPError as exc:
          e = exc.response.json().get('error', {})
          status = e.get('status')

          if status == 'INVALID_ARGUMENT':
              for d in e.get('details', []):
                  field = d.get('metadata', {}).get('field')
                  print(f'Invalid field "{field}": {d.get("description")}')
              raise

          if status == 'IDEMPOTENCY_KEY_IN_USE_WITH_DIFFERENT_PARAMS':
              print('Idempotency key already used with different body')
              raise

          if status in {'RESOURCE_EXHAUSTED', 'PROVIDER_UNAVAILABLE',
                        'DATABASE_UNAVAILABLE', 'UPSTREAM_UNAVAILABLE'}:
              if attempt >= 3:
                  raise
              time.sleep(backoff_seconds(attempt))
              return create_payment_order(client, wallet, body, attempt + 1)

          if status == 'INTERNAL':
              record_id = next((d['metadata'].get('id') for d in e.get('details', [])
                                if d.get('reason') == 'ERROR_RECORDED'), None)
              print(f'Internal error — reference id {record_id}')
              raise

          raise

  def backoff_seconds(attempt: int) -> float:
      base, cap = 0.5, 30.0
      return random.uniform(0, min(cap, base * (2 ** attempt)))    # full jitter
  ```
</CodeGroup>

## Retry semantics by status

Apply this table without modification — it is the standard.

| `error.status`                                                         |           Retry?           | How                                                                                                    |
| ---------------------------------------------------------------------- | :------------------------: | ------------------------------------------------------------------------------------------------------ |
| `INVALID_ARGUMENT`                                                     |           **No**           | The request is malformed. Fix it.                                                                      |
| `IDEMPOTENCY_KEY_IN_USE_WITH_DIFFERENT_PARAMS`                         |           **No**           | The key is burned for that wallet. Pick a new key.                                                     |
| `SIGNATURE_INVALID`                                                    |           **No**           | Fix signing (see [Troubleshooting](/get-started/authentication/troubleshooting)).                      |
| `TIMESTAMP_SKEW_EXCEEDED`                                              | **No** (until clock fixed) | Sync NTP, then retry.                                                                                  |
| `REPLAY_DETECTED`                                                      |        **Yes, once**       | Generate a fresh `X-Access-Request-Id`.                                                                |
| `MISSING_HEADER`, `TIMESTAMP_INVALID`                                  |           **No**           | Fix the headers.                                                                                       |
| `CREDENTIAL_DISABLED` / `REVOKED` / `EXPIRED`                          |           **No**           | Contact account team.                                                                                  |
| `RBAC_DENY`                                                            |           **No**           | Request role binding; capture `decisionId`.                                                            |
| `WALLET_NOT_FOUND`, `PAYMENT_ORDER_NOT_FOUND`                          |           **No**           | Not going to appear later.                                                                             |
| `WALLET_ALREADY_EXISTS`                                                |           **No**           | Reconcile with the existing wallet.                                                                    |
| `LABEL_IMMUTABLE`                                                      |           **No**           | Names cannot be changed.                                                                               |
| `PAYMENT_ORDER_NOT_AWAITING_APPROVAL`                                  |           **No**           | Verify state via `GET` before retrying.                                                                |
| `PAYMENT_ORDER_INVALID_STATE`                                          |           **No**           | Verify state via `GET` before retrying.                                                                |
| `RESOURCE_EXHAUSTED`                                                   |    **Yes, with backoff**   | Honor `Retry-After` if present; exponential backoff with jitter otherwise.                             |
| `PROVIDER_UNAVAILABLE`, `DATABASE_UNAVAILABLE`, `UPSTREAM_UNAVAILABLE` |    **Yes, with backoff**   | Transient infrastructure failure.                                                                      |
| `INTERNAL`                                                             |     **No** (by default)    | Log `ERROR_RECORDED` id; escalate. Retry only if you have explicit reason to believe it was transient. |

Detailed retry mechanics in [Retry strategy](/get-started/errors/retry-strategy).

## Idempotency on retry

When you do retry — `RESOURCE_EXHAUSTED`, provider/database/upstream `UNAVAILABLE`, or the one-shot retry after `REPLAY_DETECTED` — keep these stable across attempts:

| Field                      |    Reuse on retry?    | Why                                                  |
| -------------------------- | :-------------------: | ---------------------------------------------------- |
| `idempotencyKey` (in body) |         ✅ Yes         | So a partial success is not duplicated.              |
| Request body bytes         |         ✅ Yes         | Same hash → same signature input.                    |
| `X-Access-Key`             |         ✅ Yes         | Same credential.                                     |
| `X-Access-Timestamp`       | ❌ **No** — read fresh | Stale timestamps fail clock-skew.                    |
| `X-Access-Request-Id`      | ❌ **No** — mint fresh | Stale ids fail `REPLAY_DETECTED`.                    |
| `X-Access-Signature`       |  ❌ **No** — recompute | Timestamp and request-id change → signature changes. |

The signer should be designed so that **only the body bytes feed into the canonical request as content**; everything else is fresh per attempt.

## Logging recommendations

For every non-2xx response, log at minimum:

* HTTP method and URL (without secret query-string values).
* `error.status` and `error.code`.
* Every `details[].reason`.
* `metadata.decisionId` if present.
* `metadata.id` from `ERROR_RECORDED` if present.
* The full `details[]` array as structured JSON (designed to be safe to log).
* Approximate UTC time of the request.
* Your `X-Access-Request-Id` (your correlation handle).

Do **not** log: credentials, the private key, raw signatures, full request bodies containing PII.

## Next

<CardGroup cols={2}>
  <Card title="Retry strategy" icon="rotate-right" href="/get-started/errors/retry-strategy">
    The exact backoff formula and limits.
  </Card>

  <Card title="Error catalog" icon="list" href="/get-started/errors/error-catalog">
    Every reason code with HTTP status and remediation.
  </Card>
</CardGroup>
