Réessayer un envoi sans doublon : la clé d'idempotence
Votre application envoie une confirmation de commande. La requête part, puis rien : le délai est dépassé, sans réponse. Le message a-t-il été accepté ? Vous ne pouvez pas le savoir. Si vous le renvoyez, vous risquez d'écrire deux fois au client. Si vous ne le renvoyez pas, vous risquez qu'il ne reçoive rien.
La clé d'idempotence règle ce dilemme : vous pouvez réessayer autant de fois que nécessaire, et Duva n'accepte le message qu'une seule fois. Cet article explique comment elle fonctionne, comment la choisir et les erreurs à éviter.
Le problème : une réponse perdue
Une requête réseau peut échouer de deux façons très différentes :
- Avant d'arriver. La connexion est refusée, le DNS ne répond pas. Rien n'a été traité : réessayer est sans risque.
- Après avoir été traitée. Le serveur a accepté le message, puis la réponse s'est perdue en chemin, ou votre délai a expiré juste avant qu'elle n'arrive. Le résultat de la première tentative est inconnu.
Dans le second cas, un simple « réessayer » crée un doublon. L'idempotence rend l'opération sûre : envoyer la même requête avec la même clé produit le même résultat que de l'envoyer une seule fois.
Comment ça marche chez Duva
Ajoutez l'en-tête Idempotency-Key à POST /v1/{domaine}/messages :
curl -X POST https://api.duva.ca/v1/example.com/messages \
-H "Authorization: Bearer $DUVA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4821-confirmation" \
-d '{
"from": "Example <[email protected]>",
"to": ["[email protected]"],
"subject": "Votre commande",
"text": "Merci pour votre commande."
}'
Les règles, telles que la référence de l'API les décrit :
- La clé fait de 1 à 255 caractères ASCII imprimables, sans espace, et elle est unique par domaine.
- Même clé et même requête : la réponse est un
202avec le mêmeidde message et l'en-têteIdempotent-Replayed: true. Aucun quota n'est consommé de nouveau, même s'il est épuisé. - Même clé, requête différente :
409avec le codeidempotency_conflict. - Deux requêtes identiques en même temps avec la même clé ne créent qu'un seul message.
- Les clés n'expirent pas tant que le message existe.
Dans le cas de la réponse perdue, la deuxième tentative reçoit donc le 202 que la première aurait dû recevoir, avec le même identifiant msg_....
Choisir la clé
Le piège principal : une clé générée au hasard à chaque tentative ne protège de rien, puisque chaque tentative aurait une clé différente. La clé doit désigner l'envoi logique, pas la tentative.
- Bon :
order-4821-confirmation. Elle est dérivée de ce que vous envoyez (la confirmation de la commande 4821) et reste identique à chaque tentative, même après un redémarrage de votre application. - Bon : un identifiant de tâche de votre file (
job-98213), stocké avant le premier essai. - Mauvais :
uuid4()calculé dans la boucle de réessai. - Mauvais : l'adresse du destinataire seule. Le deuxième courriel que vous lui enverrez, demain, serait pris pour un rejeu du premier ou refusé en
409.
Si vous utilisez un identifiant aléatoire, générez-le une fois, avant le premier essai, et conservez-le avec la tâche.
Une clé par motif d'envoi aide aussi : order-4821-confirmation et order-4821-shipped sont deux messages différents de la même commande.
Ce qu'il ne faut pas changer entre deux tentatives
Pour Duva, « la même requête » veut dire le même contenu : destinataires, sujet, corps. Si vous réessayez avec un sujet modifié, ou un destinataire de plus, la réponse est un 409 idempotency_conflict : la clé a déjà servi pour une requête différente. C'est volontaire. Cela vous évite de croire qu'une version corrigée du message est partie alors que la première a été acceptée.
Si vous voulez vraiment envoyer une version corrigée, utilisez une nouvelle clé : ce sera un nouveau message.
Un exemple de réessai
La bibliothèque officielle Python fait le travail pour vous. Pour dédoublonner un envoi logique, donnez-lui une clé stable ; elle expose aussi replayed, qui vaut True quand la réponse vient d'un rejeu :
import os
from duva import Duva
duva = Duva(api_key=os.environ["DUVA_API_KEY"], domain="example.com")
message = duva.messages.send(
from_="Example <[email protected]>",
to=["[email protected]"],
subject="Votre commande",
text="Merci pour votre commande.",
idempotency_key="order-4821-confirmation",
)
print(message.id, message.replayed)
La bibliothèque réessaie les échecs passagers (jusqu'à deux fois par défaut) et, quand vous ne donnez pas de clé, en génère une pour l'appel. Cette clé générée protège les réessais à l'intérieur d'un appel, mais pas deux appels séparés : pour éviter un doublon entre deux exécutions de votre code, fournissez la vôtre.
Sans bibliothèque, voici la même idée avec la bibliothèque standard de Python :
import json
import time
import urllib.error
import urllib.request
API = "https://api.duva.ca/v1/example.com/messages"
def send_with_retries(api_key: str, payload: dict, key: str, attempts: int = 3) -> dict:
body = json.dumps(payload).encode()
for attempt in range(attempts):
request = urllib.request.Request(
API,
data=body,
method="POST",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Idempotency-Key": key, # la même clé à chaque tentative
},
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
return json.load(response)
except urllib.error.HTTPError as error:
if error.code < 500:
raise # 4xx : réessayer la même requête ne changera pas la réponse
except OSError:
pass # délai dépassé ou erreur réseau : résultat inconnu, on réessaie
time.sleep(0.5 * 2**attempt)
raise RuntimeError("envoi échoué après plusieurs tentatives")
Nous l'avons exécuté contre un faux serveur local qui accepte le premier envoi puis « perd » la réponse : la deuxième tentative reçoit le même identifiant de message, et un seul message existe. Avec la même clé et un autre destinataire, le serveur répond 409, comme le fait Duva.
Quand réessayer, quand s'arrêter
Réessayer n'est pas toujours la bonne idée :
| Réponse | Quoi faire |
|---|---|
Erreur réseau, délai dépassé, 5xx |
Réessayez avec la même clé, avec une attente croissante. |
429 rate_limited |
Attendez le nombre de secondes de Retry-After, puis réessayez. |
429 quota_exceeded |
Ne réessayez pas tout de suite : Retry-After peut représenter des heures (la prochaine période). C'est à votre application de décider quoi faire du message. |
401, 403, 404, 422 |
Ne réessayez pas : la même requête donnera la même réponse. Corrigez d'abord la cause. |
409 idempotency_conflict |
Ne réessayez pas : vous avez changé la requête sous la même clé. |
Pour un 429 rate_limited, ne faites pas de rafale de nouvelles tentatives : un 429 de ce type ne consomme pas votre quota d'envoi, mais insister aggrave le problème.
Par SMTP
Si vous envoyez par SMTP (smtp.duva.ca), le principe est le même. Vous pouvez poser l'en-tête X-Idempotency-Key, que Duva lit puis retire avant l'envoi : le destinataire ne le voit jamais. Sans cet en-tête, Duva calcule une clé à partir du Message-ID et des destinataires de l'enveloppe : un logiciel qui renvoie le même message après une coupure ne crée pas de doublon, et reçoit la même réponse. Le même Message-ID pour d'autres destinataires est un autre message.
Une réponse 4xx du serveur invite à réessayer ; une réponse 5xx est définitive pour ce message.
L'idempotence existe aussi de l'autre côté
Duva envoie au moins une fois ses webhooks : le même événement peut arriver plus d'une fois. C'est à votre récepteur de les dédoublonner sur webhook-id, comme on le détaille dans Vérifier la signature d'un webhook. Les deux protections se complètent : la clé évite les doublons à l'entrée, la déduplication évite d'agir deux fois à la sortie.
Et une fois le message accepté ?
Un 202, même rejoué, veut dire que le message est accepté et en file, pas qu'il est livré. Pour suivre ce qui se passe ensuite, gardez l'identifiant msg_... et lisez son statut : voir Un courriel n'est pas arrivé : comment trouver pourquoi.
Une liste de vérification
- Chaque envoi logique a une clé stable, dérivée de ce qu'il représente.
- La clé est choisie avant le premier essai et conservée avec la tâche.
- Chaque tentative réutilise la même clé et la même requête.
- Une version corrigée du message utilise une nouvelle clé.
- Les
5xx, les délais dépassés et les erreurs réseau sont réessayés avec une attente croissante et un nombre d'essais borné. - Un
quota_exceededn'est pas réessayé en boucle. - Les
4xxne sont pas réessayés sans correction. - Vous gardez l'identifiant
msg_...renvoyé, rejeu compris.