# Erreurs et idempotence

import { ApiExample, ApiResponse } from "../../src/ApiExample";

## Format des erreurs

Toute erreur a la même forme, quel que soit l'endpoint :

<ApiResponse
  status={422}
  body={{
    error: {
      code: "RESELLER_CREDIT_CAP_EXCEEDED",
      message: "Montant supérieur au plafond par opération",
    },
  }}
/>

| Champ | Contrat |
|---|---|
| `code` | **stable**. Branchez votre logique dessus. |
| `message` | français, destiné à l'affichage. **Peut changer sans préavis** — ne le parsez jamais. |
| `details` | présent sur les erreurs de validation : liste de `{ field, messages[] }`. |

Une validation échouée détaille chaque champ fautif, et tous les motifs pour chacun :

<ApiResponse
  status={422}
  body={{
    error: {
      code: "VALIDATION_ERROR",
      message: "Validation échouée",
      details: [
        {
          field: "phoneNumber",
          messages: ["Numéro ivoirien attendu, format +225XXXXXXXXXX"],
        },
        {
          field: "amountFcfa",
          messages: [
            "Doit être un entier",
            "Doit être strictement positif",
          ],
        },
      ],
    },
  }}
/>

Une gestion correcte, côté client :

```javascript title="JavaScript"
const reponse = await fetch(url, options);

if (!reponse.ok) {
  const { error } = await reponse.json();

  switch (error.code) {
    case "RESELLER_DAILY_CAP_EXCEEDED":
      return refuserAvecMessageMetier(error.message);
    case "RATE_LIMIT_EXCEEDED":
      return replanifier(Number(reponse.headers.get("X-RateLimit-Reset")));
    default:
      // Un code inconnu se traite selon son statut HTTP, jamais en silence
      throw new ErreurInstafuel(reponse.status, error.code, error.message);
  }
}
```

```python title="Python"
reponse = requests.get(url, headers=entetes, timeout=30)

if not reponse.ok:
    erreur = reponse.json()["error"]

    if erreur["code"] == "RESELLER_DAILY_CAP_EXCEEDED":
        refuser_avec_message_metier(erreur["message"])
    elif erreur["code"] == "RATE_LIMIT_EXCEEDED":
        replanifier(int(reponse.headers["X-RateLimit-Reset"]))
    else:
        raise ErreurInstafuel(reponse.status_code, erreur["code"], erreur["message"])
```

## Codes HTTP

| Code | Signification |
|---|---|
| `400` | requête malformée ou en-tête obligatoire manquant |
| `401` | clé absente, invalide, expirée ou révoquée |
| `403` | authentifié, mais hors périmètre, hors droits, ou IP non autorisée |
| `404` | ressource inexistante, ou fonctionnalité non activée pour vous |
| `409` | conflit — unicité violée, ou rejeu avec un corps différent |
| `422` | règle métier violée : plafond dépassé, rôle hors liste blanche, validation échouée |
| `429` | limite de débit dépassée |
| `5xx` | erreur serveur |

La distinction `400` / `422` est utile : `400` signifie « votre requête est mal formée », `422` signifie « votre requête est correcte mais l'opération est refusée ». Un plafond dépassé est un `422` — inutile de retenter à l'identique.

## Idempotence

Toute mutation exige l'en-tête **`X-Idempotency-Key`** : un UUID que vous générez.

<ApiExample
  method="POST"
  path="/v1/papi/companies/4832/wallet/credit"
  idempotency
  body={{
    amountFcfa: 500000,
    source: "BANK_TRANSFER",
    clientReference: "VIR-2026-0814",
    proofIds: ["11111111-1111-4111-8111-111111111111"],
  }}
/>

### Ce que fait un second appel

Le premier appel enregistre la demande :

<ApiResponse
  status={202}
  title="Premier appel — 202"
  body={{
    status: "APPROVAL_PENDING",
    approvalId: "0193a1f2-7c44-7c1e-9b0a-5f2d1c8e4b60",
    amountFcfa: 500000,
    requestedAt: "2026-08-31T09:04:12.000Z",
    expiresAt: "2026-09-07T09:04:12.000Z",
  }}
/>

Le **même appel rejoué avec la même clé et le même corps** ne crée pas de seconde demande : il renvoie la réponse mémorisée, à l'identique — même `approvalId`, même `requestedAt` — accompagnée de l'en-tête `X-Idempotent-Replay: true`.

<ApiResponse
  status={202}
  title="Rejeu, même corps — 202, réponse mémorisée"
  body={{
    status: "APPROVAL_PENDING",
    approvalId: "0193a1f2-7c44-7c1e-9b0a-5f2d1c8e4b60",
    amountFcfa: 500000,
    requestedAt: "2026-08-31T09:04:12.000Z",
    expiresAt: "2026-09-07T09:04:12.000Z",
  }}
/>

La **même clé avec un corps différent** est en revanche un bug côté appelant, et l'API refuse de trancher à votre place :

<ApiResponse
  status={409}
  title="Rejeu, corps différent — 409"
  body={{
    error: {
      code: "IDEMPOTENCY_CONFLICT",
      message:
        "Cette clé d'idempotence a déjà été utilisée avec un corps différent",
      firstSeenAt: "2026-08-31T09:04:12.000Z",
    },
  }}
/>

Et sans l'en-tête du tout :

<ApiResponse
  status={400}
  body={{
    error: {
      code: "IDEMPOTENCY_KEY_MISSING",
      message: "En-tête X-Idempotency-Key obligatoire sur cette opération",
    },
  }}
/>

La clé est conservée **24 h**.

<Callout type="tip" title="Une clé par opération métier, pas par tentative">
  Générez la clé au moment où l'opération est décidée — pas au moment de l'envoi — et **réutilisez-la** pour toutes les tentatives réseau de cette opération.

  Générer une nouvelle clé à chaque retry annule toute la protection : chaque tentative devient une opération distincte, et un versement de 500 000 FCFA déclaré trois fois donne trois demandes.
</Callout>

```javascript title="JavaScript"
// La clé appartient à l'opération, pas à la requête HTTP
const operation = { cle: crypto.randomUUID(), corps: { amountFcfa: 500000 } };

for (let tentative = 1; tentative <= 3; tentative++) {
  const reponse = await envoyer(operation); // même clé à chaque tour
  if (reponse.ok || !estRetentable(reponse.status)) return reponse;
  await attendre(2 ** tentative * 1000);
}
```

```php title="PHP"
<?php
// La clé appartient à l'opération : générée une fois, réutilisée à chaque essai
$cleIdempotence = bin2hex(random_bytes(16));

for ($tentative = 1; $tentative <= 3; $tentative++) {
    [$statut, $reponse] = envoyer($cleIdempotence, $corps);
    if ($statut < 400 || !estRetentable($statut)) {
        return $reponse;
    }
    sleep(2 ** $tentative);
}
```

### Deuxième couche, côté wallet

Indépendamment de l'en-tête HTTP, chaque écriture au grand livre porte une clé dérivée du wallet et de la référence du versement, sous contrainte d'unicité en base. Une même `clientReference` sur un même wallet ne peut donc pas produire deux écritures, même si l'en-tête a été mal géré.

Cette référence est préfixée côté serveur par votre identifiant d'apporteur : deux partenaires utilisant la même référence sur un même wallet n'entrent pas en collision.

## Retenter, ou pas

| Code | Retenter ? |
|---|---|
| `429`, `5xx` | oui — backoff exponentiel, **même clé d'idempotence** |
| `408`, coupure réseau, délai dépassé | oui — même clé : c'est précisément le cas qu'elle couvre |
| `401` | non, sauf si vous venez de faire tourner la clé |
| `400`, `403`, `404`, `409`, `422` | non — un rejeu à l'identique produira le même résultat |

<Callout type="caution" title="Le silence n'est pas un échec">
  Si la connexion tombe avant la réponse, l'opération a peut-être abouti. Ne la considérez ni comme réussie ni comme échouée : rejouez-la avec la même clé d'idempotence. Vous récupérerez la réponse mémorisée si elle avait abouti, et l'opération sera exécutée sinon.
</Callout>

## `403` ou `404` ?

Les deux existent et ne veulent pas dire la même chose :

- **`403`** — la ressource est identifiée et se trouve hors de votre périmètre.
- **`404`** — la ressource n'existe pas, ou le périmètre est appliqué directement dans la requête, ce qui rend les deux cas indiscernables.

Un `404` sur un identifiant que vous croyez valide signifie donc en général qu'il appartient à quelqu'un d'autre. Voir [Périmètre](/guides/perimetre).

## Fonctionnalités non activées

Un module non ouvert pour votre compte répond **`404 FEATURE_DISABLED`**, pas `403`. Tant qu'une fonctionnalité n'est pas activée, elle est invisible : vous ne pouvez pas déduire son existence en sondant l'API.

<ApiResponse
  status={404}
  body={{
    error: {
      code: "FEATURE_DISABLED",
      message: "Fonctionnalité non activée pour ce compte",
    },
  }}
/>

Si une opération documentée ici renvoie ce code, c'est qu'elle doit être activée côté Instafuel pour votre compte.
