Conversation
jbfeldis
force-pushed
the
feature/dpp-86-insee-circuit-breaker-et-rattrapage
branch
4 times, most recently
from
September 22, 2026 08:49
39f1c1d to
55cc72c
Compare
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
force-pushed
the
feature/dpp-86-insee-circuit-breaker-et-rattrapage
branch
from
September 22, 2026 10:03
55cc72c to
7aa7ce2
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
DRAFT 🙃