Aller au contenu principal

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érationIdempotent ?Clé d'idempotenceNotes
ISyncClient.SyncIdentifiantExterne par entitéRe-exécuter produit le même état final
IEmployesClient.AddEmploye / UpdateEmploye✅ (IdExterne)IdExterneRappeler fait une mise à jour non destructive
IEmployesClient.DeleteEmployeIdExterne2e appel = no-op (déjà supprimé)
IEmployesClient.UpdateCourrielsIdExterneInvalides remontent dans la liste B2BError
IEmployeursClient.*, ISyndicatsClient.*, IEmploisClient.*IdentifiantExterneMême pattern
IChampUtilisateurClient.SetEmployeurs / SetChoixNom du champRemplacement complet
IObjetsConsentementClient.*IdentifiantExterne
IConsentementClient.Get* (lecture)N/ALectures toujours idempotentes
IFormulaireClient.GetAllFormulairesN/ALecture
ISignatureClient.Get* / Search*N/ALectures
ISignatureClient.DeleteAdhesionByIdUniqueIdUnique2e appel = no-op ou 404
ICampagnesClient.AddCampagne⚠️Pas de clé externeRenvoyer crée une nouvelle campagne (Guid différent)
ICampagnesClient.UpdateCampagne / Publier / DepublierGuid campagne
IQuestionsClient.AddQuestion / AddOption⚠️Pas de clé externeRenvoyer crée un duplicata
IQuestionsClient.Update* / Delete*ID natif
IQuestionsClient.ReorderQuestionscampagneIdRemplacement complet de l'ordre
IVotantsClient.UpsertVotant✅ si VotantId fourniVotantIdSans VotantId, crée toujours un nouveau votant (⚠️)
IVotantsClient.RadierVotant / DeleteVotantvotantId
IVotantsClient.MettreAJourDroitsDeVoteSet de VotantIdsN'é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⚠️idUnique1er appel crée l'employé ; 2e retourne StatutInvalidePourAccepter
IConciliationClient.Rejeter⚠️idUniqueIdem

Opérations non idempotentes à surveiller

  1. AddCampagne — pas d'IdExterne cô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 via GetAllCampagnes(filtre par Titre) avant re-tentative.

  2. 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.

  3. UpsertVotant sans VotantId — crée un nouveau votant à chaque appel. Avant retry, appelez GetVotantsByCampagne(filtre par Courriel) pour détecter les doublons. Ou pré-générez VotantId de votre côté si votre source fournit un ID stable.

  4. ICourrielClient — par définition, envoyer deux fois = deux courriels. Pas de dédup serveur. Mitigation : cache local de votre côté avec TTL.

  5. Confirmer / Rejeter — le 2e appel échoue avec StatutInvalidePour*, 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'erreurRejouer ?Stratégie
MCM.CantReachApi (réseau, timeout)Backoff exponentiel, max 3-5 tentatives
MCM.ClientError.500Backoff, max 3 tentatives
MCM.ClientError.502/503/504Backoff, max 3 tentatives
MCM.ClientError.429Respecter Retry-After si présent, sinon backoff
MCM.ClientError.400 (validation)Corriger la charge utile
MCM.ClientError.401Clé invalide — ne rejouez pas
MCM.ClientError.403Module manquant — ne rejouez pas
MCM.ClientError.404Dépend du contexte (si suppression, considérer comme succès)
MCM.ClientError.409 (conflit)❌ en généralDé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é

3. Timeout côté client

HttpClient par défaut : 100 secondes (ASP.NET Core). Pour un Sync de 10 000 employés, c'est parfois court. Surchargez :

builder.Services.AddMcmApiClient(builder.Configuration);

// Surcharger le timeout sur le HttpClient nommé de ISyncClient
builder.Services.Configure<HttpClientFactoryOptions>(nameof(ISyncClient), options =>
{
options.HttpClientActions.Add(client => client.Timeout = TimeSpan.FromMinutes(5));
});

Les opérations bulk (Sync, MettreAJourDroitsDeVote) peuvent prendre plusieurs minutes à grosse volumétrie. Mesurez en staging avant de fixer le timeout prod.


4. Idempotence des webhooks entrants

Les webhooks MCM sont rejoués après 5xx ou timeout, avec le même deliveryId. Votre handler doit déduper.

Voir webhooks.md §6 pour le pattern détaillé.

Rappel minimal :

try
{
await db.InsertWebhookDelivery(payload.DeliveryId, ct); // UNIQUE constraint
}
catch (DbUpdateException) when (IsUniqueViolation())
{
return; // déjà traité
}

await ProcessEvent(payload.Data, ct);

5. Safe-retry patterns par domaine

Sync d'employés

// Safe to retry — les IdExterne stabilisent l'écriture
await retryPolicy.ExecuteAsync(() => syncClient.Sync(syndicats, employes, []));

Création d'une campagne + questions

// Non sûr — AddCampagne et AddQuestion créent des doublons
// Enveloppe manuelle avec lookup avant retry
async Task<Guid> CreerCampagneIdempotente(string titre, ...)
{
var existing = await campagnesClient.GetAllCampagnes(new() { Titre = titre });
if (!existing.IsError && existing.Value.Any(c => c.Titre == titre))
return existing.Value.First(c => c.Titre == titre).Id;

var result = await campagnesClient.AddCampagne(new B2BCreateCampagneDto { Titre = titre, ... });
if (result.IsError) throw new Exception(result.FirstError.Description);
return result.Value;
}

Envoi de jeton

// Idempotent côté MCM mais envoie un mail à chaque appel
// Tracker de votre côté pour ne pas spammer
if (!await db.JetonAlreadySent(campagneId, votantId))
{
await listeElectoraleClient.EnvoyerJeton(campagneId, votantId);
await db.MarkJetonSent(campagneId, votantId);
}

Conciliation

var r = await conciliationClient.Confirmer(demandeId, data);
if (r.IsError && r.FirstError.Code == "DemandeAdhesion.StatutInvalidePourAccepter")
{
// Déjà traitée — lire l'employé créé par un sync précédent
logger.LogInformation("Demande {Id} déjà confirmée", demandeId);
}

6. Anti-patterns à éviter

  • Ne pas rejouer aveuglément sur 400 / 401 / 403 — ce sont des erreurs définitives.
  • Ne pas présumer qu'un timeout = échec. L'écriture peut avoir réussi côté serveur ; vérifiez avant de retenter.
  • Ne pas multiplier les AddCampagne / AddQuestion sans dédup — ça pollue votre client MCM avec des doublons invisibles.
  • Ne pas bloquer toute la file sur un seul retry infini. Limitez à N tentatives, puis mettez l'élément en file d'attente pour investigation humaine.
  • Ne pas faire confiance à un 200 pour signifier succès métier — vérifiez ErrorOr<T>.IsError même quand l'HTTP est vert.

7. Observabilité

Pour diagnostiquer les retries :

  • Loggez Error.Code, Error.Metadata["StatusCode"], Error.Metadata["Url"].
  • Comptez les retries par endpoint (métrique custom).
  • Alertez si un endpoint a un taux de retry > 10 % (signe de dégradation côté MCM ou de validation chronique de votre côté).

Exemple :

var errorMetadata = result.FirstError.Metadata;
logger.LogWarning("B2B retry {Code} on {Url} [{Status}]: {Description}",
result.FirstError.Code,
errorMetadata.GetValueOrDefault("Url"),
errorMetadata.GetValueOrDefault("StatusCode"),
result.FirstError.Description);

Voir aussi