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

# Resilience Patterns

> Build fault-tolerant integrations with retry logic and circuit breakers

Network failures, rate limits, and transient server errors are facts of life in distributed systems. This guide covers patterns for handling them gracefully when integrating with the Signa API.

## Transient vs. Permanent Failures

Before retrying, determine whether the failure is recoverable.

| Status Code | Type      | Action                                                               |
| ----------- | --------- | -------------------------------------------------------------------- |
| `429`       | Transient | Rate limited. Retry after the `Retry-After` header value.            |
| `500`       | Transient | Internal server error. Retry with exponential backoff.               |
| `502`       | Transient | Bad gateway. Retry with backoff (typically resolves within seconds). |
| `503`       | Transient | Service temporarily unavailable. Retry with longer backoff.          |
| `504`       | Transient | Gateway timeout. Retry with backoff.                                 |
| `400`       | Permanent | Validation error. Fix the request payload before resending.          |
| `401`       | Permanent | Invalid or expired API key. Check your credentials.                  |
| `403`       | Permanent | Insufficient scopes. Update your API key permissions.                |
| `404`       | Permanent | Resource not found. Verify the ID is correct.                        |
| `409`       | Permanent | Conflict (e.g., duplicate create). Inspect the error body.           |
| `410`       | Permanent | Entity was merged. Follow the `merged_into` in the response.         |
| `422`       | Permanent | Semantic error. The request is well-formed but cannot be processed.  |

<Note>
  Only retry on transient failures (4xx rate limits and 5xx server errors). Retrying permanent failures wastes your rate limit budget and will never succeed.
</Note>

***

## Exponential Backoff with Jitter

The standard retry strategy for transient errors. Each retry waits longer than the previous one, with random jitter to avoid thundering-herd problems when many clients retry simultaneously.

**Algorithm:**

```
delay = min(max_delay, base_delay * 2^attempt) + random(0, jitter)
```

<CodeGroup>
  ```typescript TypeScript theme={null}
  interface RetryOptions {
    maxRetries?: number;
    baseDelayMs?: number;
    maxDelayMs?: number;
  }

  async function fetchWithRetry(
    url: string,
    options: RequestInit,
    { maxRetries = 3, baseDelayMs = 1000, maxDelayMs = 60_000 }: RetryOptions = {}
  ): Promise<Response> {
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
      const response = await fetch(url, options);

      // Do not retry permanent failures
      if (response.status >= 400 && response.status < 500 && response.status !== 429) {
        return response;
      }

      // Success -- return immediately
      if (response.ok) {
        return response;
      }

      // All retries exhausted
      if (attempt === maxRetries) {
        return response;
      }

      // Calculate delay
      let delayMs: number;

      if (response.status === 429) {
        // Prefer the server's Retry-After value
        const retryAfter = response.headers.get('Retry-After');
        delayMs = retryAfter ? parseInt(retryAfter, 10) * 1000 : baseDelayMs * 2 ** attempt;
      } else {
        delayMs = baseDelayMs * 2 ** attempt;
      }

      // Cap at max delay, add jitter
      delayMs = Math.min(delayMs, maxDelayMs);
      delayMs += Math.random() * baseDelayMs;

      await new Promise((resolve) => setTimeout(resolve, delayMs));
    }

    // Unreachable, but satisfies TypeScript
    throw new Error('Retry loop exited unexpectedly');
  }
  ```

  ```python Python theme={null}
  import random
  import time
  from typing import Optional

  import requests

  def fetch_with_retry(
      method: str,
      url: str,
      max_retries: int = 3,
      base_delay: float = 1.0,
      max_delay: float = 60.0,
      **kwargs,
  ) -> requests.Response:
      for attempt in range(max_retries + 1):
          response = requests.request(method, url, **kwargs)

          # Do not retry permanent failures
          if 400 <= response.status_code < 500 and response.status_code != 429:
              return response

          # Success
          if response.ok:
              return response

          # All retries exhausted
          if attempt == max_retries:
              return response

          # Calculate delay
          if response.status_code == 429:
              retry_after = response.headers.get("Retry-After")
              delay = float(retry_after) if retry_after else base_delay * (2 ** attempt)
          else:
              delay = base_delay * (2 ** attempt)

          # Cap at max delay, add jitter
          delay = min(delay, max_delay)
          delay += random.uniform(0, base_delay)

          time.sleep(delay)

      # Unreachable
      raise RuntimeError("Retry loop exited unexpectedly")
  ```
</CodeGroup>

***

## Retry Backoff Schedule

With the default settings (`base_delay=1s`, `max_retries=3`), the schedule looks like this:

| Attempt | Base Delay | With Jitter (approx.) | Cumulative Wait |
| ------- | ---------- | --------------------- | --------------- |
| 1       | 1 s        | 1.0 -- 2.0 s          | \~1.5 s         |
| 2       | 2 s        | 2.0 -- 3.0 s          | \~4 s           |
| 3       | 4 s        | 4.0 -- 5.0 s          | \~8.5 s         |

For `429` responses, the `Retry-After` header overrides the calculated base delay. Always respect this value.

<Tip>
  The Signa TypeScript SDK (`@signa-so/sdk`) has built-in retry logic with these defaults. If you are using the SDK, you get this behavior automatically.
</Tip>

***

## Bulk Operation Retry

When using the [batch endpoint](/api-reference/trademarks/batch-trademarks), some items in a batch may succeed while others fail. Each item in the response has a `status` field (`success` or `error`) and an optional `error` object, so you can retry only the failed items:

<CodeGroup>
  ```typescript TypeScript theme={null}
  import { Signa } from '@signa-so/sdk';

  const signa = new Signa({ api_key: 'sig_YOUR_KEY' });

  async function fetchBatchWithRetry(ids: string[], maxRetries = 3): Promise<any[]> {
    let pending = ids;
    const results: any[] = [];

    for (let attempt = 0; attempt <= maxRetries && pending.length > 0; attempt++) {
      const response = await signa.trademarks.batch({ ids: pending });

      const retryIds: string[] = [];

      for (const item of response.data) {
        if (item.status === 'success') {
          results.push(item.data);
        } else if (item.error?.type === 'rate_limited' || item.error?.type === 'internal_error') {
          retryIds.push(item.id);
        }
        // Permanent errors are skipped (not retried)
      }

      pending = retryIds;

      if (pending.length > 0 && attempt < maxRetries) {
        const delay = 1000 * 2 ** attempt + Math.random() * 1000;
        await new Promise((resolve) => setTimeout(resolve, delay));
      }
    }

    return results;
  }
  ```

  ```python Python theme={null}
  import time
  import random
  import requests

  def fetch_batch_with_retry(
      api_key: str,
      ids: list[str],
      max_retries: int = 3,
  ) -> list[dict]:
      pending = ids
      results = []

      for attempt in range(max_retries + 1):
          if not pending:
              break

          response = requests.post(
              "https://api.signa.so/v1/trademarks/batch",
              headers={"Authorization": f"Bearer {api_key}"},
              json={"ids": pending},
          )
          body = response.json()

          retry_ids = []
          for item in body["data"]:
              if item["status"] == "success":
                  results.append(item["data"])
              elif item.get("error", {}).get("type") in ("rate_limited", "internal_error"):
                  retry_ids.append(item["id"])

          pending = retry_ids

          if pending and attempt < max_retries:
              delay = (2 ** attempt) + random.uniform(0, 1)
              time.sleep(delay)

      return results
  ```
</CodeGroup>

***

## Circuit Breaker Pattern

For high-throughput integrations, wrap your API calls in a circuit breaker to stop sending requests when the API is consistently failing. This protects both your application and the API from cascading failures.

The circuit has three states:

* **Closed** (normal): Requests flow through. Failures are counted.
* **Open** (tripped): All requests fail immediately without contacting the API.
* **Half-open** (probing): A single test request is sent. If it succeeds, the circuit closes; if it fails, it re-opens.

```typescript theme={null}
class CircuitBreaker {
  private state: 'closed' | 'open' | 'half-open' = 'closed';
  private failureCount = 0;
  private lastFailureTime = 0;

  constructor(
    private readonly failureThreshold: number = 5,
    private readonly resetTimeoutMs: number = 30_000
  ) {}

  async execute<T>(fn: () => Promise<T>): Promise<T> {
    if (this.state === 'open') {
      // Check if enough time has passed to try again
      if (Date.now() - this.lastFailureTime >= this.resetTimeoutMs) {
        this.state = 'half-open';
      } else {
        throw new Error('Circuit breaker is open -- request blocked');
      }
    }

    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }

  private onSuccess(): void {
    this.failureCount = 0;
    this.state = 'closed';
  }

  private onFailure(): void {
    this.failureCount++;
    this.lastFailureTime = Date.now();

    if (this.failureCount >= this.failureThreshold) {
      this.state = 'open';
    }
  }
}

// Usage
const breaker = new CircuitBreaker(5, 30_000);

async function getTrademarkSafe(id: string) {
  return breaker.execute(() =>
    fetchWithRetry(`https://api.signa.so/v1/trademarks/${id}`, {
      headers: { Authorization: 'Bearer sig_YOUR_KEY' },
    })
  );
}
```

<Warning>
  A circuit breaker should wrap your retry logic, not replace it. The retry function handles transient blips; the circuit breaker prevents sustained outages from overwhelming your application.
</Warning>

***

## Request Timeouts

Always set explicit timeouts on API calls. A reasonable default for Signa endpoints:

| Endpoint Type                                         | Recommended Timeout |
| ----------------------------------------------------- | ------------------- |
| Single resource (`GET /v1/trademarks/:id`)            | 10 s                |
| List / search (`GET` or `POST /v1/trademarks`)        | 15 s                |
| Batch (`POST /v1/trademarks/batch`)                   | 30 s                |
| Image similarity (`POST /v1/trademarks/image-search`) | 30 s                |

<CodeGroup>
  ```typescript TypeScript theme={null}
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 10_000);

  try {
    const response = await fetch('https://api.signa.so/v1/trademarks/tm_abc123', {
      headers: { Authorization: 'Bearer sig_YOUR_KEY' },
      signal: controller.signal,
    });
  } finally {
    clearTimeout(timeout);
  }
  ```

  ```python Python theme={null}
  response = requests.get(
      "https://api.signa.so/v1/trademarks/tm_abc123",
      headers={"Authorization": "Bearer sig_YOUR_KEY"},
      timeout=10,
  )
  ```
</CodeGroup>

***

## Idempotent Requests

Mutation endpoints (PATCH, DELETE, and non-exempt POSTs -- see [Exempt Endpoints](#exempt-endpoints)) require an `Idempotency-Key` header. This guarantees that if a request is retried -- due to a network timeout, a dropped connection, or an ambiguous failure -- the operation only executes once.

```bash theme={null}
curl -X PATCH https://api.signa.so/v1/organization/api-keys/key_abc123 \
  -H "Authorization: Bearer $SIGNA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: rename-prod-key-2026-04-14" \
  -d '{"name": "Production key (renamed)"}'
```

### How It Works

1. On the first request with a given key, the API processes the request normally and caches the response.
2. If the same key is sent again with the **same request body**, the API returns the cached response without re-executing the operation. Replays carry an `Idempotent-Replayed: true` response header and preserve the original `request_id` (in the body and on `x-request-id`), so you can tell a cached replay from a fresh execution.
3. If the same key is sent with a **different body**, the API returns `409 conflict` -- each key is bound to a specific request body.
4. If the same key is sent while the **first request is still in flight**, the API returns `409 idempotency_processing` -- wait for the original to complete, then retry with the same key to get its cached result.

Cached responses are stored for **24 hours**, after which the key can be reused.

<Note>
  **Only successful (2xx) responses are cached.** Client errors (4xx) and
  server errors (5xx) are never cached -- if a request fails, you can safely
  retry with the same idempotency key without being pinned to the error.
</Note>

### Key Format

* 1-255 characters
* Alphanumeric, dashes, and underscores only (`[a-zA-Z0-9_-]`)
* Must be unique per operation -- use UUIDs, request identifiers, or a deterministic string derived from the operation (e.g., `create-api-key-{name}-{timestamp}`)

### Exempt Endpoints

Read-shaped POST endpoints are **exempt by design** from the `Idempotency-Key` requirement -- they exist as POSTs only because their query bodies are too large or complex for a URL, and they create nothing. Sending a key is harmless (the value's **format is still validated** -- a malformed key returns `400` even here), but the middleware will not enforce or replay it.

| Endpoint                                         | Why exempt                                            |
| ------------------------------------------------ | ----------------------------------------------------- |
| `POST /v1/trademarks`                            | Read-only search, inherently safe to retry            |
| `POST /v1/trademarks/batch`                      | Read-only batch lookup, inherently safe to retry      |
| `POST /v1/suggest` (and other suggest endpoints) | Read-only, inherently safe to retry                   |
| `POST /v1/watches/preview`                       | Dry-run match count -- creates nothing                |
| `POST /v1/alerts/lookup`                         | Bulk read by IDs (polling pattern) -- creates nothing |

<Warning>
  `POST /v1/organization/api-keys` and `POST /v1/organization/api-keys/{id}/rotate` are **not** exempt, even though they return one-time secrets. Idempotency replay is exactly what you want there: a retry with the same key returns the **same** secret instead of minting a second credential. The cached secret is only ever served to the caller that supplied the original `Idempotency-Key`.
</Warning>

Every other mutating endpoint (`PATCH`, `DELETE`, and any non-exempt `POST`) requires `Idempotency-Key` and will return `400 validation_error` if it is missing.

### Example: Safe Retry Pattern

The example below demonstrates a retry loop against `PATCH /v1/organization/api-keys/{id}` (renaming a key), which **is** enforced by the idempotency middleware.

<CodeGroup>
  ```typescript TypeScript theme={null}
  import { randomUUID } from 'crypto';

  async function renameApiKeySafe(keyId: string, name: string, maxRetries = 2): Promise<any> {
    const idempotencyKey = randomUUID();

    for (let attempt = 0; attempt <= maxRetries; attempt++) {
      try {
        const response = await fetch(`https://api.signa.so/v1/organization/api-keys/${keyId}`, {
          method: 'PATCH',
          headers: {
            'Authorization': `Bearer ${process.env.SIGNA_API_KEY}`,
            'Content-Type': 'application/json',
            'Idempotency-Key': idempotencyKey,
          },
          body: JSON.stringify({ name }),
        });

        if (response.ok) return response.json();
        if (response.status < 500) return response.json(); // permanent error
      } catch {
        if (attempt === maxRetries) throw new Error('Request failed after retries');
      }

      await new Promise(r => setTimeout(r, 1000 * 2 ** attempt));
    }
  }
  ```

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

  def rename_api_key_safe(key_id: str, name: str, max_retries: int = 2) -> dict:
      idempotency_key = str(uuid.uuid4())

      for attempt in range(max_retries + 1):
          try:
              response = requests.patch(
                  f"https://api.signa.so/v1/organization/api-keys/{key_id}",
                  headers={
                      "Authorization": f"Bearer {os.environ['SIGNA_API_KEY']}",
                      "Idempotency-Key": idempotency_key,
                  },
                  json={"name": name},
                  timeout=10,
              )

              if response.ok or response.status_code < 500:
                  return response.json()
          except requests.RequestException:
              if attempt == max_retries:
                  raise

          time.sleep(2 ** attempt)
  ```
</CodeGroup>

<Tip>
  The Signa TypeScript SDK sets the `Idempotency-Key` header automatically on all mutation requests. If you are using the SDK, you get idempotent retries without any extra code.
</Tip>

***

## Decision Tree

Use this to determine the right strategy for any failure:

```
Request failed
  |
  |--> Status 400/401/403/404/410/422?
  |      --> Permanent failure. Do not retry. Log and handle.
  |
  |--> Status 409 (idempotency conflict)?
  |      --> Same key, different body. Use a new Idempotency-Key.
  |
  |--> Status 429?
  |      --> Read Retry-After header.
  |      --> Wait and retry with backoff.
  |
  |--> Status 500/502/503/504?
  |      --> Retry with exponential backoff + jitter.
  |      --> If 3+ consecutive 5xx: trip circuit breaker.
  |
  |--> Network error / timeout?
         --> Retry with backoff.
         --> If persistent: trip circuit breaker.
```
