# Retries and idempotency

> Which requests are safe to repeat, how to recover from timeouts and server errors, and how to back off without wasting your rate limit.

A request can fail in two different ways. Either the API answers with an error, and you know exactly what happened, or the connection breaks and you don't know whether the API did the work. This page covers every write operation: what happens when you send the same request again, and how to recover from both kinds of failure. The goal is to avoid duplicate students or applications and to avoid burning through your [rate limit](https://hub.main-team.org/api/rate-limits).

## The short version

| What came back | Did this request change anything? | What to do |
|---|---|---|
| `2xx` | Yes, if it was a write | Nothing more. Store the result. |
| `400`, `413`, `415` | No | Fix the request. Sending it again unchanged gets the same answer. |
| `401` | No | Sign a fresh token and send the request once more. If that also fails, stop and check your token. |
| `403` | No | Don't retry. A permission or an access rule refused it. |
| `404` | No | Don't retry. After a delete that timed out, see [Deleting an application](#deleting-an-application). |
| `409` | No | Don't retry unchanged. The message names the state that blocks the request; the group challenge submits also say it in `error.details.reason`. |
| `429` | No | Wait the number of seconds in `Retry-After`, then send it again. |
| `500` | Maybe | Retry with backoff if the operation is safe to repeat (next section). Otherwise check first. |
| `502`, `503`, `504`, a timeout, or a dropped connection | Maybe | Same as `500`. |

Every `4xx` is a refusal. The API checks a request completely before it writes anything, so a `4xx` never leaves a half-done change behind. You only need a "did it happen?" check after a `5xx` or when no response arrived.

A `429`, a `401`, and a `403` for a missing permission are all refused before the operation starts. That makes them safe to resend for any operation, including ones that are not idempotent, once you have fixed the cause or waited.

## Which operations are safe to repeat

Every `GET` has no side effects, so you can repeat it freely. This includes the lists, the single reads, the exam picker and the two PDF downloads. The table covers the writes.

| Operation | What a repeat does | Safe to retry after a timeout? |
|---|---|---|
| [Register a student](https://hub.main-team.org/api/reference/register-student) `POST /v1/student` | **Not idempotent.** If the first attempt worked, the repeat answers `409 conflict` with the new student's `_id` in the message. | Yes, if you handle that `409`. See [Registering a student](#registering-a-student). |
| [Register many students at once](https://hub.main-team.org/api/reference/create-student-import) `POST /v1/student/import` | **Idempotent for 24 hours.** The same rows from your account inside a day answer `202` with the import you already have, not a second one, and the `message` says so. | Yes. See [Registering many students at once](#registering-many-students-at-once). |
| [Update a student](https://hub.main-team.org/api/reference/update-student) `PUT /v1/student/{studentId}` | Same body, same result. | Yes |
| [Update an organization student](https://hub.main-team.org/api/reference/update-org-student) `PUT /v1/{organizationId}/student/{studentId}` | Same body, same result. The organization is added to the student's access list only once. | Yes |
| [Set a password](https://hub.main-team.org/api/reference/set-student-password) `PUT /v1/student/{studentId}/password` | Sets the same password again. Answers `409` if the student confirmed their email address in between. | Yes |
| [Link a supervisor](https://hub.main-team.org/api/reference/link-student-supervisor) `PUT /v1/{organizationId}/student/{studentId}/supervisor` | Links the same supervisor again and answers `200` each time. | Yes |
| [Create a sign-in link](https://hub.main-team.org/api/reference/create-signin-link) `POST /v1/{organizationId}/auth/signin` | Mints a new, separate link. The earlier one expires unused. | Yes, retry the request. **Never** reopen or resend the link itself. |
| [Create an application](https://hub.main-team.org/api/reference/create-application) `POST /v1/{organizationId}/application` | **Idempotent** for the same student and exam: `201` the first time, then `200 "Application already exists."` with the same application. | Yes, one attempt at a time |
| [Move an application](https://hub.main-team.org/api/reference/move-application) `PUT /v1/{organizationId}/application/{applicationId}` | **Idempotent.** Moving to the exam the application already has answers `200 "Application already uses that exam."` and writes nothing. | Yes |
| [Delete an application](https://hub.main-team.org/api/reference/delete-application) `DELETE /v1/{organizationId}/application/{applicationId}` | The first call deletes. A repeat answers `404 "Application not found!"`. | Yes. Treat that `404` as "already deleted" if you know the application existed. |
| [Submit a group challenge step](https://hub.main-team.org/api/reference/submit-group-challenge-step) `POST /v1/{organizationId}/group-challenge/{challengeId}/group/{groupId}/step/{stepId}/submit` | **Idempotent.** A step already submitted, by anyone, answers `200` with `changed: false` and writes nothing. | Yes. See [Group challenge submits](#group-challenge-submits). |
| [Send a group's work](https://hub.main-team.org/api/reference/submit-group-challenge-work) `POST /v1/{organizationId}/group-challenge/{challengeId}/group/{groupId}/final-submit` | **Idempotent.** Work already sent answers `200` with `changed: false`; nothing is written and no second e-mail goes out. | Yes. See [Group challenge submits](#group-challenge-submits). |
| [Revoke a token](https://hub.main-team.org/api/reference/revoke-token) `POST /v1/api-account/revoke-token` | The first call revokes the token. A repeat with the same token answers `401`, because that token is now revoked. | Yes. A `401` on the repeat means the first call worked. |

## Registering many students at once

`POST /v1/student/import` is the one write on this API that is safe to repeat exactly as it is.
Send the same rows again inside 24 hours and you get `202` with the import you already have —
`data._id` is the id you already had, and the `message` says the batch was already sent.

So a request whose answer you never saw needs no special handling at all:

```js
const send = () =>
  fetch('https://api.main-team.org/v1/student/import', {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ students: rows }),
  });

let answer = await send();
if (answer.status >= 500 || answer.status === 408) answer = await send();
```

"The same rows" means the rows themselves: addresses are compared without regard to case or
surrounding spaces, and `clientReference` is ignored. Change one row and it is a new batch, which
is refused with `409` while the first one is still running.

The batch itself is all-or-nothing — every student is registered or none is — so there is never a
half-finished import to reconcile after a retry. See [Bulk
registration](https://hub.main-team.org/api/guides/bulk-registration).

## Creating an application

`POST /v1/{organizationId}/application` is idempotent. Applying the same student to the same exam twice is one request sent twice, so the API answers the repeat with the application that already exists instead of creating a second one.

```bash
curl -X POST "https://api.main-team.org/v1/$ORGANIZATION_ID/application" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Request-Id: 1f0c2d9e-5b7a-4c3e-8f21-6a9d0b4e7c35.1" \
  -d '{"studentId":"652f1c9b8e4b2a0012a3c4d5","examId":"64a1f0b2c9d8e7f600112233"}'
```

The first time, the application is created and you get `201 Created`:

```json
{
  "success": true,
  "message": "Application created successfully.",
  "data": {
    "_id": "66c3e5f7a9b1c2d3e4f50617",
    "exam": "64a1f0b2c9d8e7f600112233",
    "user": "66a0b1c2d3e4f5a6b7c8d9e0",
    "payment": "66c3e5f7a9b1c2d3e4f50618",
    "ExamBucksRedeemed": false,
    "partners": [],
    "definedQuota": false,
    "participated": false,
    "reversable": false,
    "simulationStarted": false,
    "simulationSubmitted": false,
    "simulationV2Enabled": false,
    "isSimulationV2": false,
    "carriedPoints": 0,
    "uuid": "3fa-9c1-e07",
    "cameraRecord": false,
    "createdAt": "2026-09-15T09:30:02.511Z",
    "updatedAt": "2026-09-15T09:30:02.511Z",
    "__v": 0
  }
}
```

Send the same body again and you get `200 OK`. `data` is the same application, with the same `_id`:

```json
{
  "success": true,
  "message": "Application already exists.",
  "data": {
    "_id": "66c3e5f7a9b1c2d3e4f50617",
    "exam": "64a1f0b2c9d8e7f600112233",
    "user": "66a0b1c2d3e4f5a6b7c8d9e0",
    "payment": "66c3e5f7a9b1c2d3e4f50618",
    "ExamBucksRedeemed": false,
    "partners": [],
    "definedQuota": false,
    "participated": false,
    "reversable": false,
    "simulationStarted": false,
    "simulationSubmitted": false,
    "simulationV2Enabled": false,
    "isSimulationV2": false,
    "carriedPoints": 0,
    "uuid": "3fa-9c1-e07",
    "cameraRecord": false,
    "createdAt": "2026-09-15T09:30:02.511Z",
    "updatedAt": "2026-09-15T09:30:02.511Z",
    "__v": 0
  }
}
```

Branch on the status: `201` means this request created the application, `200` means it already existed. Both give you the application to store. (`user` here is the organization's own id for the student. See [Identifiers](https://hub.main-team.org/api/identifiers).)

The API runs its checks in this order. The first one that fails decides the answer:

1. The student belongs to your account (otherwise `404 not_found`).
2. The student has signed in to this organization at least once (otherwise `409 conflict`).
3. The exam exists in this organization (otherwise `404 not_found`).
4. The exam is one the picker would offer this student: open, their grade, their country, a language set (otherwise `409 conflict`, or `400 bad_request` when the student has no grade).
5. **The application already exists: `200 OK`.**
6. The student holds no other exam in the same category on the same sitting (otherwise `409 conflict`).
7. The application is created: `201 Created`.

The repeat is recognized at step 5, after the eligibility checks. If the exam has closed since your first attempt, or the student's grade or country has changed, a repeat answers `409 conflict` even though the application exists. When a retry answers `409`, list the student's applications with [`GET /v1/{organizationId}/application/student-applications/{studentId}`](https://hub.main-team.org/api/reference/list-student-applications) before you conclude that the first attempt failed.

Send at most one create for a given student and exam at a time. The API recognizes a repeat only once the earlier request has finished. Two identical requests in flight at the same moment, from two workers say, are not guaranteed to be treated as one. Retry after the previous attempt has completed or timed out, never in parallel with it.

```js
// Apply once, surviving timeouts. callApi is the helper at the end of this page.
export async function applyOnce(organizationId, studentId, examId) {
  const res = await callApi('POST', `/${organizationId}/application`, {
    body: { studentId, examId },
    repeatable: true,
  });
  const body = await res.json();

  if (res.status === 201) return { created: true, application: body.data };
  if (res.status === 200) return { created: false, application: body.data };

  if (res.status === 409) {
    // The exam may have closed after an earlier attempt succeeded. Look before giving up.
    const existing = await findApplication(organizationId, studentId, examId);
    if (existing) return { created: false, application: existing };
  }
  throw new Error(`${res.status} ${body.error.code}: ${body.error.message}`);
}
```

## Registering a student

`POST /v1/student` is **not** idempotent, on purpose. Registering an email address that is already registered is not a repeat, it is two students colliding on one address. So the API refuses it with `409 conflict` rather than returning the existing record as though it had just been created.

That refusal is what you use to recover from a timeout. Send the registration again, and read the answer:

| Answer to the second attempt | What it means | What to do |
|---|---|---|
| `201 Created` | The first attempt never landed. This one created the student. | Store `data._id`. |
| `409` with `A student with that email is already registered to this account (652f1c9b8e4b2a0012a3c4d5).` | The first attempt landed, or you had already registered this address. The student is yours either way. | Use the id in the message. Fetch the record with [`GET /v1/student/{studentId}`](https://hub.main-team.org/api/reference/get-student) if you need it. |
| `409` with `That email address is already registered.` | Another account registered this address. Email addresses are unique across the whole platform. | You cannot use this address. Ask the student for a different one. |

The existing student's `_id` appears only in the message, so this is the one place you read it from there. Take the 24-character hex id from between the parentheses:

```js
// Register, or recover the id of a registration that already happened.
export async function registerOrRecover(student) {
  let res;
  try {
    res = await callApi('POST', '/student', { body: student, repeatable: false });
  } catch {
    // No answer: the student may exist now. Asking again is safe because of the 409.
    res = await callApi('POST', '/student', { body: student, repeatable: false });
  }
  const body = await res.json();

  if (res.status === 201) return body.data._id;
  if (res.status === 409) {
    const yours = /\(([0-9a-f]{24})\)\.?$/.exec(body.error.message);
    if (yours) return yours[1];
    throw new Error('This email address belongs to another account.');
  }
  throw new Error(`${res.status} ${body.error.code}: ${body.error.message}`);
}
```

```php
<?php
// Register, or recover the id of a registration that already happened.
function registerOrRecover(array $student): string
{
    try {
        $res = callApi('POST', '/student', $student, false);
    } catch (RuntimeException $noAnswer) {
        $res = callApi('POST', '/student', $student, false);
    }

    if ($res['status'] === 201) {
        return $res['body']['data']['_id'];
    }
    if ($res['status'] === 409) {
        if (preg_match('/\(([0-9a-f]{24})\)\.?$/', $res['body']['error']['message'], $m)) {
            return $m[1];
        }
        throw new RuntimeException('This email address belongs to another account.');
    }
    throw new RuntimeException($res['status'] . ' ' . $res['body']['error']['code']);
}
```

A few more things matter for registration:

- **Keep your own mapping from email address to student `_id`.** No operation looks a student up by email, so the id you store at registration is how you find them again.
- **Register one address from one worker at a time.** If two of your workers register the same new address at the same moment, one of them gets the `409` *without* an id, as though another account held it.
- **Allow time for a password.** When the body carries a `password`, the API hashes it with a deliberately slow algorithm, so the request takes noticeably longer (often more than a second). A 30-second read timeout is comfortable.

## Updating a student

Both student updates are partial: send only the fields you are changing, and fields you leave out keep their value. The same body sent twice gives the same result, so both are safe to retry.

- **Changing the email address withdraws the student's email confirmation**, because nobody has proved the new address yet. A repeat carrying the same new address is not a change, so it doesn't withdraw anything a second time. Re-sending the address a student already has, in any letter case, never withdraws it.
- **`PUT /v1/{organizationId}/student/{studentId}`** also adds that organization to the student's access list. A repeat doesn't add it twice.
- **`PUT /v1/student/{studentId}/password`** sets the same password again on a repeat. If the student confirms their email address between your attempts, the repeat answers `409 conflict`. Stop there: the account is the student's now. Like registration, this request takes longer than others because of the password hashing.
- **`PUT /v1/{organizationId}/student/{studentId}/supervisor`** links the same supervisor again and answers `200` with the same result.

## Moving an application

`PUT /v1/{organizationId}/application/{applicationId}` moves an application to another exam. If the application already points at the exam you send, the API answers without writing anything:

```json
{
  "success": true,
  "message": "Application already uses that exam.",
  "data": {
    "_id": "66c3e5f7a9b1c2d3e4f50617",
    "exam": "64a1f0b2c9d8e7f600445566",
    "user": "66a0b1c2d3e4f5a6b7c8d9e0",
    "payment": "66c3e5f7a9b1c2d3e4f50618",
    "ExamBucksRedeemed": false,
    "partners": [],
    "definedQuota": false,
    "participated": false,
    "reversable": false,
    "simulationStarted": false,
    "simulationSubmitted": false,
    "simulationV2Enabled": false,
    "isSimulationV2": false,
    "carriedPoints": 0,
    "uuid": "3fa-9c1-e07",
    "cameraRecord": false,
    "createdAt": "2026-09-15T09:30:02.511Z",
    "updatedAt": "2026-09-15T09:41:17.020Z",
    "__v": 0
  }
}
```

So after a move that timed out, send it again. `200 "Application updated successfully."` means the first attempt didn't land and this one did. `200 "Application already uses that exam."` means the first one did. Either way the application now has the exam you asked for.

This check comes before the eligibility rules, so a repeat still answers `200` if the exam has closed since your first attempt. Only one earlier check can change the answer: once the student has started or handed in the exam, every move of that application answers `409 conflict` with `This exam has already been started and can no longer be changed.`, even a move to the exam it already has. The rules a move has to pass are in [Applications](https://hub.main-team.org/api/guides/applications).

## Deleting an application

The first `DELETE` removes the application and answers `200` with the deleted record:

```json
{
  "success": true,
  "message": "Application deleted successfully.",
  "data": {
    "_id": "66c3e5f7a9b1c2d3e4f50617",
    "exam": "64a1f0b2c9d8e7f600112233",
    "user": "66a0b1c2d3e4f5a6b7c8d9e0",
    "payment": "66c3e5f7a9b1c2d3e4f50618",
    "ExamBucksRedeemed": false,
    "partners": [],
    "definedQuota": false,
    "participated": false,
    "reversable": false,
    "simulationStarted": false,
    "simulationSubmitted": false,
    "simulationV2Enabled": false,
    "isSimulationV2": false,
    "carriedPoints": 0,
    "uuid": "3fa-9c1-e07",
    "cameraRecord": false,
    "createdAt": "2026-09-15T09:30:02.511Z",
    "updatedAt": "2026-09-15T09:30:02.511Z",
    "__v": 0
  }
}
```

A repeat answers `404 not_found` with `Application not found!`. If you got the id from the API and the delete timed out, a `404` on the retry means the application is gone, most likely because your first attempt worked.

A `409 conflict` on a delete means the application has been paid for. The API does not delete paid applications, because that would need a refund it cannot make. Retrying won't change that.

## Group challenge submits

[`submitGroupChallengeStep`](https://hub.main-team.org/api/reference/submit-group-challenge-step) and [`submitGroupChallengeWork`](https://hub.main-team.org/api/reference/submit-group-challenge-work) are safe to send again. A submit that already happened — your earlier attempt, a member in the panel, another request — answers `200` with `changed: false`, so after a timeout, send it again and read `changed`:

| Answer to the second attempt | What it means |
|---|---|
| `200`, `changed: true` | The first attempt never landed; this one did the submit. |
| `200`, `changed: false` | It was already done, by your first attempt or by someone else. Nothing changed. |
| `503`, `details.reason: busy` | Someone was changing the group at that moment; nothing was written. Wait the second in `Retry-After` and send it again. |
| `409` | The group's state refuses it; `details.reason` says why. Don't retry unchanged. See [conflict](https://hub.main-team.org/api/errors#conflict). |

## Sign-in links: retry the request, never the link

`POST /v1/{organizationId}/auth/signin` mints a new link every time you call it. Retrying the request is harmless: any link you didn't use expires on its own after 120 seconds.

The link itself is different. It works **once**, for **120 seconds**. Once a browser has opened it, opening it again fails. The student sees an "Invalid or expired access token" message instead of their panel. So:

- Never retry a redirect with the same URL. Mint a new link.
- Mint the link when the student clicks, and redirect at once. Don't generate links ahead of time.
- Keep links away from anything that fetches URLs on its own, such as chat previews, email scanners and browser prefetch. A fetch like that uses the link up.

If minting fails with `500 internal_error` and the message `Could not issue a sign-in token, please retry.`, retry straight away. Nothing was issued. See [Sign-in links](https://hub.main-team.org/api/guides/sign-in-links) for the whole flow.

## Downloads

`GET .../certificate/download/{certificateId}` and `GET .../report/download/{reportId}` stream a PDF.

- **An error arrives before any bytes**, as the usual JSON error envelope with its status. Handle it like any other error.
- **A failure after the file has started** can't be reported with a status any more, so the API closes the connection early. Your client sees a transfer that ends before `Content-Length` bytes (when the header is present) or with a network error. Throw the partial file away and download it again.
- **Every download starts from the first byte.** The API always sends the whole file and doesn't serve byte ranges, so you can't resume a partial download.

Write to a temporary file and rename it only once the transfer has completed, so a truncated PDF never looks like a finished one.

## Timeouts

| Setting | Suggested value | Why |
|---|---|---|
| Connect timeout | 5 s | Fail fast when the network is the problem. |
| Read timeout, JSON operations | 30 s | Covers the slowest ordinary request, a registration or password change with hashing. |
| Read timeout, downloads | 120 s or more, depending on file size | PDFs are streamed. |

The API expects to receive your whole request, headers and body, within 30 seconds of you starting to send it. Don't stream request bodies slowly. Reuse connections (HTTP keep-alive) rather than opening a new one for each request.

A timeout is not a failure. It only means you didn't see the answer. Treat it like a `500`: retry if the operation is safe to repeat, and check first if it isn't.

## Backing off

Wait longer after each failed attempt, and add randomness so that many clients (or many workers of yours) don't retry in lockstep:

```
delay = random(0.5, 1.0) × min(30 s, 1 s × 2^attempt)
```

| Attempt that failed | Wait before the next one |
|---|---|
| 1st | 0.5–1 s |
| 2nd | 1–2 s |
| 3rd | 2–4 s |
| 4th | 4–8 s |
| 5th | give up and alert |

Three rules sit on top of that:

- **`Retry-After` wins.** On a `429`, wait at least the number of seconds the header gives. A request sent sooner is refused too.
- **Cap the attempts.** Five in total is plenty. After that, stop and alert a person. Retrying forever turns an outage into a flood.
- **Pause a whole batch when many requests fail.** If several requests in a row come back `5xx`, stop the batch. Then check the API in two steps before you resume.

First, [`GET /v1/health`](https://hub.main-team.org/api/reference/get-health) tells you whether the API is reachable at all. It needs no token and doesn't count against your rate limit:

```bash
curl https://api.main-team.org/v1/health
```

```json
{
  "success": true,
  "message": "Request completed successfully.",
  "data": { "status": "ok" }
}
```

A `200` here only means the API is up and answering. It doesn't check the systems your operations need behind it, so it can answer `200` while your operations are still failing. Second, once it answers, send one cheap read that your account is allowed, such as `GET /v1/grade?limit=1` (needs `grade/read` on `mto`). Resume the batch when that read succeeds.

## Retries and your rate limit

Each API account may make 100 requests per 60 seconds to each operation. One operation's budget is shared by all its calls, whatever ids are in the path and whichever organization they name. Two operations never share a budget.

A retry is a request like any other, so every attempt that reaches an operation counts. A few things work in your favor:

- A request refused for authentication (`401`), for a missing permission (`403`) or for an unknown organization (`404 Organization not found!`) is not counted.
- After a `429`, every request to that operation is refused until the `Retry-After` time has passed. The first `429` says `Retry-After: 60`, because the block lasts a full 60 seconds from the request that went over the limit. Refusals during the block are not counted and don't extend it, but they don't achieve anything either.
- The `429` response carries `Retry-After` only. The `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers come on the responses that were counted, so read them there to slow down *before* you reach the limit.

The network in front of the API also limits how fast each client address may send requests, whether or not they carry a token. A request refused there gets a `429` without the API's JSON error envelope. Wait about 10 seconds before you send again, and spread steady high volume over time instead of sending it in bursts.

See [Rate limits](https://hub.main-team.org/api/rate-limits) for client-side throttling patterns.

## Request ids

Every response carries an `X-Request-Id` header, and every error body repeats it as `error.request_id`. Quote it when you ask for help: it is how a request is found.

You can send your own. The API reuses it if it is 1 to 256 characters of letters, digits and `. _ : ; = + / @ -`. Otherwise the API assigns an id of its own. Don't depend on the format of an id you didn't send. A useful pattern for retries is one id per logical operation plus the attempt number, such as `1f0c2d9e-5b7a-4c3e-8f21-6a9d0b4e7c35.1`, `.2`, `.3`. Each attempt stays unique, and all attempts of one operation share a prefix you can search for.

The request id is **not** an idempotency key. The API does not deduplicate requests by it. Two `POST`s with the same `X-Request-Id` are two requests. Idempotency comes from the operation itself, as described above.

Never put personal data such as an email address or a name into a request id. It is repeated in error bodies and written to logs.

## A complete retry helper

This helper implements everything on this page: backoff with jitter, `Retry-After`, a fresh token after a `401`, per-attempt request ids, and no blind retries of operations that aren't safe to repeat.

```js
// api.mjs: Node.js 18+ (global fetch and AbortSignal.timeout)
import { randomUUID } from 'node:crypto';
import { getToken } from './token.mjs'; // getToken({ forceNew }) returns a signed JWT

const BASE_URL = 'https://api.main-team.org/v1';
const RETRY_ON = new Set([500, 502, 503, 504]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

function delayMs(attempt, retryAfter) {
  const seconds = Number(retryAfter);
  if (retryAfter != null && Number.isFinite(seconds)) {
    return seconds * 1000 + Math.random() * 1000; // never sooner than asked
  }
  const ceiling = Math.min(30_000, 1_000 * 2 ** attempt);
  return ceiling / 2 + (Math.random() * ceiling) / 2;
}

/**
 * repeatable: true when sending the request twice is safe (see the table above).
 * Returns the Response for 2xx and 4xx; throws when attempts run out.
 */
export async function callApi(method, path, { body, repeatable, maxAttempts = 5, timeoutMs = 30_000 } = {}) {
  const operation = randomUUID();
  let forceNewToken = false;
  let lastProblem;

  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    let res;
    try {
      res = await fetch(BASE_URL + path, {
        method,
        headers: {
          Authorization: `Bearer ${getToken({ forceNew: forceNewToken })}`,
          'Content-Type': 'application/json',
          'X-Request-Id': `${operation}.${attempt + 1}`,
        },
        body: body === undefined ? undefined : JSON.stringify(body),
        signal: AbortSignal.timeout(timeoutMs),
      });
    } catch (error) {
      if (!repeatable) throw error; // outcome unknown: the caller must check
      lastProblem = error;
      await sleep(delayMs(attempt));
      continue;
    }

    if (res.status === 401 && !forceNewToken) {
      forceNewToken = true; // one more try with a freshly signed token
      continue;
    }
    if (res.status === 429 || (repeatable && RETRY_ON.has(res.status))) {
      lastProblem = new Error(`HTTP ${res.status} (request ${res.headers.get('x-request-id')})`);
      await sleep(delayMs(attempt, res.headers.get('retry-after')));
      continue;
    }
    return res;
  }
  throw lastProblem;
}
```

```php
<?php
// api.php: PHP 8.1+ with ext-curl and ext-json. apiToken(bool $forceNew) returns a signed JWT.

function backoffMicroseconds(int $attempt, ?string $retryAfter): int
{
    $jitter = mt_rand() / mt_getrandmax();
    if ($retryAfter !== null && is_numeric($retryAfter)) {
        return (int) (((float) $retryAfter + $jitter) * 1_000_000); // never sooner than asked
    }
    $ceilingMs = min(30_000, 1_000 * (2 ** $attempt));
    return (int) (($ceilingMs / 2 + $jitter * $ceilingMs / 2) * 1_000);
}

/** @return array{status:int, headers:array<string,string>, body:mixed} */
function callApi(string $method, string $path, ?array $body, bool $repeatable, int $maxAttempts = 5): array
{
    $operation = bin2hex(random_bytes(16));
    $forceNewToken = false;
    $lastProblem = 'no attempt made';

    for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
        $headers = [];
        $ch = curl_init('https://api.main-team.org/v1' . $path);
        curl_setopt_array($ch, [
            CURLOPT_CUSTOMREQUEST => $method,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_TIMEOUT => 30,
            CURLOPT_HTTPHEADER => [
                'Authorization: Bearer ' . apiToken($forceNewToken),
                'Content-Type: application/json',
                'X-Request-Id: ' . $operation . '.' . ($attempt + 1),
            ],
            CURLOPT_HEADERFUNCTION => function ($ch, string $line) use (&$headers): int {
                $parts = explode(':', $line, 2);
                if (count($parts) === 2) {
                    $headers[strtolower(trim($parts[0]))] = trim($parts[1]);
                }
                return strlen($line);
            },
        ]);
        if ($body !== null) {
            curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_THROW_ON_ERROR));
        }
        $raw = curl_exec($ch);
        $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        $error = curl_error($ch);
        curl_close($ch);

        if ($raw === false) {
            if (!$repeatable) {
                throw new RuntimeException('No answer (' . $error . '); check before retrying.');
            }
            $lastProblem = $error;
            usleep(backoffMicroseconds($attempt, null));
            continue;
        }
        if ($status === 401 && !$forceNewToken) {
            $forceNewToken = true;
            continue;
        }
        if ($status === 429 || ($repeatable && in_array($status, [500, 502, 503, 504], true))) {
            $lastProblem = 'HTTP ' . $status . ' (request ' . ($headers['x-request-id'] ?? '-') . ')';
            usleep(backoffMicroseconds($attempt, $headers['retry-after'] ?? null));
            continue;
        }
        return ['status' => $status, 'headers' => $headers, 'body' => json_decode($raw, true)];
    }
    throw new RuntimeException('Gave up: ' . $lastProblem);
}
```

## Checklist

- [ ] Every `4xx` except `401` (once) and `429` is handled without a retry.
- [ ] `429` waits at least `Retry-After` seconds.
- [ ] `5xx` and timeouts are retried with jittered backoff, at most five attempts in total, and only for operations that are safe to repeat.
- [ ] A registration that timed out is re-sent, and its `409` is read for the student's id.
- [ ] An application create is never sent twice in parallel, and a `409` on a retry is checked against the student's applications.
- [ ] Sign-in links are minted on demand and never reopened.
- [ ] Downloads are written to a temporary file and discarded if truncated.
- [ ] Each attempt carries its own `X-Request-Id`, with no personal data in it.
