# Request Reliability — Integration Guide

> One retry policy for the whole app. Which failures are worth retrying, which
> are not, how long to wait between attempts, when a repeat is **safe** and when
> it silently creates a duplicate, and why the first request after a quiet period
> can take eight seconds. Build this once in your HTTP client and your socket
> setup; do not re-decide it per screen.

- API base URL (local): `http://localhost:3001`
- Auth: `Authorization: Bearer <accessToken>` — see [Auth Integration](../auth/mobile-auth-integration.md)
- This guide is about **transport failures**. Business errors (`429`, `422`
  moderation, `403`) have their own guides and are linked where they come up.

## What's new

<!-- Newest first, plain language, max 5 entries. Every session that changes this
     guide adds one line here and drops the oldest past 5 — see CLAUDE.md rule 8. -->

- **2026-09-16** — New guide. The server runs on **one instance that shuts down when idle**, so the first request after a few quiet minutes waits ~8s for a cold start. Plain HTTP requests survive that (they're queued) — websocket upgrades do **not**, which is why `transports: ["websocket"]` has been removed from the [socket guide](../chat/mobile-chat-socket-guide.md). Everything else here is the retry policy to build once: what's retryable, the backoff numbers, and the list of endpoints that are **not** safe to repeat (`POST /api/media/attach` duplicates media rows; a chat send without `clientMessageId` duplicates the message).

---

## 1 — The short version

If you read nothing else:

| | |
| --- | --- |
| **Retry** | network errors, timeouts, `500`, `502`, `503`, `504` |
| **Don't retry** | `400`, `401`, `403`, `404`, `409`, `422` — the same request will fail the same way |
| **Wait, then retry once** | `429` — wait exactly `retryAfter` seconds ([Rate Limits](../rate-limits/mobile-rate-limits-guide.md)) |
| **Refresh, then retry once** | `401` — silent token refresh, then replay ([Auth](../auth/mobile-auth-integration.md)) |
| **Attempts** | 3 total (1 original + 2 retries) for reads |
| **Backoff** | 1s, 2s, 4s… capped at 10s, each multiplied by a random 0.5–1.5 |
| **Per-attempt timeout** | 20s. Not lower — a cold start legitimately takes ~8s |
| **Never auto-retry** | a `POST` that creates something, unless it takes an idempotency key |

---

## 2 — Why the first request is slow (cold start)

The API runs on Cloud Run **capped at a single instance**, and that instance is
shut down after a few idle minutes. This is deliberate for now — the server keeps
live socket state in process memory, so a second instance would not see the first
one's connections. One instance is the correct shape for this stage of the
product, and the cost is a cold start.

Waking it takes about **8 seconds**: container start, then Node boot, then the
server begins listening.

What that means for you:

- **Plain HTTP requests survive it.** The platform queues the request until the
  server is listening, then delivers it. Your `GET` doesn't fail — it takes 8
  seconds. This is the entire reason the per-attempt timeout below is 20s and
  not 5s.
- **WebSocket upgrades do not survive it.** They are not queued. The upgrade
  comes back `500` after ~6–8 seconds with no meaningful body. Handling is in the
  [socket guide §2.1](../chat/mobile-chat-socket-guide.md) — in short, don't force
  `transports: ["websocket"]`, and let the reconnect backoff run.

### Warm the server on foreground

Cheap and worth doing. When the app comes to the foreground, fire and forget:

```http
GET /health
```

No auth, no body worth reading (`{"status":"ok","timestamp":"…"}`). It is an
ordinary request, so it gets queued through the cold start and wakes the
instance while the user is still looking at your splash or home skeleton. By the
time they tap something, the server is up and the socket connects on the first
try.

Do **not** poll it on a timer. Once per foreground transition is the whole idea.

---

## 3 — Which failures are retryable

Retrying a request that failed for a deterministic reason just burns battery and
delays the error the user needs to see. Branch on the cause, not on "did it
fail".

| Outcome | Retry? | Notes |
| --- | --- | --- |
| Network unreachable, DNS failure, connection reset | ✅ | The classic flaky-mobile case |
| Request timed out (no response) | ⚠️ | Safe for reads. For writes see §5 — you don't know whether the server acted |
| `500` Internal Server Error | ✅ | Usually transient here (a restart mid-request) |
| `502` / `503` / `504` | ✅ | Platform-level; the instance was starting, stopping, or replaced |
| `429` Too Many Requests | ⏱ | Wait `retryAfter` **seconds**, then one retry. Do not back off your own way — the server tells you the number. [Rate Limits](../rate-limits/mobile-rate-limits-guide.md) |
| `401` Unauthorized | 🔑 | Not a transport failure. Refresh the access token, replay the request **once**. If the refresh itself 401s, go to login. [Auth](../auth/mobile-auth-integration.md) |
| `400` Bad Request | ❌ | Your payload is wrong. Retrying sends the same wrong payload |
| `403` Forbidden | ❌ | Permission. Won't change on retry |
| `404` Not Found | ❌ | Also what you get for content you're **not allowed to see** — posts return 404, never 403, so ids can't be probed. Either way, don't retry |
| `409` Conflict | ❌ | State already moved. Refetch instead |
| `422` Unprocessable | ❌ | Validation or the blocked-word filter. [Moderation](../moderation/mobile-content-moderation-guide.md) |

Rule of thumb: **4xx is you, 5xx is us.** Retry us, not you — with `429` and
`401` as the two exceptions that have their own instruction attached.

---

## 4 — Backoff: the numbers

Exponential backoff with jitter. Three parameters, and the jitter is not
optional.

```
delay(attempt) = min(BASE * 2^(attempt-1), MAX) * random(0.5, 1.5)

BASE = 1000ms
MAX  = 10000ms
```

| Attempt | Nominal wait | With jitter | Elapsed (nominal) |
| --- | --- | --- | --- |
| 1 | — | — | 0s |
| 2 | 1s | 0.5–1.5s | 1s |
| 3 | 2s | 1–3s | 3s |
| 4 | 4s | 2–6s | 7s |
| 5 | 8s | 4–12s | 15s |
| 6+ | 10s | 5–15s | +10s each |

**Why `MAX` is 10s and not 5s:** a cold start is ~8s. A 5s ceiling means several
attempts land inside one boot and all fail for the same reason.

**Why jitter matters here specifically:** with one server instance, every client
that was waiting gets released at the same moment and every client that failed
retries at the same moment. The two `500`s that prompted this guide arrived
**14ms apart** from different users. Without jitter your whole userbase retries
in lockstep and hammers an instance that is still booting. `random(0.5, 1.5)`
costs one line and spreads them out.

**How many attempts:**

| Kind of call | Attempts | Reason |
| --- | --- | --- |
| Reads (`GET`) | 3 | Covers a cold start plus one bad moment |
| Idempotent writes (see §5) | 3 | Same — repeating is harmless |
| Non-idempotent writes | **1** | Surface the failure, let the user decide |
| Background/prefetch | 2 | Not worth battery; it'll run again |

Cap total elapsed time too, not just attempt count: **30 seconds** across all
attempts. If a screen can't load in 30s, the user needs an error and a Retry
button, not a fourth silent attempt.

---

## 5 — Idempotency: when a repeat is safe

A retry after a **timeout** is the dangerous case. You never received a
response, so you cannot know whether the server processed the request. Retrying
a create in that state is how users end up with two of something.

### Safe to repeat — retry freely

| Endpoint | Why it's safe |
| --- | --- |
| Every `GET` | No side effects |
| `PUT /api/posts/:id/like` | Carries the **desired state** in the body (`{ liked: true }`), not a toggle. Applying it twice lands in the same place |
| `PUT /api/posts/:id/save` | Same shape, same reason |
| `POST /api/chat/conversations/:id/messages` **with `clientMessageId`** | The server dedupes on it and answers `deduplicated: true` on the replay |
| `POST /api/chat/conversations` for a **DM** | Returns the existing DM rather than making a second one |
| Report endpoints | Idempotent per (reporter, target) |

> This is why toggles are `PUT` with a desired state instead of `POST /toggle`.
> A retried toggle flips twice and lands wrong; a retried `PUT` doesn't. When you
> add an optimistic UI, send the state you want, never "the opposite of what I
> think is there".

### NOT safe to repeat — one attempt, then ask the user

| Endpoint | What a blind retry does |
| --- | --- |
| `POST /api/media/attach` | Inserts a **second `Media` row** pointing at the same uploaded object. The upload isn't duplicated; the attachment is. Two copies on the post |
| `POST /api/chat/.../messages` **without `clientMessageId`** | Second message in the thread. Always send the id — it is the only reason the retry above is safe |
| `POST /api/posts` and other creates | Second post |
| `POST /api/auth/refresh` | Rotates the refresh token again. See the concurrent-rotation note in [Auth](../auth/mobile-auth-integration.md) — if the response has no `refreshToken`, another request already rotated it; keep the one you have |

**Generate `clientMessageId` on the client, before the first attempt**, and reuse
the *same* value for every retry of that send. A fresh UUID per attempt defeats
the whole mechanism — that is the one mistake this section exists to prevent.

For creates with no idempotency key, the recovery is **reconcile, don't retry**:
on timeout, refetch the list and look for what you were creating. If it's there,
the write landed. If it isn't, offer Retry.

### The media upload flow specifically

Presign → `PUT` to the signed URL → attach. Each step retries differently:

1. **Presign** — safe to retry. A wasted presign is a wasted URL, nothing more.
2. **`PUT` to storage** — safe to retry. Same key, same object, overwritten.
3. **Attach** — **not** safe (see above). On timeout, refetch the owner (post,
   message) and check whether the media is on it before trying again.

---

## 6 — Per-attempt timeout

| Setting | Value | Why |
| --- | --- | --- |
| Connect + response timeout | **20s** | A cold start legitimately consumes ~8s of it |
| Socket.IO `timeout` | **20s** | Same reason — [socket guide §2](../chat/mobile-chat-socket-guide.md) |
| Media `PUT` to storage | 60s+ | Large files on mobile uplinks; storage is not behind the cold start |
| Total budget per user action | 30s | Across all attempts, then show an error |

**Do not lower the 20s to "fail fast".** The failures that motivated this guide
came back at 6.2s and 8.3s — already inside a 20s deadline. A 10s timeout would
abort requests that were about to succeed and turn a slow first load into a
broken one.

---

## 7 — What the user sees

Retries are invisible infrastructure right up until they aren't.

- **0–2s** — normal loading state.
- **2–15s** — still "Loading…". This is cold-start territory. Do **not** show an
  error, a failure icon, or a Retry button here; the request is very likely still
  in flight and about to succeed.
- **After ~15s** — you may show "Taking longer than usual".
- **After the budget (30s) or a non-retryable error** — a real error with a
  **Retry** button. Manual retry resets the attempt counter.

Never show a toast per failed attempt. Three attempts must produce **at most
one** message.

While the socket is down the app is not down — chat, history, and sending all
work over HTTP. Don't gate screens on `socket.connected`.

---

## 8 — Drop-in fetch wrapper

One place, wrapping every call. Adapt to your HTTP client; the logic is the
point, not the library.

```typescript
const BASE_MS = 1000;
const MAX_MS = 10_000;
const ATTEMPT_TIMEOUT_MS = 20_000;
const TOTAL_BUDGET_MS = 30_000;

const RETRYABLE_STATUS = new Set([500, 502, 503, 504]);

function backoff(attempt: number): number {
  const nominal = Math.min(BASE_MS * 2 ** (attempt - 1), MAX_MS);
  return nominal * (0.5 + Math.random());   // jitter — see §4
}

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

export async function apiFetch(
  path: string,
  init: RequestInit = {},
  // Callers that create something pass { attempts: 1 } — see §5.
  opts: { attempts?: number } = {},
): Promise<Response> {
  const maxAttempts = opts.attempts ?? 3;
  const deadline = Date.now() + TOTAL_BUDGET_MS;
  let refreshed = false;

  for (let attempt = 1; ; attempt++) {
    const ac = new AbortController();
    const timer = setTimeout(() => ac.abort(), ATTEMPT_TIMEOUT_MS);

    try {
      const res = await fetch(`${API_URL}${path}`, {
        ...init,
        signal: ac.signal,
        headers: {
          "Content-Type": "application/json",
          "X-Client-Type": "mobile",
          "X-Request-Id": crypto.randomUUID(),   // fresh per attempt, for tracing
          Authorization: `Bearer ${await getAccessToken()}`,
          ...init.headers,
        },
      });

      // 401 — refresh once, then replay. Not counted as a retry attempt.
      if (res.status === 401 && !refreshed) {
        refreshed = true;
        await refreshAccessToken();          // throws → caller bounces to login
        continue;
      }

      // 429 — the server tells you how long. Obey it, don't invent a delay.
      if (res.status === 429) {
        const body = await res.clone().json().catch(() => ({}));
        const waitMs = (body.retryAfter ?? 1) * 1000;
        if (attempt >= maxAttempts || Date.now() + waitMs > deadline) return res;
        await sleep(waitMs);
        continue;
      }

      if (!RETRYABLE_STATUS.has(res.status)) return res;   // success or a 4xx you own
      if (attempt >= maxAttempts) return res;

    } catch (err) {
      // Network failure or our own abort. Both are retryable for reads; callers
      // that create something passed attempts: 1 and fall straight through.
      if (attempt >= maxAttempts) throw err;
    } finally {
      clearTimeout(timer);
    }

    const wait = backoff(attempt);
    if (Date.now() + wait > deadline) break;
    await sleep(wait);
  }

  throw new Error(`Request failed after ${maxAttempts} attempts: ${path}`);
}
```

Usage:

```typescript
// Read — default 3 attempts
const res = await apiFetch(`/api/posts/${id}`);

// Create — one attempt; on failure, reconcile or let the user retry (§5)
const res = await apiFetch("/api/posts", { method: "POST", body }, { attempts: 1 });

// Chat send — safe to retry BECAUSE of clientMessageId, generated once
const clientMessageId = crypto.randomUUID();
const res = await apiFetch(
  `/api/chat/conversations/${cid}/messages`,
  { method: "POST", body: JSON.stringify({ content, clientMessageId }) },
);
```

---

## 9 — Anti-patterns

Each of these has a real failure attached to it.

| Don't | Why |
| --- | --- |
| Retry on a fixed 1s interval | Ten attempts inside one 8s cold start, all failing for the same reason |
| Retry without jitter | One instance, every client released at once — see §4 |
| Retry a `4xx` | The same request fails the same way. You've doubled the delay before the user learns something is wrong |
| Lower the timeout to "fail fast" | Aborts requests that were about to succeed (§6) |
| Auto-retry a create | Duplicate posts, duplicate media, duplicate messages (§5) |
| Generate a fresh `clientMessageId` per attempt | Defeats server-side dedupe; the retry becomes a second message |
| Invent your own delay for a `429` | The server sent `retryAfter`. Ignoring it extends the lockout |
| Show an error at 3s | That's a cold start, not a failure (§7) |
| Poll `/health` on a timer | Once per foreground transition is the point; a timer just keeps an instance alive on the user's battery |
| Block the chat screen on `socket.connected` | Chat works over HTTP without the socket |
