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.Sync✅IdentifiantExterne par entitéRe-exécuter produit le même état final
IEmployesClient.AddEmploye / UpdateEmploye✅ (IdExterne)IdExterneRappeler fait une mise à jour non destructive
IEmployesClient.DeleteEmploye✅IdExterne2e appel = no-op (déjà supprimé)
IEmployesClient.UpdateCourriels✅IdExterneInvalides remontent dans la liste B2BError
IEmployeursClient.*, ISyndicatsClient.*, IEmploisClient.*✅IdentifiantExterneMême pattern
IChampUtilisateurClient.SetEmployeurs / SetChoix✅Nom du champRemplacement complet
IObjetsConsentementClient.*✅IdentifiantExterne
IConsentementClient.Get* (lecture)✅N/ALectures toujours idempotentes
IFormulaireClient.GetAllFormulaires✅N/ALecture
ISignatureClient.Get* / Search*✅N/ALectures
ISignatureClient.DeleteAdhesionByIdUnique✅IdUnique2e appel = no-op ou 404
ICampagnesClient.AddCampagne⚠️Pas de clé externeRenvoyer crée une nouvelle campagne (Guid différent)
ICampagnesClient.UpdateCampagne / Publier / Depublier✅Guid campagne
IQuestionsClient.AddQuestion / AddOption⚠️Pas de clé externeRenvoyer crée un duplicata
IQuestionsClient.Update* / Delete*✅ID natif
IQuestionsClient.ReorderQuestions✅campagneIdRemplacement complet de l'ordre
IVotantsClient.UpsertVotant✅ si VotantId fourniVotantIdSans VotantId, crée toujours un nouveau votant (⚠️)
IVotantsClient.RadierVotant / DeleteVotant✅votantId
IVotantsClient.MettreAJourDroitsDeVote✅Set 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.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é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​