Skip to content

Améliore la gestion des appels vers l'INSEE (DPP-86) - #1770

Closed
jbfeldis wants to merge 6 commits into
developfrom
feature/dpp-86-insee-circuit-breaker-et-rattrapage
Closed

jbfeldis wants to merge 6 commits into
developfrom
feature/dpp-86-insee-circuit-breaker-et-rattrapage

Conversation

@jbfeldis

Copy link
Copy Markdown
Contributor

DRAFT 🙃

@linear

linear Bot commented Sep 21, 2026

Copy link
Copy Markdown

DPP-86

@jbfeldis
jbfeldis force-pushed the feature/dpp-86-insee-circuit-breaker-et-rattrapage branch 4 times, most recently from 39f1c1d to 55cc72c Compare September 22, 2026 08:49
L’interrupteur manuel suppose que quelqu’un regarde. En production, 1043 erreurs
sont parties en six heures avant qu’on s’en aperçoive.

`INSEECallsPause` coupe tout seul sur un 400 ou un 401 du jeton et sur un 401 de
l’API Sirene — les trois cas où l’INSEE nous refuse, et où réessayer ne peut
qu’alimenter le compteur de verrouillage. Le drapeau vit dans Redis, donc la
coupure vaut pour tous les serveurs web et tous les workers à la fois.

Il est armé par le client pour la même raison que l’interrupteur : c’est le seul
point qui couvre les jobs déjà enfilés et le chemin synchrone.

Le garde est fail-closed. Kredis avale les erreurs Redis et renvoie `nil`, ce qui
rend un `nil` indistinguable d’un « non armé » ; `failsafe(returning:)` rétablit
la distinction, et `paused?` répond `true` quand Redis ne répond pas. L’inverse
rejouerait l’incident. `pause!` relit le drapeau après écriture et alerte si
l’armement n’a pas pris.
Le coupe-circuit arrête l’hémorragie, il n’explique pas le volume. 1043 erreurs
en six heures venaient de trois multiplicateurs.

`INSEEAPIAuthentication#http_connection` empilait deux middlewares `:retry`,
celui de la classe abstraite et le sien : jusqu’à 36 `POST token` par jeton
obtenu. `Faraday::ClientError` figurait dans les exceptions retentées, or
`Faraday::BadRequestError` en hérite — le 400 observé en production était donc
rejoué alors qu’il ne peut pas aboutir. Et le jeton n’était pas mis en cache, le
lambda `Bearer` instanciant un client neuf à chaque tentative de chaque requête.

Un seul middleware désormais, et seules les pannes de transport sont rejouées
par le client ; les 5xx et les réponses illisibles le sont par ActiveJob, avec
un backoff. Le cache du jeton est par process et sans verrou : le jeton et son
expiration voyagent dans le même objet, et sous le GVL une affectation d’ivar
est atomique. Au pire quelques threads renouvellent ensemble à l’expiration,
et tous les jetons obtenus sont valides.

`conn.response :json` ne parse que sur le bon Content-Type. Une page HTML
renvoyée en 200 faisait que `payload['access_token']` valait la sous-chaîne
`"access_token"`, mise en cache cinq minutes : chaque appel Sirene partait
ensuite en 401, donc en coupure de 6 h déclenchée par une réponse mal typée.
La réponse doit maintenant être un objet JSON portant un `access_token`.
Le lissage par lots du rattrapage est une discipline, pas une garantie : rien
n’empêche un autre chemin d’enfiler mille jobs d’un coup, et à l’échéance d’un
lot GoodJob dépile aussi vite que son pool le permet.

`UpdateOrganizationINSEEPayloadJob` passe sur la queue `insee` à concurrence 1.
Le débit devient une propriété de configuration : aucun incident ne peut plus
produire des milliers d’appels concurrents.

L’exclusion `-insee` pour le second pool est nécessaire — `*` inclurait `insee`
et le pool général y piocherait aussi, la sérialisation ne tiendrait pas. Et le
`ENV.fetch` préserve la priorité du déploiement, parce que dans GoodJob
l’initializer prime sur `GOOD_JOB_QUEUES`.

`RefreshStaleOrganizationsINSEEPayloadJob` reste sur la queue par défaut : il ne
fait aucun appel INSEE et se bloquerait derrière l’unique worker.

Deux limites : un worker unique plafonne autour de 30 appels/minute à cause du
timeout de 2 s, ce qui tombe sur le quota INSEE par coïncidence et non par
conception ; et la queue ne protège pas le chemin synchrone, qui n’y passe pas.
`FindOrCreateOrganization` exécute le job en ligne, pendant la requête HTTP.
Un 400 non rattrapé y remontait en 500 à un utilisateur qui crée simplement une
organisation : notre incident devenait le sien.

Seul `EntityNotFoundError` continue de refuser, parce que l’information est
certaine — ce SIRET n’existe pas. Une indisponibilité INSEE signifie « on ne
sait pas » : l’organisation est créée et le payload enfilé pour plus tard. On
l’obtiendra de toute façon quelques minutes après.

L’organisation vit sans payload jusque-là. Rien ne casse, la lecture est
défensive partout, mais trois effets persistent : `legal_category` tombe à
`:other`, la recherche par nom ne la trouve pas, et HubEE reçoit des champs
vides. Le troisième est le plus dur — c’est lui qui transforme « incomplet un
moment » en « donnée fausse chez un partenaire ».
Les organisations à rattraper sont déjà connues : le job n’écrit
`last_insee_payload_updated_at` qu’en cas de succès, donc un appel
court-circuité, échoué ou jamais tenté laisse la colonne inchangée. Un registre
parallèle serait une seconde source de vérité qui diverge au premier incident
concurrent ; la colonne, elle, survit à un redémarrage, à un vidage Redis et à
une purge de queue.

Le piège est le débit, pas la liste. Relancer tout d’un coup après la coupure
referait exactement ce qui a verrouillé le compte, donc le job enfile par lots
étalés, et ne fait rien tant que les appels sont coupés. Les trois paramètres de
lissage se règlent en base, sans déploiement.

Le rejeu est idempotent — `return if last_update_within_24h?` — donc on peut
relancer large sans crainte de double travail.

Le job n’est volontairement planifié nulle part : le débit que l’INSEE tolère
reste à mesurer avant de le mettre au cron.
`populate_codes_insee_and_entity` était un `after_commit on: :create` : il ne
tournait qu’une fois, à la création de la demande. Une demande CNOUS créée sans
payload INSEE basculait en saisie manuelle et y restait définitivement, même
après que le rattrapage ait rempli l’organisation. Autrement dit, rattraper
l’organisation ne réparait pas les demandes déjà créées.

Le calcul devient rejouable, et `UpdateOrganizationINSEEPayloadJob` enfile
`PopulateDraftRequestsGeographicPerimeterJob` dès que `insee_payload` a
réellement changé. Seules les demandes encore en brouillon sont rejouées :
corriger le périmètre d’une demande déjà soumise est une décision métier, pas
une correction technique.

Les dégradations silencieuses sont tracées. Trois chemins menaient au même
`return` muet ; deux sont désormais signalés à Sentry, le troisième reste
silencieux parce qu’il est légitime — une catégorie juridique qui n’est ni
commune, ni département, ni région n’a pas de périmètre automatique.

Le cas « API Géo en panne » produisait déjà exactement le même effet avant
l’incident INSEE, et personne ne l’avait jamais vu.
@jbfeldis
jbfeldis force-pushed the feature/dpp-86-insee-circuit-breaker-et-rattrapage branch from 55cc72c to 7aa7ce2 Compare September 22, 2026 10:03
@jbfeldis

Copy link
Copy Markdown
Contributor Author

Remplacée par un train de PR empilées, plus petites et relisables séparément : #1772 → #1773 → #1774 → #1775 → #1776. Le contenu est identique, redécoupé.

@jbfeldis jbfeldis closed this Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant