{"_id":"@astratra/resilience","_rev":"3-134540bd94ee467b952eab16fe2f74a1","name":"@astratra/resilience","dist-tags":{"latest":"1.0.0"},"versions":{"0.1.0":{"name":"@astratra/resilience","version":"0.1.0","keywords":["astratra","circuit-breaker","cache","retry","resilience"],"license":"MIT","_id":"@astratra/resilience@0.1.0","maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"dist":{"shasum":"61686f5ab4e6f88ad0fc5e6efa8abd94acfeef0c","tarball":"https://registry.npmjs.org/@astratra/resilience/-/resilience-0.1.0.tgz","fileCount":8,"integrity":"sha512-BG5x2X7E7GdxHqZ913qrEOPXCcP1v7AeD9YR2WVsUnhI6wO8TK5n3IHa+/KZsRb4rhHEzJdjL3d32BEzXFlgSw==","signatures":[{"sig":"MEQCIEe+7CkNghVjEXG79LqPj1NZBFmSWbIkzXoirZrWAouhAiBfXTUrI9MmcnHzfPYdTYw3sONUU59BPobFkLB0mhEOoQ==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":20098},"jest":{"testMatch":["**/__tests__/**/*.test.js"]},"main":"src/index.js","type":"commonjs","types":"src/index.d.ts","engines":{"node":">=20"},"gitHead":"a3f7c737294561ba8651e14d507a18b41e199999","scripts":{"test":"jest"},"_npmUser":{"name":"emch99","email":"emchkongo@gmail.com"},"_npmVersion":"11.13.0","description":"Circuit breaker with a single half-open probe, a TTL cache that degrades instead of failing, and retry with jitter.","directories":{},"_nodeVersion":"24.16.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"jest":"30.4.2"},"_npmOperationalInternal":{"tmp":"tmp/resilience_0.1.0_1787666109695_0.4651388332152686","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@astratra/resilience","version":"0.2.0","keywords":["astratra","circuit-breaker","cache","retry","resilience","job-lock","cluster","timers"],"license":"MIT","_id":"@astratra/resilience@0.2.0","maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"dist":{"shasum":"96bacffb50537bd9ddf35b1fd3cfe5c91951ac5d","tarball":"https://registry.npmjs.org/@astratra/resilience/-/resilience-0.2.0.tgz","fileCount":10,"integrity":"sha512-x4iKatk/aPox/D7jpbFo4PJPWfijY6U1GdXiB/pbUY47BCQGYCmF3IHHJ+Zzgywot8rkAxTWdbO1UyDU2bGsdQ==","signatures":[{"sig":"MEYCIQD/0dRHc94i7FTMnxIzOf4JRhJDJ2sYvmhtmaxoBrhaFwIhAOZpTbJVaK0a61DukIuDcTONTvq5YKuIHGubSbzxn3Y1","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"sig":"MEUCICjdc1r722tE2E5xpgxuR43sbeHc/Ic2bzP31JCT5eeqAiEAiC3a9MvaQTnOHxVWZvizhH0iMvu7h+oU+TbTvixfS14=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":38511},"jest":{"testMatch":["**/__tests__/**/*.test.js"]},"main":"src/index.js","type":"commonjs","types":"src/index.d.ts","engines":{"node":">=20"},"gitHead":"a6eda5bf009d1d47caff00955940c6d2303bee2c","scripts":{"test":"jest"},"_npmUser":{"name":"emch99","email":"emchkongo@gmail.com"},"_npmVersion":"11.13.0","description":"Circuit breaker with a single half-open probe, a TTL cache that degrades instead of failing, retry with jitter, a cluster-wide job lock and a background timer registry.","directories":{},"_nodeVersion":"24.16.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"jest":"30.4.2"},"_npmOperationalInternal":{"tmp":"tmp/resilience_0.2.0_1789991421981_0.6005744018552424","host":"s3://npm-registry-packages-npm-production"}},"1.0.0":{"_id":"@astratra/resilience@1.0.0","dist":{"shasum":"fc13ddb4c33fe607cce2b2cec91957e262a69b8b","tarball":"https://registry.npmjs.org/@astratra/resilience/-/resilience-1.0.0.tgz","fileCount":10,"integrity":"sha512-iTbmH9OBUF/Tq6hPiTtjcgddpKHwbp6eN45wRpx/teH23DVvYYobDxNualsw245POjB2/+Q+zsvrlh+evxFJJg==","signatures":[{"sig":"MEUCIHvwxe4qGAFOA7vaoCF+Hu2DzHj9Ql12xz/5Ol+XIz0tAiEA8OkFiPbmtTRZ3xxWLj6sCJCj+/XWsFtT3awVkb3OygY=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIHV9yH6GFG3/izNNqquYf/evLMMupKZkEZYNMKOZIw6HAiA5ff3GeeSE82iKB08N12lmWRFhrAUlVWauovIMm+MWqQ=="}],"unpackedSize":38511},"jest":{"testMatch":["**/__tests__/**/*.test.js"]},"main":"src/index.js","name":"@astratra/resilience","type":"commonjs","types":"src/index.d.ts","engines":{"node":">=20"},"gitHead":"28e680aaed8dfdf7c5aa00c7dfbde74ab8e377de","license":"MIT","scripts":{"test":"jest"},"version":"1.0.0","_npmUser":{"name":"emch99","email":"emchkongo@gmail.com"},"keywords":["astratra","circuit-breaker","cache","retry","resilience","job-lock","cluster","timers"],"_npmVersion":"11.13.0","description":"Circuit breaker with a single half-open probe, a TTL cache that degrades instead of failing, retry with jitter, a cluster-wide job lock and a background timer registry.","directories":{},"maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"_nodeVersion":"24.16.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"jest":"30.4.2"},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/resilience_1.0.0_1790882290375_0.8926504588819295"}}},"time":{"created":"2026-08-25T13:55:09.538Z","modified":"2026-10-01T19:18:10.662Z","0.1.0":"2026-08-25T13:55:09.840Z","0.2.0":"2026-09-21T11:50:22.064Z","1.0.0":"2026-10-01T19:18:10.495Z"},"license":"MIT","keywords":["astratra","circuit-breaker","cache","retry","resilience","job-lock","cluster","timers"],"description":"Circuit breaker with a single half-open probe, a TTL cache that degrades instead of failing, retry with jitter, a cluster-wide job lock and a background timer registry.","maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"readme":"# @astratra/resilience\n\nTrois protections contre les dépendances qui tombent : le disjoncteur, le cache\nqui se dégrade au lieu d'échouer, et la relance avec brouillage. Et deux\nprotections pour ce qui tourne en arrière-plan : le verrou de tâches en grappe\net le registre des minuteurs de fond.\n\nAucune dépendance à l'exécution.\n\n## Le disjoncteur\n\nUne dépendance en panne n'échoue pas simplement — elle échoue **lentement**.\nChaque appel attend son délai d'expiration, les requêtes s'empilent derrière,\net une API tierce morte emporte ton propre service avec elle. Le disjoncteur\nremplace l'échec lent par un échec rapide : après assez d'erreurs consécutives\nil **s'ouvre**, et les appelants reçoivent un refus immédiat au lieu d'une\nsocket qui pend.\n\n```js\nconst { createCircuitBreaker } = require('@astratra/resilience');\n\nconst iaBreaker = createCircuitBreaker({\n  name: 'service-ia',\n  failureThreshold: 3,\n  recoveryMs: 20_000,\n  // Un 404 est une réponse, pas une panne : compter les erreurs métier\n  // ouvrirait le circuit sur un service en pleine santé.\n  isFailure: (e) => !e.statusCode || e.statusCode >= 500,\n  onStateChange: ({ name, from, to }) => logger.warn(`${name}: ${from} → ${to}`),\n});\n\nconst reponse = await iaBreaker.call(() => axios.post(url, payload));\n```\n\n### La sonde est singulière, et c'est le point\n\nPassé le délai, le circuit passe en semi-ouvert et laisse passer **un seul**\nappel pour tâter le terrain. Laisser passer tous les appelants en attente\n« pour tester » signifie qu'à la seconde où le délai expire, un troupeau\nentier frappe un service probablement encore à genoux — l'engorgement que le\ndisjoncteur devait empêcher, livré à l'heure.\n\nUne sonde qui échoue rouvre **immédiatement** : une mauvaise réponse suffit\ncomme preuve, pas besoin de recompter jusqu'au seuil. Et le délai repart de\ncet échec-là.\n\n`reset()` referme la porte sans attendre — pour l'opérateur qui vient de\ndéployer le correctif.\n\n## Le cache\n\nLe propre d'un cache est que son absence soit survivable. Donc rien ne lève\njamais ici : un store cassé se lit comme une absence, une écriture ratée est\njournalisée et abandonnée. Dès qu'un cache peut faire tomber une requête, il\nest devenu une dépendance — le contraire de son travail.\n\n```js\nconst { createCache } = require('@astratra/resilience');\n\nconst cache = createCache({ prefix: 'stats', ttlSeconds: 300 });\n\n// L'idiome cache-aside, écrit une fois :\nconst stats = await cache.remember('dashboard:s9', () => computeDashboard(schoolId));\n```\n\n### `remember` protège de la ruée\n\nÀ la seconde où une entrée populaire expire, chaque requête partirait sinon\nfrapper la base avec la même question. Sous concurrence, **un seul** calcul\ntourne par clé — les appelants parallèles attendent la même promesse en vol.\n\nDeux autres choix testés : `null` n'est pas mis en cache (« rien » aujourd'hui\nne doit pas masquer « quelque chose » pendant cinq minutes), et l'éviction\nmémoire retire le **moins récemment utilisé**, pas la plus vieille écriture.\n\nLe store est injecté — Redis en production, `createMemoryCacheStore()` partout\nailleurs, même code.\n\n## La relance\n\nDeux règles portent toute la valeur :\n\n**Le recul avec brouillage.** Relancer immédiatement martèle un service déjà en\ndifficulté ; relancer à intervalle fixe synchronise tous les clients en vagues\nqui arrivent ensemble. Un délai croissant à composante aléatoire étale la\ncharge.\n\n**Ne relancer que ce qui peut changer.** Un délai dépassé peut réussir la\nprochaine fois ; un 400 non — la requête est fausse, et l'envoyer trois fois ne\nla rend pas plus juste. Par défaut, tout statut sous 500 n'est **pas** relancé.\n\n```js\nconst { retry } = require('@astratra/resilience');\n\nconst data = await retry(() => fetchFromProvider(id), {\n  attempts: 3,\n  baseDelayMs: 200,\n  onRetry: (e, attempt, delay) => logger.warn(`essai ${attempt} raté, reprise dans ${delay}ms`),\n});\n```\n\nEt le rappel qui compte : ce qui n'est **pas idempotent** — un paiement, un\nenvoi — ne se relance pas aveuglément. Le premier essai a peut-être réussi sans\nque tu entendes la réponse. `shouldRetry` est là pour le dire.\n\n## Les trois ensemble\n\n```js\nconst donnee = await cache.remember(cle, () =>\n  iaBreaker.call(() => retry(() => provider.ask(prompt)))\n);\n```\n\nLe cache absorbe, le disjoncteur coupe, la relance lisse.\n\n## Le verrou de tâches en grappe\n\nEn grappe (PM2 en mode cluster, plusieurs conteneurs), chaque instance démarre\nles **mêmes** tâches planifiées. La grappe répartit les requêtes ; une tâche\nrépétée dans quatre processus n'est pas partagée, elle est dupliquée : quatre\ne-mails de relance identiques à la même seconde, quatre sauvegardes lancées en\nmême temps. Un garde en mémoire (« déjà envoyé », `isProcessing`) n'y peut\nrien : les quatre le lisent avant qu'aucune ne l'écrive.\n\nLe verrou vit dans un stockage que toutes les instances partagent, et le\nprendre est une écriture atomique.\n\n```js\nconst { createJobLock, createRedisLockStore } = require('@astratra/resilience');\n\nconst jobLock = createJobLock({\n  store: createRedisLockStore({ command: (args) => redis.sendCommand(args) }),\n});\n\nsetInterval(() => jobLock.run('relances', 55 * 60_000, envoyerLesRelances), 60 * 60_000);\n```\n\nTrois adaptateurs : `createRedisLockStore` (`SET NX PX`, expiration par Redis\nlui-même), `createMongoLockStore(collection)` (ajoute un index TTL sur\n`expiresAt`), `createMemoryLockStore()` pour les tests et un processus unique.\n\nCe qui est garanti et testé :\n\n- **une seule** instance obtient le verrou, et la même ne l'obtient pas deux fois ;\n- il **expire** : une instance morte ne bloque pas la tâche pour toujours ;\n- seul le **propriétaire** peut le libérer : une instance en retard ne supprime\n  pas le verrou qu'une autre vient de prendre après expiration ;\n- une panne du stockage **lève** : un doublon se voit, une tâche qui ne tourne\n  plus en silence non.\n\nPar défaut, `run` ne libère **pas** le verrou à la fin : il expire. Choisis une\ndurée un peu inférieure à l'intervalle. Libérer à la fin laisserait une instance\ndont le minuteur part quelques secondes plus tard refaire le même tour. Passe\n`{ release: true }` seulement si rejouer tout de suite est sans conséquence.\n\nL'identité par défaut combine hôte, pid et un aléa : un pid seul ne suffit pas,\nchaque conteneur tourne en pid 1.\n\n## Le registre des minuteurs de fond\n\n```js\nconst { createTimerRegistry } = require('@astratra/resilience');\n\nconst minuteurs = createTimerRegistry();\n\nminuteurs.every(60_000, nettoyer, { key: 'nettoyage' });\nminuteurs.after(5_000, prechauffer);\n\nprocess.on('SIGTERM', () => minuteurs.stopAll());\n```\n\nTrois défauts qu'il corrige :\n\n- un minuteur posé au chargement d'un module, sans référence gardée, ne peut\n  plus jamais être arrêté — et empêche Node de sortir ;\n- `unref()` n'est pas un arrêt. Il empêche le minuteur de **retenir** le\n  processus, pas de se **déclencher** : un minuteur `unref` qui part après le\n  démontage de Jest exécute son code dans un monde détruit. Le registre fait\n  les deux, `unref` à l'inscription et `clear` à l'arrêt ;\n- `clear` n'arrête pas un travail **déjà parti**. `isStopped()` permet à ce\n  travail de renoncer avant son prochain effet de bord.\n\nUne même `key` remplace le minuteur précédent : un `start()` appelé deux fois\n(rechargement à chaud) ne double pas la tâche. Un `after` déjà parti est\noublié tout seul. Inscrire un minuteur rouvre le registre, pour qu'un service\nqui redémarre n'hérite pas de l'extinction précédente.\n\n## Tests\n\n```bash\nnpm test --workspace @astratra/resilience\n```\n","readmeFilename":"README.md"}