Idempotence et retry
Tour d'horizon des garanties d'idempotence de l'API B2B, des stratégies de retry côté consommateur, et des pièges courants.
TL;DR :
- La majorité des endpoints B2B sont idempotents par construction (upserts par
IdentifiantExterne). - L'API n'accepte pas d'en-tête
Idempotency-Key— l'idempotence repose sur la stabilité des identifiants métier. - Les webhooks sortants exigent une idempotence côté récepteur via
deliveryId.
1. Matrice d'idempotence
| Opération | Idempotent ? | Clé d'idempotence | Notes |
|---|---|---|---|
ISyncClient.Sync | ✅ | IdentifiantExterne par entité | Re-exécuter produit le même état final |
IEmployesClient.AddEmploye / UpdateEmploye | ✅ (IdExterne) | IdExterne | Rappeler fait une mise à jour non destructive |
IEmployesClient.DeleteEmploye | ✅ | IdExterne | 2e appel = no-op (déjà supprimé) |
IEmployesClient.UpdateCourriels | ✅ | IdExterne | Invalides remontent dans la liste B2BError |
IEmployeursClient.*, ISyndicatsClient.*, IEmploisClient.* | ✅ | IdentifiantExterne | Même pattern |
IChampUtilisateurClient.SetEmployeurs / SetChoix | ✅ | Nom du champ | Remplacement complet |
IObjetsConsentementClient.* | ✅ | IdentifiantExterne | |
IConsentementClient.Get* (lecture) | ✅ | N/A | Lectures toujours idempotentes |
IFormulaireClient.GetAllFormulaires | ✅ | N/A | Lecture |
ISignatureClient.Get* / Search* | ✅ | N/A | Lectures |
ISignatureClient.DeleteAdhesionByIdUnique | ✅ | IdUnique | 2e appel = no-op ou 404 |
ICampagnesClient.AddCampagne | ⚠️ | Pas de clé externe | Renvoyer crée une nouvelle campagne (Guid différent) |
ICampagnesClient.UpdateCampagne / Publier / Depublier | ✅ | Guid campagne | |
IQuestionsClient.AddQuestion / AddOption | ⚠️ | Pas de clé externe | Renvoyer crée un duplicata |
IQuestionsClient.Update* / Delete* | ✅ | ID natif | |
IQuestionsClient.ReorderQuestions | ✅ | campagneId | Remplacement complet de l'ordre |
IVotantsClient.UpsertVotant | ✅ si VotantId fourni | VotantId | Sans VotantId, crée toujours un nouveau votant (⚠️) |
IVotantsClient.RadierVotant / DeleteVotant | ✅ | votantId | |
IVotantsClient.MettreAJourDroitsDeVote | ✅ | Set de VotantIds | N'écrit que les deltas |
IListeElectoraleClient.EnvoyerJeton | ✅ | (campagneId, votantId) | Renvoie le courriel à chaque appel — idempotent côté état, pas côté mail |
IListeElectoraleClient.MettreAJourDroitDeVote | ✅ | (campagneId, votantId) | |
ICourrielClient.Envoyer* | ❌ | — | Chaque appel envoie un courriel supplémentaire |
IConciliationClient.Confirmer | ⚠️ | idUnique | 1er appel crée l'employé ; 2e retourne StatutInvalidePourAccepter |
IConciliationClient.Rejeter | ⚠️ | idUnique | Idem |
Opérations non idempotentes à surveiller
-
AddCampagne— pas d'IdExternecôté DTO. Si votre retry crée une deuxième campagne identique, vous aurez deux Guids différents à gérer. Mitigation : stocker le Guid retourné immédiatement, ne re-tenter que sur timeout sans réponse et vérifier viaGetAllCampagnes(filtre par Titre)avant re-tentative. -
AddQuestion/AddOption— même problème. Mitigation : construire la campagne complète en une transaction unique de votre côté, ne retry que le bloc entier. -
UpsertVotantsansVotantId— crée un nouveau votant à chaque appel. Avant retry, appelezGetVotantsByCampagne(filtre par Courriel)pour détecter les doublons. Ou pré-générezVotantIdde votre côté si votre source fournit un ID stable. -
ICourrielClient— par définition, envoyer deux fois = deux courriels. Pas de dédup serveur. Mitigation : cache local de votre côté avec TTL. -
Confirmer/Rejeter— le 2e appel échoue avecStatutInvalidePour*, pas silencieusement. Traitez ces codes comme « déjà fait » et continuez.
2. Stratégie de retry côté consommateur
Politique par type d'erreur
| Classe d'erreur | Rejouer ? | Stratégie |
|---|---|---|
MCM.CantReachApi (réseau, timeout) | ✅ | Backoff exponentiel, max 3-5 tentatives |
MCM.ClientError.500 | ✅ | Backoff, max 3 tentatives |
MCM.ClientError.502/503/504 | ✅ | Backoff, max 3 tentatives |
MCM.ClientError.429 | ✅ | Respecter Retry-After si présent, sinon backoff |
MCM.ClientError.400 (validation) | ❌ | Corriger la charge utile |
MCM.ClientError.401 | ❌ | Clé invalide — ne rejouez pas |
MCM.ClientError.403 | ❌ | Module manquant — ne rejouez pas |
MCM.ClientError.404 | ❌ | Dépend du contexte (si suppression, considérer comme succès) |
MCM.ClientError.409 (conflit) | ❌ en général | Dépend du code métier — lire, corriger, re-tenter |
DemandeAdhesion.StatutInvalidePour* | ⚠️ | Considérer comme « déjà traité » |
Implémentation
Le SDK n'embarque pas de retry automatique — c'est à vous. Exemple avec Polly :
using Polly;
var retryPolicy = Policy
.HandleResult<ErrorOr<B2BSyncEmployesResultV2>>(r => r.IsError && IsTransient(r.FirstError))
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)),
onRetry: (result, delay, attempt, _) =>
logger.LogWarning("Retry {Attempt} after {Delay}s: {Err}",
attempt, delay.TotalSeconds, result.Result.FirstError.Description));
var result = await retryPolicy.ExecuteAsync(() =>
syncClient.Sync(syndicats, employes, []));
static bool IsTransient(Error err) =>
err.Code == "MCM.CantReachApi" ||
err.Code.StartsWith("MCM.ClientError.5") ||
err.Code == "MCM.ClientError.429";
Backoff recommandé
- Base : 1 seconde
- Facteur : 2 (exponentiel)
- Max : 30 secondes par tentative individuelle
- Jitter : ±20 % pour éviter les tempêtes de retry synchronisés
- Total tentatives : 3-5 selon la criticité