# Idempotence

Renvoyer une requête sans l’exécuter deux fois. Quelles requêtes demandent une Idempotency-Key, et ce que l’API répond à une répétition.

Un réseau peut tomber après que l’API a fait le travail mais avant que vous ne lisiez la réponse. Le
paiement a-t-il été créé ? Avec une **clé d’idempotence**, inutile de le savoir : renvoyez la même
requête avec la même clé, et l’API répond ce qu’elle a répondu la première fois, sans refaire le
travail.

## Envoyer une clé

Ajoutez un en-tête `Idempotency-Key` à un `POST`. La clé est à vous : jusqu’à 255 caractères ASCII
imprimables, unique pour chaque opération à faire une seule fois. Un UUID aléatoire convient ; une
clé bâtie sur vos propres références aussi, comme `order-1042-create`. Une clé plus longue, ou qui
contient d’autres caractères, est refusée avec `400`, `parameter_invalid`.

Générez la clé **avant** le premier essai et gardez-la pour les nouvelles tentatives de cette
opération. Une nouvelle clé veut dire une nouvelle opération.

**Obligatoire** sur les requêtes qui font circuler de l’argent :

* `POST /v1/payment_intents`
* `POST /v1/payment_intents/{id}/confirm`
* `POST /v1/checkout/sessions`
* `POST /v1/refunds`

Sans elle, elles répondent `400`, `idempotency_key_missing`. Tout autre `POST` accepte une clé sans
l’exiger. `GET` et `DELETE` l’ignorent : on peut les répéter tels quels sans risque.

```bash
curl https://pay-api.adpromax.com/v1/payment_intents \
  -u "$APX_SECRET_KEY:" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 8e0d2c5e-4f3a-4b8e-9c71-2a6f0d3b5e14" \
  -d '{"amount": 25000, "currency": "mad",
       "payment_method_types": ["card", "cash"]}'
```

```js title="create.mjs"
import { randomUUID } from 'node:crypto'

// One key per operation, made before the first try.
const idempotencyKey = randomUUID()

async function createPaymentIntent() {
  const response = await fetch('https://pay-api.adpromax.com/v1/payment_intents', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.APX_SECRET_KEY}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': idempotencyKey,
    },
    body: JSON.stringify({
      amount: 25000,
      currency: 'mad',
      payment_method_types: ['card', 'cash'],
    }),
  })
  return { response, body: await response.json() }
}
```

```ts title="create.ts"
import { randomUUID } from 'node:crypto'

interface PaymentIntent {
  id: string
  object: 'payment_intent'
  amount: number
  currency: string
  status: string
}

interface ApiError {
  error: { type: string, code: string, message: string, request_id: string }
}

interface Created {
  response: Response
  body: PaymentIntent | ApiError
}

// One key per operation, made before the first try.
const idempotencyKey = randomUUID()

async function createPaymentIntent(): Promise<Created> {
  const response = await fetch('https://pay-api.adpromax.com/v1/payment_intents', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.APX_SECRET_KEY}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': idempotencyKey,
    },
    body: JSON.stringify({
      amount: 25000,
      currency: 'mad',
      payment_method_types: ['card', 'cash'],
    }),
  })
  const body = (await response.json()) as PaymentIntent | ApiError
  return { response, body }
}
```

```python title="create.py"
import os
import uuid

import requests

# One key per operation, made before the first try.
idempotency_key = str(uuid.uuid4())

def create_payment_intent():
    return requests.post(
        "https://pay-api.adpromax.com/v1/payment_intents",
        auth=(os.environ["APX_SECRET_KEY"], ""),
        headers={"Idempotency-Key": idempotency_key},
        json={
            "amount": 25000,
            "currency": "mad",
            "payment_method_types": ["card", "cash"],
        },
        timeout=30,
    )
```

```php title="create.php"
<?php
// One key per operation, made before the first try.
$idempotencyKey = bin2hex(random_bytes(16));

function create_payment_intent(string $idempotencyKey): array
{
    $ch = curl_init('https://pay-api.adpromax.com/v1/payment_intents');
    curl_setopt_array($ch, [
        CURLOPT_USERPWD => getenv('APX_SECRET_KEY') . ':',
        CURLOPT_POST => true,
        CURLOPT_HTTPHEADER => [
            'Content-Type: application/json',
            'Idempotency-Key: ' . $idempotencyKey,
        ],
        CURLOPT_POSTFIELDS => json_encode([
            'amount' => 25000,
            'currency' => 'mad',
            'payment_method_types' => ['card', 'cash'],
        ]),
        CURLOPT_RETURNTRANSFER => true,
    ]);
    $body = json_decode(curl_exec($ch), true);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    return [$status, $body];
}
```

```ruby title="create.rb"
require 'json'
require 'net/http'
require 'securerandom'

# One key per operation, made before the first try.
IDEMPOTENCY_KEY = SecureRandom.uuid

def create_payment_intent
  uri = URI('https://pay-api.adpromax.com/v1/payment_intents')
  request = Net::HTTP::Post.new(uri)
  request.basic_auth(ENV.fetch('APX_SECRET_KEY'), '')
  request['Content-Type'] = 'application/json'
  request['Idempotency-Key'] = IDEMPOTENCY_KEY
  request.body = JSON.generate(
    amount: 25000,
    currency: 'mad',
    payment_method_types: %w[card cash]
  )
  Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
    http.request(request)
  end
end
```

```go title="create.go"
package main

import (
	"bytes"
	"crypto/rand"
	"encoding/json"
	"net/http"
	"os"
)

// One key per operation, made before the first try.
var idempotencyKey = rand.Text()

func createPaymentIntent() (*http.Response, error) {
	body, err := json.Marshal(map[string]any{
		"amount":               25000,
		"currency":             "mad",
		"payment_method_types": []string{"card", "cash"},
	})
	if err != nil {
		return nil, err
	}
	req, err := http.NewRequest(http.MethodPost,
		"https://pay-api.adpromax.com/v1/payment_intents", bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	req.SetBasicAuth(os.Getenv("APX_SECRET_KEY"), "")
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Idempotency-Key", idempotencyKey)
	return http.DefaultClient.Do(req)
}
```

```java title="Create.java"
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.UUID;

public class Create {
    // One key per operation, made before the first try.
    static final String KEY = UUID.randomUUID().toString();

    static HttpResponse<String> createPaymentIntent()
            throws IOException, InterruptedException {
        String body = """
            {
              "amount": 25000,
              "currency": "mad",
              "payment_method_types": ["card", "cash"]
            }
            """;
        HttpRequest request = HttpRequest.newBuilder(
                URI.create("https://pay-api.adpromax.com/v1/payment_intents"))
            .header("Authorization", "Bearer " + System.getenv("APX_SECRET_KEY"))
            .header("Content-Type", "application/json")
            .header("Idempotency-Key", KEY)
            .POST(HttpRequest.BodyPublishers.ofString(body))
            .build();
        return HttpClient.newHttpClient()
            .send(request, HttpResponse.BodyHandlers.ofString());
    }
}
```

```csharp title="Create.cs"
using System.Net.Http.Headers;
using System.Net.Http.Json;

using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("APX_SECRET_KEY"));

// One key per operation, made before the first try.
var idempotencyKey = Guid.NewGuid().ToString();

async Task<HttpResponseMessage> CreatePaymentIntent()
{
    using var request = new HttpRequestMessage(
        HttpMethod.Post, "https://pay-api.adpromax.com/v1/payment_intents");
    request.Headers.Add("Idempotency-Key", idempotencyKey);
    request.Content = JsonContent.Create(new
    {
        amount = 25000,
        currency = "mad",
        payment_method_types = new[] { "card", "cash" },
    });
    return await http.SendAsync(request);
}
```

```rust title="main.rs"
// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "json"] }
//             serde_json = "1"
//             uuid = { version = "1", features = ["v4"] }
use reqwest::blocking::{Client, Response};
use serde_json::json;
use uuid::Uuid;

fn create_payment_intent(key: &str) -> reqwest::Result<Response> {
    Client::new()
        .post("https://pay-api.adpromax.com/v1/payment_intents")
        .bearer_auth(std::env::var("APX_SECRET_KEY").unwrap_or_default())
        .header("Idempotency-Key", key)
        .json(&json!({
            "amount": 25000,
            "currency": "mad",
            "payment_method_types": ["card", "cash"]
        }))
        .send()
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // One key per operation, made before the first try.
    let idempotency_key = Uuid::new_v4().to_string();
    let response = create_payment_intent(&idempotency_key)?;
    println!("{}", response.status());
    Ok(())
}
```

## Ce que répond une répétition

L’API garde chaque clé **24 heures**, séparément pour chaque organisation et chaque mode : une clé
de test et une clé de production ne partagent jamais la réponse d’une clé d’idempotence.

| Vous envoyez                                                                              | L’API répond                                                                                                                                                                                            |
| ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Une nouvelle clé                                                                          | Exécute la requête et garde sa réponse.                                                                                                                                                                 |
| La même clé et la même requête, une fois terminée                                         | La réponse gardée, avec le même statut et le même corps, et les en-têtes `Idempotent-Replayed: true` et `Original-Request: req_…` (l’identifiant de la première requête). Rien ne s’exécute de nouveau. |
| La même clé avec une autre requête (autre chemin, autre chaîne de requête ou autre corps) | `400`, `idempotency_error`, `idempotency_key_reused`. Rien ne s’exécute.                                                                                                                                |
| La même clé pendant que la première requête s’exécute encore                              | `409`, `idempotency_error`, `idempotency_in_progress`. Réessayez dans un instant.                                                                                                                       |

« La même requête » veut dire la même méthode, le même chemin, la même chaîne de requête et le même
corps. Le corps est comparé tel que l’API l’a lu, pas octet par octet : l’ordre de ses champs et ses
espaces ne comptent pas, et un champ omis vaut le même champ envoyé avec sa valeur par défaut. Les
en-têtes ne sont pas comparés.

### Ce qui est gardé, et ce qui ne l’est pas

* **Un résultat est gardé**, y compris un refus sur le fond : un paiement refusé avec
  `amount_too_small` répond de nouveau `amount_too_small` pour la même clé. Corrigez la requête et
  envoyez-la avec une **nouvelle** clé.
* **Une répétition est la première réponse, octet pour octet.** Une erreur rejouée porte toujours le
  `request_id` de la première requête, celui que nomme `Original-Request` ; l’identifiant de la
  répétition est dans son en-tête `Request-Id`.
* **Une requête refusée avant de s’exécuter n’est pas gardée** : une mauvaise clé d’API, une limite
  de débit (`429`), un paramètre manquant ou invalide. Corrigez-la et renvoyez-la avec la même clé.
* **Une réponse qui dit « réessayez » n’est pas gardée** : un `5xx`, un `429`, toute réponse avec un
  en-tête `Retry-After`, et un refus dont le code finit par `_busy`. La même clé exécute de nouveau
  la requête.
* **Un secret montré une seule fois n’est pas gardé.** La répétition de la création d’un endpoint de
  webhook répond le même endpoint avec `"secret": null` : conservez le secret de la première réponse.
* **Une réponse de plus de 64 Ko n’est pas gardée** : la même clé exécuterait de nouveau la requête.
  Les réponses des requêtes qui font circuler de l’argent sont bien plus petites.

```bash
# the same command again: the answer is replayed
curl -i https://pay-api.adpromax.com/v1/payment_intents \
  -u "$APX_SECRET_KEY:" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 8e0d2c5e-4f3a-4b8e-9c71-2a6f0d3b5e14" \
  -d '{"amount": 25000, "currency": "mad",
       "payment_method_types": ["card", "cash"]}'
```

```js title="replay.mjs"
// The same request again, with the same key: the answer is replayed.
const response = await fetch('https://pay-api.adpromax.com/v1/payment_intents', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.APX_SECRET_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': '8e0d2c5e-4f3a-4b8e-9c71-2a6f0d3b5e14',
  },
  body: JSON.stringify({
    amount: 25000,
    currency: 'mad',
    payment_method_types: ['card', 'cash'],
  }),
})
console.log(response.status)
for (const name of ['Idempotent-Replayed', 'Original-Request', 'Request-Id'])
  console.log(`${name}: ${response.headers.get(name)}`)
```

```ts title="replay.ts"
// The same request again, with the same key: the answer is replayed.
const response: Response = await fetch('https://pay-api.adpromax.com/v1/payment_intents', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.APX_SECRET_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': '8e0d2c5e-4f3a-4b8e-9c71-2a6f0d3b5e14',
  },
  body: JSON.stringify({
    amount: 25000,
    currency: 'mad',
    payment_method_types: ['card', 'cash'],
  }),
})
const names: string[] = ['Idempotent-Replayed', 'Original-Request', 'Request-Id']
console.log(response.status)
for (const name of names)
  console.log(`${name}: ${response.headers.get(name)}`)
```

```python title="replay.py"
import os

import requests

# The same request again, with the same key: the answer is replayed.
response = requests.post(
    "https://pay-api.adpromax.com/v1/payment_intents",
    auth=(os.environ["APX_SECRET_KEY"], ""),
    headers={"Idempotency-Key": "8e0d2c5e-4f3a-4b8e-9c71-2a6f0d3b5e14"},
    json={
        "amount": 25000,
        "currency": "mad",
        "payment_method_types": ["card", "cash"],
    },
)
print(response.status_code)
for name in ("Idempotent-Replayed", "Original-Request", "Request-Id"):
    print(f"{name}: {response.headers.get(name)}")
```

```php title="replay.php"
<?php
// The same request again, with the same key: the answer is replayed.
$headers = [];
$ch = curl_init('https://pay-api.adpromax.com/v1/payment_intents');
curl_setopt_array($ch, [
    CURLOPT_USERPWD => getenv('APX_SECRET_KEY') . ':',
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'Content-Type: application/json',
        'Idempotency-Key: 8e0d2c5e-4f3a-4b8e-9c71-2a6f0d3b5e14',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'amount' => 25000,
        'currency' => 'mad',
        'payment_method_types' => ['card', 'cash'],
    ]),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$headers) {
        $parts = explode(':', $line, 2);
        if (count($parts) === 2) {
            $headers[strtolower($parts[0])] = trim($parts[1]);
        }
        return strlen($line);
    },
]);
curl_exec($ch);
echo curl_getinfo($ch, CURLINFO_RESPONSE_CODE), PHP_EOL;
foreach (['Idempotent-Replayed', 'Original-Request', 'Request-Id'] as $name) {
    echo $name, ': ', $headers[strtolower($name)] ?? '', PHP_EOL;
}
```

```ruby title="replay.rb"
require 'json'
require 'net/http'

# The same request again, with the same key: the answer is replayed.
uri = URI('https://pay-api.adpromax.com/v1/payment_intents')
request = Net::HTTP::Post.new(uri)
request.basic_auth(ENV.fetch('APX_SECRET_KEY'), '')
request['Content-Type'] = 'application/json'
request['Idempotency-Key'] = '8e0d2c5e-4f3a-4b8e-9c71-2a6f0d3b5e14'
request.body = JSON.generate(
  amount: 25000,
  currency: 'mad',
  payment_method_types: %w[card cash]
)

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end
puts response.code
%w[Idempotent-Replayed Original-Request Request-Id].each do |name|
  puts "#{name}: #{response[name]}"
end
```

```go title="replay.go"
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"
)

func main() {
	body, err := json.Marshal(map[string]any{
		"amount":               25000,
		"currency":             "mad",
		"payment_method_types": []string{"card", "cash"},
	})
	if err != nil {
		log.Fatal(err)
	}
	// The same request again, with the same key: the answer is replayed.
	req, err := http.NewRequest(http.MethodPost,
		"https://pay-api.adpromax.com/v1/payment_intents", bytes.NewReader(body))
	if err != nil {
		log.Fatal(err)
	}
	req.SetBasicAuth(os.Getenv("APX_SECRET_KEY"), "")
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Idempotency-Key", "8e0d2c5e-4f3a-4b8e-9c71-2a6f0d3b5e14")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer res.Body.Close()

	fmt.Println(res.Status)
	names := []string{"Idempotent-Replayed", "Original-Request", "Request-Id"}
	for _, name := range names {
		fmt.Printf("%s: %s\n", name, res.Header.Get(name))
	}
}
```

```java title="Replay.java"
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;

public class Replay {
    public static void main(String[] args) throws Exception {
        String body = """
            {
              "amount": 25000,
              "currency": "mad",
              "payment_method_types": ["card", "cash"]
            }
            """;
        // The same request again, with the same key: the answer is replayed.
        HttpRequest request = HttpRequest.newBuilder(
                URI.create("https://pay-api.adpromax.com/v1/payment_intents"))
            .header("Authorization", "Bearer " + System.getenv("APX_SECRET_KEY"))
            .header("Content-Type", "application/json")
            .header("Idempotency-Key", "8e0d2c5e-4f3a-4b8e-9c71-2a6f0d3b5e14")
            .POST(HttpRequest.BodyPublishers.ofString(body))
            .build();
        HttpResponse<String> response = HttpClient.newHttpClient()
            .send(request, HttpResponse.BodyHandlers.ofString());

        System.out.println(response.statusCode());
        List<String> names = List.of(
            "Idempotent-Replayed", "Original-Request", "Request-Id");
        for (String name : names) {
            System.out.println(name + ": "
                + response.headers().firstValue(name).orElse(""));
        }
    }
}
```

```csharp title="Replay.cs"
using System.Net.Http.Headers;
using System.Net.Http.Json;

using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("APX_SECRET_KEY"));

// The same request again, with the same key: the answer is replayed.
using var request = new HttpRequestMessage(
    HttpMethod.Post, "https://pay-api.adpromax.com/v1/payment_intents");
request.Headers.Add("Idempotency-Key", "8e0d2c5e-4f3a-4b8e-9c71-2a6f0d3b5e14");
request.Content = JsonContent.Create(new
{
    amount = 25000,
    currency = "mad",
    payment_method_types = new[] { "card", "cash" },
});

using var response = await http.SendAsync(request);
Console.WriteLine((int)response.StatusCode);
string[] names = ["Idempotent-Replayed", "Original-Request", "Request-Id"];
foreach (var name in names)
{
    response.Headers.TryGetValues(name, out var values);
    Console.WriteLine($"{name}: {values?.FirstOrDefault()}");
}
```

```rust title="main.rs"
// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "json"] }
//             serde_json = "1"
use serde_json::json;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // The same request again, with the same key: the answer is replayed.
    let response = reqwest::blocking::Client::new()
        .post("https://pay-api.adpromax.com/v1/payment_intents")
        .bearer_auth(std::env::var("APX_SECRET_KEY")?)
        .header("Idempotency-Key", "8e0d2c5e-4f3a-4b8e-9c71-2a6f0d3b5e14")
        .json(&json!({
            "amount": 25000,
            "currency": "mad",
            "payment_method_types": ["card", "cash"]
        }))
        .send()?;

    println!("{}", response.status());
    for name in ["Idempotent-Replayed", "Original-Request", "Request-Id"] {
        let value = response.headers().get(name).and_then(|v| v.to_str().ok());
        println!("{name}: {}", value.unwrap_or(""));
    }
    Ok(())
}
```

```text response
HTTP/1.1 200 OK
Idempotent-Replayed: true
Original-Request: req_01k7d7m2n5b8v1c4x7z0q3w6es
Request-Id: req_01k7d7p9a2s5d8f1g4h7j0k3hm
```

## Réessayer sans risque

Réessayez quand la réponse n’est jamais venue (délai dépassé, connexion coupée), sur
`409 idempotency_in_progress`, sur `429` après le délai `Retry-After`, et sur un `5xx`. Attendez de
plus en plus longtemps entre les essais, et gardez la même clé à chaque fois. Ne réessayez pas un
autre `4xx` : il échouera de la même façon. N’attendez pas non plus sur un `Retry-After` de plusieurs
minutes ou heures : c’est le plafond quotidien du mode test, qui dure jusqu’à minuit. Voir
[Limites de débit](https://pay-docs.adpromax.com/fr/api-reference/rate-limits).

```bash
# --retry sends it again after a timeout, a 429 or a 5xx, with the same key;
# --retry-max-time gives up when Retry-After is longer (test mode's daily cap)
curl https://pay-api.adpromax.com/v1/payment_intents \
  -u "$APX_SECRET_KEY:" \
  --retry 4 \
  --retry-max-time 60 \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 8e0d2c5e-4f3a-4b8e-9c71-2a6f0d3b5e14" \
  -d '{"amount": 25000, "currency": "mad",
       "payment_method_types": ["card", "cash"]}'
```

```js title="retry.mjs"
const RETRY_STATUSES = new Set([409, 429, 500, 502, 503, 504])
const LONGEST_WAIT = 60 // seconds

// createPaymentIntent() and its key come from the step above.
async function withRetries(attempts = 4) {
  for (let attempt = 1; ; attempt++) {
    try {
      const { response, body } = await createPaymentIntent()
      if (!RETRY_STATUSES.has(response.status) || attempt === attempts)
        return body
      const wait = Number(response.headers.get('Retry-After')) || 2 ** attempt
      if (wait > LONGEST_WAIT) // test mode's daily cap, until midnight
        return body
      await new Promise(resolve => setTimeout(resolve, wait * 1000))
    }
    catch (error) { // no answer: the network failed
      if (attempt === attempts)
        throw error
      await new Promise(resolve => setTimeout(resolve, 2 ** attempt * 1000))
    }
  }
}
```

```ts title="retry.ts"
const RETRY_STATUSES = new Set([409, 429, 500, 502, 503, 504])
const LONGEST_WAIT = 60 // seconds

// createPaymentIntent() and its key come from the step above.
declare function createPaymentIntent(): Promise<{
  response: Response
  body: unknown
}>

function sleep(seconds: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, seconds * 1000))
}

async function withRetries(attempts = 4): Promise<unknown> {
  for (let attempt = 1; ; attempt++) {
    try {
      const { response, body } = await createPaymentIntent()
      if (!RETRY_STATUSES.has(response.status) || attempt === attempts)
        return body
      const wait = Number(response.headers.get('Retry-After')) || 2 ** attempt
      if (wait > LONGEST_WAIT) // test mode's daily cap, until midnight
        return body
      await sleep(wait)
    }
    catch (error) { // no answer: the network failed
      if (attempt === attempts)
        throw error
      await sleep(2 ** attempt)
    }
  }
}
```

```python title="retry.py"
import time

import requests

RETRY_STATUSES = {409, 429, 500, 502, 503, 504}
LONGEST_WAIT = 60  # seconds

# create_payment_intent() and its key come from the step above.
def with_retries(attempts=4):
    for attempt in range(1, attempts + 1):
        try:
            response = create_payment_intent()
        except requests.ConnectionError:  # no answer: the network failed
            if attempt == attempts:
                raise
            time.sleep(2 ** attempt)
            continue
        if response.status_code not in RETRY_STATUSES or attempt == attempts:
            return response.json()
        wait = int(response.headers.get("Retry-After", 2 ** attempt))
        if wait > LONGEST_WAIT:  # test mode's daily cap, until midnight
            return response.json()
        time.sleep(wait)
```

```php title="retry.php"
<?php
// create_payment_intent() and $idempotencyKey come from the step above.
function with_retries(string $idempotencyKey, int $attempts = 4): ?array
{
    $retry = [0, 409, 429, 500, 502, 503, 504]; // 0: no answer at all
    for ($attempt = 1; ; $attempt++) {
        [$status, $body] = create_payment_intent($idempotencyKey);
        if (!in_array($status, $retry, true) || $attempt === $attempts) {
            return $body;
        }
        sleep(2 ** $attempt);
    }
}
```

```ruby title="retry.rb"
require 'json'
require 'net/http'

RETRY_STATUSES = [409, 429, 500, 502, 503, 504].freeze
LONGEST_WAIT = 60 # seconds

# create_payment_intent and its key come from the step above.
def with_retries(attempts = 4)
  1.upto(attempts) do |attempt|
    begin
      response = create_payment_intent
      retry_it = RETRY_STATUSES.include?(response.code.to_i)
      return JSON.parse(response.body) unless retry_it && attempt < attempts

      wait = (response['Retry-After'] || 2**attempt).to_i
      # A longer wait is test mode's daily cap, until midnight.
      return JSON.parse(response.body) if wait > LONGEST_WAIT

      sleep(wait)
    rescue SocketError, SystemCallError, Timeout::Error
      # no answer: the network failed
      raise if attempt == attempts

      sleep(2**attempt)
    end
  end
end
```

```go title="retry.go"
package main

import (
	"net/http"
	"strconv"
	"time"
)

const longestWait = 60 * time.Second

var retryStatuses = map[int]bool{
	409: true, 429: true, 500: true, 502: true, 503: true, 504: true,
}

// createPaymentIntent() and its key come from the step above.
func withRetries(attempts int) (*http.Response, error) {
	for attempt := 1; ; attempt++ {
		wait := time.Duration(1<<attempt) * time.Second
		res, err := createPaymentIntent()
		if err != nil { // no answer: the network failed
			if attempt == attempts {
				return nil, err
			}
			time.Sleep(wait)
			continue
		}
		if !retryStatuses[res.StatusCode] || attempt == attempts {
			return res, nil
		}
		after, err := strconv.Atoi(res.Header.Get("Retry-After"))
		if err == nil {
			wait = time.Duration(after) * time.Second
		}
		if wait > longestWait { // test mode's daily cap, until midnight
			return res, nil
		}
		res.Body.Close()
		time.Sleep(wait)
	}
}
```

```java title="Retry.java"
import java.io.IOException;
import java.net.http.HttpResponse;
import java.util.Set;

public class Retry {
    static final Set<Integer> RETRY_STATUSES =
        Set.of(409, 429, 500, 502, 503, 504);
    static final long LONGEST_WAIT = 60; // seconds

    // Create.createPaymentIntent() and its key come from the step above.
    static HttpResponse<String> withRetries(int attempts)
            throws IOException, InterruptedException {
        for (int attempt = 1; ; attempt++) {
            long wait = 1L << attempt;
            try {
                HttpResponse<String> response = Create.createPaymentIntent();
                boolean retry = RETRY_STATUSES.contains(response.statusCode());
                if (!retry || attempt == attempts) {
                    return response;
                }
                wait = response.headers()
                    .firstValueAsLong("Retry-After").orElse(wait);
                if (wait > LONGEST_WAIT) { // test mode's daily cap
                    return response;
                }
            } catch (IOException e) { // no answer: the network failed
                if (attempt == attempts) {
                    throw e;
                }
            }
            Thread.sleep(wait * 1000);
        }
    }
}
```

```csharp title="Retry.cs"
int[] retryStatuses = [409, 429, 500, 502, 503, 504];
var longestWait = TimeSpan.FromSeconds(60);

// CreatePaymentIntent() and its key come from the step above.
async Task<HttpResponseMessage> WithRetries(int attempts = 4)
{
    for (var attempt = 1; ; attempt++)
    {
        var wait = TimeSpan.FromSeconds(Math.Pow(2, attempt));
        try
        {
            var response = await CreatePaymentIntent();
            var retry = retryStatuses.Contains((int)response.StatusCode);
            if (!retry || attempt == attempts)
                return response;
            wait = response.Headers.RetryAfter?.Delta ?? wait;
            if (wait > longestWait) // test mode's daily cap, until midnight
                return response;
        }
        catch (HttpRequestException) when (attempt < attempts)
        {
            // no answer: the network failed
        }
        await Task.Delay(wait);
    }
}
```

```rust title="main.rs"
// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "json"] }
use std::{thread::sleep, time::Duration};

use reqwest::blocking::Response;

const RETRY_STATUSES: [u16; 6] = [409, 429, 500, 502, 503, 504];
const LONGEST_WAIT: u64 = 60; // seconds

// create_payment_intent() and its key come from the step above.
fn with_retries(key: &str, attempts: u32) -> reqwest::Result<Response> {
    let mut attempt = 1;
    loop {
        let wait = match create_payment_intent(key) {
            Ok(response) => {
                let status = response.status().as_u16();
                if !RETRY_STATUSES.contains(&status) || attempt == attempts {
                    return Ok(response);
                }
                let wait = response
                    .headers()
                    .get("Retry-After")
                    .and_then(|value| value.to_str().ok()?.parse().ok())
                    .unwrap_or(2u64.pow(attempt));
                if wait > LONGEST_WAIT {
                    // test mode's daily cap, until midnight
                    return Ok(response);
                }
                wait
            }
            // no answer: the network failed
            Err(error) if attempt == attempts => return Err(error),
            Err(_) => 2u64.pow(attempt),
        };
        sleep(Duration::from_secs(wait));
        attempt += 1;
    }
}
```

## Sur le tableau de bord

Les formulaires de remboursement et de paiement en espèces du tableau de bord envoient une clé pour
vous : appuyer deux fois sur le bouton fait un seul remboursement. Chaque requête que vous envoyez
avec une clé l’affiche dans les journaux de requêtes du tableau de bord, à côté de son `Request-Id`.
