@nodefony/http 10.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +544 -0
- package/README.md +77 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateParam.js +8 -0
- package/dist/index.js +108 -0
- package/dist/nodefony/command/assetsPublishCommand.js +102 -0
- package/dist/nodefony/command/certificatesCommand.js +47 -0
- package/dist/nodefony/command/networkCommand.js +27 -0
- package/dist/nodefony/command/proxyGenerateCommand.js +66 -0
- package/dist/nodefony/config/config.js +335 -0
- package/dist/nodefony/config/defineModuleConfig.js +93 -0
- package/dist/nodefony/interfaces/IContext.js +1 -0
- package/dist/nodefony/interfaces/ICookie.js +1 -0
- package/dist/nodefony/interfaces/IErrorRenderer.js +1 -0
- package/dist/nodefony/interfaces/IHttpConfig.js +1 -0
- package/dist/nodefony/interfaces/IHttpKernel.js +1 -0
- package/dist/nodefony/interfaces/IRequest.js +1 -0
- package/dist/nodefony/interfaces/IRequestLogger.js +1 -0
- package/dist/nodefony/interfaces/IResponse.js +1 -0
- package/dist/nodefony/interfaces/ISession.js +1 -0
- package/dist/nodefony/interfaces/IUpload.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/service/HttpAdminApi.js +376 -0
- package/dist/nodefony/service/ProfilerAdminApi.js +73 -0
- package/dist/nodefony/service/audit-logger.js +159 -0
- package/dist/nodefony/service/certificates.js +545 -0
- package/dist/nodefony/service/error-renderer.js +320 -0
- package/dist/nodefony/service/http-kernel.js +948 -0
- package/dist/nodefony/service/pretty-request-logger.js +72 -0
- package/dist/nodefony/service/request-logger.js +54 -0
- package/dist/nodefony/service/servers/clientError.js +20 -0
- package/dist/nodefony/service/servers/server-http.js +135 -0
- package/dist/nodefony/service/servers/server-https.js +204 -0
- package/dist/nodefony/service/servers/server-static.js +192 -0
- package/dist/nodefony/service/servers/server-websocket-secure.js +104 -0
- package/dist/nodefony/service/servers/server-websocket.js +104 -0
- package/dist/nodefony/service/servers/serverShutdown.js +31 -0
- package/dist/nodefony/service/servers/wsHeartbeat.js +64 -0
- package/dist/nodefony/service/sessions/sessions-service.js +580 -0
- package/dist/nodefony/service/trace.js +72 -0
- package/dist/nodefony/service/upload/upload-service.js +171 -0
- package/dist/nodefony/src/assets/collectAssets.js +34 -0
- package/dist/nodefony/src/assets/prebuiltUi.js +125 -0
- package/dist/nodefony/src/context/Context.js +415 -0
- package/dist/nodefony/src/context/domainMatcher.js +88 -0
- package/dist/nodefony/src/context/forwarded.js +185 -0
- package/dist/nodefony/src/context/http/HttpContext.js +309 -0
- package/dist/nodefony/src/context/http/Request.js +543 -0
- package/dist/nodefony/src/context/http/Response.js +368 -0
- package/dist/nodefony/src/context/http/parser.js +188 -0
- package/dist/nodefony/src/context/http/urlFastPath.js +103 -0
- package/dist/nodefony/src/context/http2/Request.js +29 -0
- package/dist/nodefony/src/context/http2/Response.js +97 -0
- package/dist/nodefony/src/context/metaData.js +47 -0
- package/dist/nodefony/src/context/requestId.js +41 -0
- package/dist/nodefony/src/context/trustProxy.js +167 -0
- package/dist/nodefony/src/context/websocket/Response.js +181 -0
- package/dist/nodefony/src/context/websocket/WebsocketContext.js +389 -0
- package/dist/nodefony/src/context/websocket/wsBackpressure.js +56 -0
- package/dist/nodefony/src/context/websocket/wsLogContent.js +68 -0
- package/dist/nodefony/src/cookies/cookie.js +258 -0
- package/dist/nodefony/src/errors/httpError.js +69 -0
- package/dist/nodefony/src/profiler/FrameProfile.js +95 -0
- package/dist/nodefony/src/profiler/Profiler.js +139 -0
- package/dist/nodefony/src/proxy/generateProxyConfig.js +157 -0
- package/dist/nodefony/src/rateLimit/IRateLimitStore.js +1 -0
- package/dist/nodefony/src/rateLimit/MemoryRateLimitStore.js +146 -0
- package/dist/nodefony/src/rateLimit/WsConnectionCounter.js +64 -0
- package/dist/nodefony/src/rateLimit/rateLimitFilters.js +20 -0
- package/dist/nodefony/src/servers/portBinder.js +114 -0
- package/dist/nodefony/src/session/session.js +390 -0
- package/dist/nodefony/src/session/storage/MemorySessionStorage.js +185 -0
- package/dist/nodefony/src/session/storage/RevocationGuardStorage.js +137 -0
- package/dist/nodefony/src/session/storage/sessionFilters.js +83 -0
- package/dist/nodefony/src/session/storage/sessionSort.js +53 -0
- package/dist/types/index.d.ts +83 -0
- package/dist/types/nodefony/command/assetsPublishCommand.d.ts +23 -0
- package/dist/types/nodefony/command/certificatesCommand.d.ts +17 -0
- package/dist/types/nodefony/command/networkCommand.d.ts +8 -0
- package/dist/types/nodefony/command/proxyGenerateCommand.d.ts +19 -0
- package/dist/types/nodefony/config/config.d.ts +197 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +39 -0
- package/dist/types/nodefony/interfaces/IContext.d.ts +138 -0
- package/dist/types/nodefony/interfaces/ICookie.d.ts +47 -0
- package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +55 -0
- package/dist/types/nodefony/interfaces/IHttpConfig.d.ts +12 -0
- package/dist/types/nodefony/interfaces/IHttpKernel.d.ts +10 -0
- package/dist/types/nodefony/interfaces/IRequest.d.ts +35 -0
- package/dist/types/nodefony/interfaces/IRequestLogger.d.ts +31 -0
- package/dist/types/nodefony/interfaces/IResponse.d.ts +39 -0
- package/dist/types/nodefony/interfaces/ISession.d.ts +283 -0
- package/dist/types/nodefony/interfaces/IUpload.d.ts +66 -0
- package/dist/types/nodefony/interfaces/index.d.ts +7 -0
- package/dist/types/nodefony/service/HttpAdminApi.d.ts +18 -0
- package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +23 -0
- package/dist/types/nodefony/service/audit-logger.d.ts +143 -0
- package/dist/types/nodefony/service/certificates.d.ts +246 -0
- package/dist/types/nodefony/service/error-renderer.d.ts +74 -0
- package/dist/types/nodefony/service/http-kernel.d.ts +377 -0
- package/dist/types/nodefony/service/pretty-request-logger.d.ts +25 -0
- package/dist/types/nodefony/service/request-logger.d.ts +18 -0
- package/dist/types/nodefony/service/servers/clientError.d.ts +14 -0
- package/dist/types/nodefony/service/servers/server-http.d.ts +42 -0
- package/dist/types/nodefony/service/servers/server-https.d.ts +41 -0
- package/dist/types/nodefony/service/servers/server-static.d.ts +62 -0
- package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +29 -0
- package/dist/types/nodefony/service/servers/server-websocket.d.ts +29 -0
- package/dist/types/nodefony/service/servers/serverShutdown.d.ts +27 -0
- package/dist/types/nodefony/service/servers/wsHeartbeat.d.ts +46 -0
- package/dist/types/nodefony/service/sessions/sessions-service.d.ts +218 -0
- package/dist/types/nodefony/service/trace.d.ts +39 -0
- package/dist/types/nodefony/service/upload/upload-service.d.ts +61 -0
- package/dist/types/nodefony/src/assets/collectAssets.d.ts +35 -0
- package/dist/types/nodefony/src/assets/prebuiltUi.d.ts +99 -0
- package/dist/types/nodefony/src/context/Context.d.ts +195 -0
- package/dist/types/nodefony/src/context/domainMatcher.d.ts +67 -0
- package/dist/types/nodefony/src/context/forwarded.d.ts +95 -0
- package/dist/types/nodefony/src/context/http/HttpContext.d.ts +85 -0
- package/dist/types/nodefony/src/context/http/Request.d.ts +203 -0
- package/dist/types/nodefony/src/context/http/Response.d.ts +68 -0
- package/dist/types/nodefony/src/context/http/parser.d.ts +65 -0
- package/dist/types/nodefony/src/context/http/urlFastPath.d.ts +52 -0
- package/dist/types/nodefony/src/context/http2/Request.d.ts +14 -0
- package/dist/types/nodefony/src/context/http2/Response.d.ts +20 -0
- package/dist/types/nodefony/src/context/metaData.d.ts +58 -0
- package/dist/types/nodefony/src/context/requestId.d.ts +28 -0
- package/dist/types/nodefony/src/context/trustProxy.d.ts +77 -0
- package/dist/types/nodefony/src/context/websocket/Response.d.ts +53 -0
- package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +125 -0
- package/dist/types/nodefony/src/context/websocket/wsBackpressure.d.ts +73 -0
- package/dist/types/nodefony/src/context/websocket/wsLogContent.d.ts +37 -0
- package/dist/types/nodefony/src/cookies/cookie.d.ts +88 -0
- package/dist/types/nodefony/src/errors/httpError.d.ts +15 -0
- package/dist/types/nodefony/src/profiler/FrameProfile.d.ts +110 -0
- package/dist/types/nodefony/src/profiler/Profiler.d.ts +192 -0
- package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +76 -0
- package/dist/types/nodefony/src/rateLimit/IRateLimitStore.d.ts +98 -0
- package/dist/types/nodefony/src/rateLimit/MemoryRateLimitStore.d.ts +40 -0
- package/dist/types/nodefony/src/rateLimit/WsConnectionCounter.d.ts +37 -0
- package/dist/types/nodefony/src/rateLimit/rateLimitFilters.d.ts +18 -0
- package/dist/types/nodefony/src/servers/portBinder.d.ts +102 -0
- package/dist/types/nodefony/src/session/session.d.ts +171 -0
- package/dist/types/nodefony/src/session/storage/MemorySessionStorage.d.ts +77 -0
- package/dist/types/nodefony/src/session/storage/RevocationGuardStorage.d.ts +81 -0
- package/dist/types/nodefony/src/session/storage/sessionFilters.d.ts +102 -0
- package/dist/types/nodefony/src/session/storage/sessionSort.d.ts +45 -0
- package/docs/cookies.md +365 -0
- package/docs/index.md +163 -0
- package/docs/observabilite.md +460 -0
- package/docs/rate-limit.md +372 -0
- package/docs/servers.md +935 -0
- package/docs/session.md +768 -0
- package/docs/upload.md +460 -0
- package/package.json +101 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Rate-limit — plafond de trafic par IP (429, close 1013)"
|
|
3
|
+
navTitle: Rate-limit
|
|
4
|
+
lang: fr
|
|
5
|
+
module: "@nodefony/http"
|
|
6
|
+
topic: rate-limit
|
|
7
|
+
section: "Cœur runtime"
|
|
8
|
+
audience: [developer, devops]
|
|
9
|
+
tags:
|
|
10
|
+
[
|
|
11
|
+
rate-limit,
|
|
12
|
+
throttling,
|
|
13
|
+
429,
|
|
14
|
+
retry-after,
|
|
15
|
+
x-ratelimit,
|
|
16
|
+
fenetre-fixe,
|
|
17
|
+
websocket,
|
|
18
|
+
1013,
|
|
19
|
+
ddos,
|
|
20
|
+
trust-proxy,
|
|
21
|
+
]
|
|
22
|
+
version: "doc"
|
|
23
|
+
status: stable
|
|
24
|
+
updated: 2026-07-21
|
|
25
|
+
source: "src/packages/@nodefony/http/docs/rate-limit.md"
|
|
26
|
+
coverageModule: http
|
|
27
|
+
coverageFiles: rateLimit/MemoryRateLimitStore.ts,rateLimit/IRateLimitStore.ts,rateLimit/WsConnectionCounter.ts,http-kernel.ts,HttpAdminApi.ts
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
# Rate-limit — plafond de trafic par IP (429, close 1013)
|
|
31
|
+
|
|
32
|
+
> Un **tourniquet de métro** posé à l'entrée du processus : chaque IP cliente reçoit un quota de
|
|
33
|
+
> requêtes par fenêtre de temps ; au-delà, on la refoule — un `429 Too Many Requests` en HTTP, une
|
|
34
|
+
> fermeture `1013 Try Again Later` en WebSocket. Le refus se décide **avant** d'allouer le moindre
|
|
35
|
+
> contexte, pour qu'un flood coûte une simple recherche dans une table de hachage. C'est une **défense
|
|
36
|
+
> de capacité par IP**, à ne pas confondre avec le backoff anti-bruteforce du **login** (page voisine,
|
|
37
|
+
> côté sécurité). Chaque fait ci-dessous est ancré sur le code.
|
|
38
|
+
|
|
39
|
+
📍 [Documentation](../../../../../docs/index.md) › [@nodefony/http](index.md) › **Rate-limit**
|
|
40
|
+
|
|
41
|
+
## 🧠 Le modèle mental — une clé, une fenêtre, un verdict
|
|
42
|
+
|
|
43
|
+
Le rate-limit ne connaît qu'**une** question : « cette IP a-t-elle déjà trop parlé dans la fenêtre en
|
|
44
|
+
cours ? ». Tout le reste en découle.
|
|
45
|
+
|
|
46
|
+
```mermaid
|
|
47
|
+
flowchart TD
|
|
48
|
+
REQ["Requête HTTP<br/>ou handshake WS"] --> IP["IP cliente résolue<br/>forwarded-aware (RFC 7239)"]
|
|
49
|
+
IP --> HIT["rateLimiter.hit(ip)<br/>fenêtre fixe · O(1) · synchrone"]
|
|
50
|
+
HIT -->|"sous le quota"| PIPE["→ pipeline<br/>+ en-têtes X-RateLimit-*"]
|
|
51
|
+
HIT -->|"quota dépassé · HTTP"| R429["429 Too Many Requests<br/>+ Retry-After"]
|
|
52
|
+
HIT -->|"quota dépassé · WS"| C1013["close 1013<br/>Try Again Later"]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Trois idées à retenir :
|
|
56
|
+
|
|
57
|
+
1. **La clé est l'IP, pas l'utilisateur.** On borne du **trafic**, pas des identités. L'IP est
|
|
58
|
+
résolue exactement comme pour les logs et l'audit (`resolveForwarded()`, `http-kernel.ts:991`) —
|
|
59
|
+
non falsifiable tant que `trustProxy` n'accorde pas sa confiance à un proxy.
|
|
60
|
+
2. **Le verdict porte tout.** Un seul appel `hit(key)` (`IRateLimitStore.ts:79`) rend un
|
|
61
|
+
`RateLimitVerdict` (`IRateLimitStore.ts:20`) qui contient déjà limite, restant, reset et
|
|
62
|
+
`Retry-After` — de quoi émettre les en-têtes sans relire l'état.
|
|
63
|
+
3. **HTTP et WebSocket partagent le même compteur.** Un upgrade WS **est** une requête HTTP : il passe
|
|
64
|
+
par le même `hit()`, seule la façon de refouler change (429 en HTTP, close 1013 en WS).
|
|
65
|
+
|
|
66
|
+
## 📖 Lexique
|
|
67
|
+
|
|
68
|
+
| Terme | Sens |
|
|
69
|
+
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
70
|
+
| Rate-limit / throttling | Limiter le **débit** entrant : X requêtes autorisées par unité de temps, le reste est refusé. |
|
|
71
|
+
| Fenêtre fixe | _Fixed window_ : un compteur par IP remis à zéro à chaque intervalle (60 s). Simple, O(1), 0 alloc pour une IP connue. |
|
|
72
|
+
| Fenêtre glissante | _Sliding window_ : lissage continu, plus juste aux bords de fenêtre. **Non** implémenté ici (compromis assumé). |
|
|
73
|
+
| Token bucket | Autre algorithme (jetons rechargés à débit constant). **Non** utilisé ici. |
|
|
74
|
+
| Quota / `max` | Nombre de requêtes autorisées par IP et par fenêtre. Au-delà → refus. |
|
|
75
|
+
| `429` | _Too Many Requests_ (RFC 6585 §4) : le code HTTP du refoulement. |
|
|
76
|
+
| `Retry-After` | En-tête indiquant au client **combien de secondes** attendre avant de réessayer. |
|
|
77
|
+
| `X-RateLimit-*` | Famille d'en-têtes de facto (`Limit`, `Remaining`, `Reset`) qui expose l'état du quota au client. |
|
|
78
|
+
| Close `1013` | _Try Again Later_ (RFC 6455 §7.4.1) : le refoulement d'un WebSocket, faute de pouvoir renvoyer un `429`. |
|
|
79
|
+
| IP forwarded-aware | IP cliente réelle reconstituée derrière un proxy via `X-Forwarded-For` / `Forwarded` (RFC 7239), sous contrôle de trust. |
|
|
80
|
+
| `trustProxy` | Réglage qui décide si l'on croit les en-têtes de proxy pour établir l'IP. `false` par défaut (non falsifiable). |
|
|
81
|
+
| `maxTracked` | Borne mémoire : nombre max d'IP suivies simultanément. Au cap → purge des expirées puis éviction FIFO. |
|
|
82
|
+
| GC (garbage collection) | Ici : balayage périodique qui **purge les fenêtres expirées** hors du chemin chaud (`GcScheduler` du core). |
|
|
83
|
+
| Backoff de login (NIST) | Défense **distincte** : ralentir les tentatives d'authentification par identifiant saisi (`security.rateLimit`). |
|
|
84
|
+
|
|
85
|
+
## Qu'est-ce qu'un rate-limit, et quelle attaque il bloque ?
|
|
86
|
+
|
|
87
|
+
Sans plafond, un seul client peut lancer des milliers de requêtes par seconde et **saturer** le
|
|
88
|
+
processus : famine de l'event-loop, mémoire qui gonfle, latence p99 qui explose pour tous les autres.
|
|
89
|
+
C'est le cœur d'un **déni de service applicatif** (DoS), volontaire ou accidentel (un script en boucle,
|
|
90
|
+
un crawler mal réglé).
|
|
91
|
+
|
|
92
|
+
Le rate-limit **refoule** l'IP fautive avant qu'elle ne coûte cher : elle reçoit un `429` (ou une
|
|
93
|
+
fermeture `1013` en WebSocket) tant qu'elle dépasse son quota, pendant que les autres IP passent
|
|
94
|
+
intactes. Le quota est **isolé par IP** (`rateLimit.test.ts:77`) : une IP saturée n'affecte jamais ses
|
|
95
|
+
voisines.
|
|
96
|
+
|
|
97
|
+
> [!IMPORTANT]
|
|
98
|
+
> Ce rate-limit borne le **trafic par IP sur toutes les routes**. Il ne remplace **pas** le backoff
|
|
99
|
+
> anti-bruteforce du **login** (`security.rateLimit`, par identifiant saisi, norme NIST) : ce sont deux
|
|
100
|
+
> briques différentes, à deux étages différents. Confondre les deux laisse un trou. Voir
|
|
101
|
+
> [@nodefony/security](../../security/docs/index.md).
|
|
102
|
+
|
|
103
|
+
## La vision Nodefony
|
|
104
|
+
|
|
105
|
+
Trois choix structurent l'implémentation, et chacun est un compromis assumé.
|
|
106
|
+
|
|
107
|
+
**Désactivé par défaut — opt-in explicite.** En cloud-native, le plafond par IP est souvent mieux placé
|
|
108
|
+
à l'**ingress/gateway** (il voit tout le trafic, tous les pods, et rejette avant le coût TLS). Le module
|
|
109
|
+
laisse donc `rateLimit` désarmé par défaut (`config.ts:830`) : `null` tant qu'on ne l'active pas → **0
|
|
110
|
+
coût** sur le chemin chaud. On l'active quand on n'a **pas** d'edge devant soi (bare-metal, VPS), ou en
|
|
111
|
+
défense en profondeur.
|
|
112
|
+
|
|
113
|
+
**Fenêtre fixe, en mémoire, O(1).** L'algorithme est le plus frugal possible :
|
|
114
|
+
`MemoryRateLimitStore` (`MemoryRateLimitStore.ts:33`) tient une entrée `{ count, resetAt }` par IP,
|
|
115
|
+
remise à zéro **en place** à l'expiration (0 allocation pour une IP récurrente). Le prix de cette
|
|
116
|
+
simplicité est connu : un pic à cheval sur deux fenêtres peut laisser passer jusqu'à `2 × max` sur un
|
|
117
|
+
court intervalle (`MemoryRateLimitStore.ts:29`). Acceptable pour une défense de **capacité** ; un
|
|
118
|
+
_sliding window_ viendrait en option si le besoin s'en fait sentir.
|
|
119
|
+
|
|
120
|
+
**Refoulé avant toute allocation.** Le verdict est rendu **avant** le contexte, la portée DI et l'ALS
|
|
121
|
+
(`http-kernel.ts:860`) : un flood coûte un `Map.get` et rien d'autre. Le contrat `hit()` est
|
|
122
|
+
**synchrone** à dessein (`IRateLimitStore.ts:8`) — aucune `Promise`, aucune microtask sur le chemin de
|
|
123
|
+
chaque requête.
|
|
124
|
+
|
|
125
|
+
## 🚀 Démarrage rapide
|
|
126
|
+
|
|
127
|
+
Dans une application générée par `nodefony create app`, le rate-limit est **présent mais désarmé**. On
|
|
128
|
+
l'active dans le manifeste, via `use("@nodefony/http", { … })`. L'exemple ci-dessous fixe un quota
|
|
129
|
+
volontairement **bas** (5 req/min) pour voir le `429` en quelques secondes.
|
|
130
|
+
|
|
131
|
+
### 1. Activer et régler
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
// nodefony.config.ts — activer le rate-limit général par IP
|
|
135
|
+
export default defineConfig(() => ({
|
|
136
|
+
modules: [
|
|
137
|
+
use("@nodefony/http", {
|
|
138
|
+
rateLimit: {
|
|
139
|
+
enabled: true, // opt-in : désarmé par défaut
|
|
140
|
+
windowS: 60, // fenêtre fixe de 60 secondes
|
|
141
|
+
max: 5, // 5 requêtes / IP / fenêtre (bas exprès, pour la démo)
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
"@nodefony/framework",
|
|
145
|
+
],
|
|
146
|
+
}));
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Les trois clés `enabled` / `windowS` / `max` sont **éditables à chaud** (`runtimeMutable`) : le kernel
|
|
150
|
+
reconstruit le compteur sans redémarrage (`configureRateLimit()`, `http-kernel.ts:322`).
|
|
151
|
+
|
|
152
|
+
### 2. Observer le 429 et les en-têtes
|
|
153
|
+
|
|
154
|
+
Chaque réponse porte l'état du quota ; la 6ᵉ requête dépasse `max=5` et se fait refouler. Les en-têtes
|
|
155
|
+
sont posés avant le routage, donc visibles même sur un 404.
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
# 6 requêtes rapides depuis la même IP → la 6ᵉ prend un 429
|
|
159
|
+
for i in $(seq 1 6); do
|
|
160
|
+
curl -s -o /dev/null -D - http://127.0.0.1:5151/ \
|
|
161
|
+
| grep -iE 'HTTP/|X-RateLimit|Retry-After'
|
|
162
|
+
done
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Ce qu'on observe : les cinq premières passent, le compteur `X-RateLimit-Remaining` décroît, puis la
|
|
166
|
+
sixième bascule.
|
|
167
|
+
|
|
168
|
+
```text
|
|
169
|
+
HTTP/1.1 200 OK
|
|
170
|
+
X-RateLimit-Limit: 5
|
|
171
|
+
X-RateLimit-Remaining: 4
|
|
172
|
+
X-RateLimit-Reset: 1753082460
|
|
173
|
+
...
|
|
174
|
+
HTTP/1.1 429 Too Many Requests
|
|
175
|
+
X-RateLimit-Limit: 5
|
|
176
|
+
X-RateLimit-Remaining: 0
|
|
177
|
+
X-RateLimit-Reset: 1753082460
|
|
178
|
+
Retry-After: 42
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
- `X-RateLimit-Remaining` : requêtes restantes dans la fenêtre (`http-kernel.ts:1001`).
|
|
182
|
+
- `X-RateLimit-Reset` : **epoch en secondes** de la fin de fenêtre (`http-kernel.ts:1003`).
|
|
183
|
+
- `Retry-After` (sur le `429` seulement) : secondes à attendre, **jamais 0** — un `Retry-After: 0`
|
|
184
|
+
relancerait un client bien élevé immédiatement (`MemoryRateLimitStore.ts:80`).
|
|
185
|
+
|
|
186
|
+
## 🏗️ Architecture interne — le parcours d'un `hit`
|
|
187
|
+
|
|
188
|
+
La logique de fenêtre fixe tient dans `hit()` (`MemoryRateLimitStore.ts:51`). Trois cas, tous O(1) :
|
|
189
|
+
|
|
190
|
+
```mermaid
|
|
191
|
+
flowchart TD
|
|
192
|
+
H["hit(ip)"] --> Q{"IP déjà suivie ?"}
|
|
193
|
+
Q -->|"non"| NEW["nouvelle fenêtre<br/>count=1 (éviction au cap d'abord)"]
|
|
194
|
+
Q -->|"oui"| EXP{"fenêtre expirée ?<br/>now ≥ resetAt"}
|
|
195
|
+
EXP -->|"oui"| RST["reset EN PLACE<br/>count=1 · 0 alloc"]
|
|
196
|
+
EXP -->|"non"| INC["count += 1"]
|
|
197
|
+
INC --> OVER{"count > max ?"}
|
|
198
|
+
OVER -->|"non"| OK["verdict: autorisé<br/>remaining = max − count"]
|
|
199
|
+
OVER -->|"oui"| REJ["verdict: limité<br/>rejectedTotal++ · Retry-After ≥ 1"]
|
|
200
|
+
NEW --> OK
|
|
201
|
+
RST --> OK
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Autour de ce cœur, le kernel orchestre le cycle de vie :
|
|
205
|
+
|
|
206
|
+
- **Construction / reconfiguration** : `configureRateLimit()` (`http-kernel.ts:412`) instancie le store
|
|
207
|
+
depuis la config (`windowMs = windowS × 1000`, `http-kernel.ts:412`) et arme un `GcScheduler`
|
|
208
|
+
(`http-kernel.ts:422`) qui **purge les fenêtres expirées** hors du chemin chaud.
|
|
209
|
+
- **Émission HTTP** : sous le quota, les en-têtes `X-RateLimit-*` sont posés (`http-kernel.ts:1000`) et
|
|
210
|
+
la requête continue ; au-delà, `Retry-After` (`http-kernel.ts:1007`) puis `writeHead(429)`
|
|
211
|
+
(`http-kernel.ts:1012`) — corps vide, on ne journalise pas chaque rejet (amplificateur sous flood).
|
|
212
|
+
- **Borne mémoire** : au cap `maxTracked`, le store purge les expirées puis évince en **FIFO**
|
|
213
|
+
(`#evict`, `MemoryRateLimitStore.ts:169`) — la mémoire ne dérive jamais.
|
|
214
|
+
|
|
215
|
+
## ⚙️ Configuration
|
|
216
|
+
|
|
217
|
+
Table dérivée de `rateLimitSchema` (`config.ts:847`). Tout est optionnel : ce sont les défauts du
|
|
218
|
+
schéma, écrits ici pour les montrer.
|
|
219
|
+
|
|
220
|
+
| Option | Type | Défaut | Effet | Chaud |
|
|
221
|
+
| ------------- | ------------ | --------- | -------------------------------------------------------------------------------- | ----- |
|
|
222
|
+
| `enabled` | bool | `false` | Arme le rate-limit (HTTP **et** handshakes WS, même compteur) (`config.ts:849`). | oui |
|
|
223
|
+
| `windowS` | int (s) | `60` | Largeur de la fenêtre fixe ; le compteur par IP repart à zéro (`config.ts:860`). | oui |
|
|
224
|
+
| `max` | int | `300` | Requêtes/IP/fenêtre ; au-delà `429` + `Retry-After` (`config.ts:871`). | oui |
|
|
225
|
+
| `maxTracked` | int (≥ 1000) | `100 000` | Borne mémoire : IP suivies ; au cap, purge puis éviction FIFO (`config.ts:883`). | non |
|
|
226
|
+
| `gcIntervalS` | int (s) | `300` | Intervalle du balayage de purge des fenêtres expirées, hors hot-path. | non |
|
|
227
|
+
| `gcJitter` | bool | `true` | Étale le tick GC d'un jitter aléatoire (anti-thundering-herd multi-pod). | non |
|
|
228
|
+
|
|
229
|
+
Et un réglage **séparé**, propre au WebSocket, à la racine du module :
|
|
230
|
+
|
|
231
|
+
| Option | Type | Défaut | Effet | Chaud |
|
|
232
|
+
| ----------------------- | ------------- | ------ | --------------------------------------------------------------------------------------------------- | ----- |
|
|
233
|
+
| `wsMaxConnectionsPerIp` | int \| `null` | `null` | Cap de connexions WS **concurrentes** par IP ; au-delà, upgrade fermé en `1013` (`config.ts:1046`). | oui |
|
|
234
|
+
|
|
235
|
+
> [!TIP]
|
|
236
|
+
> `max: 300` sur `windowS: 60` = **5 req/s soutenu** par IP, avec des rafales tolérées jusqu'à 300 d'un
|
|
237
|
+
> coup. Règle simple : `max` doit couvrir le **pic légitime** d'un vrai utilisateur (rechargement,
|
|
238
|
+
> préchargement d'assets), pas la moyenne — sinon on refoule ses propres clients.
|
|
239
|
+
|
|
240
|
+
## 🔌 Rate-limit côté WebSocket
|
|
241
|
+
|
|
242
|
+
Un WebSocket ne peut **pas** recevoir un `429` : au moment où le rate-limit décide, le `101 Switching
|
|
243
|
+
Protocols` est déjà parti sur le fil (émis par la bibliothèque `ws`). Le refoulement se fait donc par
|
|
244
|
+
une **fermeture RFC 6455 `1013 Try Again Later`**, décidée dans `onWebsocketRequest()`
|
|
245
|
+
(`http-kernel.ts:1505`) — **avant** `enterScope`, l'ALS et le pipeline, comme le `429` HTTP.
|
|
246
|
+
|
|
247
|
+
Deux plafonds distincts, tous deux par IP forwarded-aware :
|
|
248
|
+
|
|
249
|
+
| Plafond | Ce qu'il borne | Source de config | Refus |
|
|
250
|
+
| ---------------------- | ---------------------------------------------- | ----------------------- | ---------------------------------------------------------- |
|
|
251
|
+
| Débit de handshakes | Ouvertures/seconde (le **même** compteur HTTP) | `rateLimit` | close `1013` (`http-kernel.ts:1521`) |
|
|
252
|
+
| Connexions simultanées | Sockets **ouvertes** en même temps par IP | `wsMaxConnectionsPerIp` | close `1013` — `tryAcquire` refuse (`http-kernel.ts:1531`) |
|
|
253
|
+
|
|
254
|
+
Le cap concurrent est porté par un compteur dédié, `WsConnectionCounter` (`WsConnectionCounter.ts:18`) :
|
|
255
|
+
`tryAcquire(ip)` (`WsConnectionCounter.ts:33`) réserve un créneau à l'upgrade, `release(ip)`
|
|
256
|
+
(`WsConnectionCounter.ts:45`) le rend à la fermeture — branché sur `ws.once("close", …)`
|
|
257
|
+
(`http-kernel.ts:1536`), donc jamais de fuite de compteur, même sur un `terminate` de heartbeat.
|
|
258
|
+
|
|
259
|
+
> [!WARNING]
|
|
260
|
+
> `wsMaxConnectionsPerIp` a une **portée par process** (1 pod) : il ne voit que le trafic de son propre
|
|
261
|
+
> worker. Un vrai plafond **global par IP** se fait à l'ingress (`nginx limit_conn`, HAProxy
|
|
262
|
+
> `sc_conn_cur`, annotation k8s). En cloud-native, laisser `null` et déléguer à l'edge ; ne l'activer
|
|
263
|
+
> (ex. `20`) qu'en défense en profondeur sur une machine **sans** ingress.
|
|
264
|
+
|
|
265
|
+
Ces limites-là bornent le **rythme d'ouverture** et le **nombre de sockets**. Elles sont distinctes des
|
|
266
|
+
bornes **par message** (taille `maxPayload` → close `1009`, backpressure), décrites dans
|
|
267
|
+
[Serveurs](servers.md).
|
|
268
|
+
|
|
269
|
+
## 🧩 Étendre — un store distribué
|
|
270
|
+
|
|
271
|
+
Tout passe par le contrat `IRateLimitStore` (`IRateLimitStore.ts:74`). L'implémentation par défaut est
|
|
272
|
+
en mémoire (par process), mais le contrat est pensé pour un futur backend **distribué** (Redis,
|
|
273
|
+
multi-pod) : `hit()` reste synchrone (le hot-path ne tolère pas de `Promise`), tandis que l'introspection
|
|
274
|
+
`listPage()` (`IRateLimitStore.ts:99`) est asynchrone — un store distribué la servira par `SCAN`.
|
|
275
|
+
|
|
276
|
+
Un adapter doit fournir : `hit(key)` (verdict de fenêtre), `gc()` (purge), `listPage(query)`
|
|
277
|
+
(introspection admin), plus les métriques `trackedCount` et `rejectedTotal` (`IRateLimitStore.ts:101`).
|
|
278
|
+
|
|
279
|
+
## 📜 Normes appliquées
|
|
280
|
+
|
|
281
|
+
| Domaine | Norme | Ancrage |
|
|
282
|
+
| ---------------------------------- | ---------------- | ------------------------------------------------- |
|
|
283
|
+
| `429 Too Many Requests` | RFC 6585 §4 | `writeHead(429)` (`http-kernel.ts:1012`) |
|
|
284
|
+
| `Retry-After` (delta-seconds) | RFC 9110 §10.2.3 | en-tête posé sur le `429` (`http-kernel.ts:1007`) |
|
|
285
|
+
| IP cliente derrière proxy | RFC 7239 | `resolveForwarded()` (`http-kernel.ts:991`) |
|
|
286
|
+
| WebSocket — close `1013` Try Again | RFC 6455 §7.4.1 | refus d'upgrade (`http-kernel.ts:1378`) |
|
|
287
|
+
|
|
288
|
+
> [!NOTE]
|
|
289
|
+
> Les en-têtes émis sont la famille **de facto** `X-RateLimit-Limit/Remaining/Reset`
|
|
290
|
+
> (`http-kernel.ts:1000`), largement déployée et lue par les clients. Le brouillon IETF
|
|
291
|
+
> `draft-ietf-httpapi-ratelimit-headers` (en-têtes `RateLimit` / `RateLimit-Policy`) n'est **pas** encore
|
|
292
|
+
> émis — une évolution possible, pas une régression : rien ne le promet aujourd'hui.
|
|
293
|
+
|
|
294
|
+
## ⚡ Performance & mémoire
|
|
295
|
+
|
|
296
|
+
Le rate-limit vit sur le **chemin chaud absolu** — il s'exécute avant tout le reste, sur chaque requête.
|
|
297
|
+
Les choix visibles dans le code :
|
|
298
|
+
|
|
299
|
+
- **Synchrone, O(1)** : `hit()` = 1 `Map.get` + arithmétique, zéro `Promise`, zéro microtask
|
|
300
|
+
(`IRateLimitStore.ts:8`).
|
|
301
|
+
- **Lazy** : la `Map` interne n'est allouée qu'au **premier** hit (`MemoryRateLimitStore.ts:33`) →
|
|
302
|
+
quand le rate-limit est désarmé (défaut), le coût mémoire est **nul**.
|
|
303
|
+
- **0 alloc pour une IP connue** : la fenêtre expirée se réinitialise **en place**, pas de nouvel objet.
|
|
304
|
+
- **Rejet avant allocation** : un flood est refoulé avant le contexte / la portée DI / l'ALS
|
|
305
|
+
(`http-kernel.ts:860`) → un attaquant paie une recherche de hachage, pas un pipeline complet.
|
|
306
|
+
- **Résolution d'IP seulement si un limiteur est armé** côté WS (`http-kernel.ts:1368`).
|
|
307
|
+
- **Mémoire bornée** : `maxTracked` + éviction FIFO ; purge périodique `unref` hors hot-path.
|
|
308
|
+
|
|
309
|
+
Rejouer la pression sous charge : skill `nodefony-load-test`. Gate mémoire avant tout commit touchant le
|
|
310
|
+
pipeline : `npm run test:memory` (skill `nodefony-check-memory-health`).
|
|
311
|
+
|
|
312
|
+
## 📡 Observabilité — Studio
|
|
313
|
+
|
|
314
|
+
Le data plane admin expose **qui martèle** via `createHttpAdminApi()` (`HttpAdminApi.ts:141`) :
|
|
315
|
+
|
|
316
|
+
- **`GET /nodefony/http/api/rate-limit/list`** (`HttpAdminApi.ts:218`) — les IP suivies, **les plus
|
|
317
|
+
bruyantes d'abord** (tri `count` décroissant), paginé **côté serveur** (`?limited&q&limit&offset`).
|
|
318
|
+
`q` filtre par **préfixe** d'IP (un sous-réseau `10.0.`), pas par sous-chaîne.
|
|
319
|
+
- **Réservé `ROLE_NODEFONY_ADMIN`** (`HttpAdminApi.ts:220`) : une IP est une **donnée personnelle** →
|
|
320
|
+
seul l'état du compteur sort d'ici, jamais l'URL, l'en-tête ou le corps des requêtes.
|
|
321
|
+
- **État honnête quand désarmé** : rate-limit désactivé (le défaut) → `enabled: false` + liste vide, pas
|
|
322
|
+
un `503` (`HttpAdminApi.ts:241`). La console affiche « désarmé » plutôt qu'une erreur.
|
|
323
|
+
- Métriques exposées : `trackedCount` (IP suivies) et `rejectedTotal` (429 cumulés depuis le boot).
|
|
324
|
+
|
|
325
|
+
## ⚠️ Pièges (symptôme → cause → correction)
|
|
326
|
+
|
|
327
|
+
| Symptôme | Cause | Correction |
|
|
328
|
+
| ------------------------------------------------------ | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
|
329
|
+
| Le rate-limit « ne fait rien » | Désactivé par défaut (opt-in) | `rateLimit.enabled: true` dans `use("@nodefony/http", …)` |
|
|
330
|
+
| Toutes les IP derrière le proxy comptent comme **une** | `trustProxy: false` → l'IP vue est celle du proxy, pas du client | Régler `trustProxy` sur l'IP/CIDR du proxy — voir [Serveurs](servers.md) |
|
|
331
|
+
| Un burst laisse passer près de `2 × max` | Limite **assumée** de la fenêtre fixe (pic à cheval sur deux fenêtres) | Réduire `windowS`, ou attendre l'option _sliding window_ |
|
|
332
|
+
| Un WebSocket ne reçoit jamais de `429` | Le `101` est déjà émis — impossible de renvoyer un code HTTP | Attendu : le refus WS est une fermeture `1013` (`http-kernel.ts:1378`) |
|
|
333
|
+
| `wsMaxConnectionsPerIp` semble inefficace en cluster | Portée **par process** — chaque pod compte pour lui | Déléguer le cap global/IP à l'ingress (nginx `limit_conn`, HAProxy) |
|
|
334
|
+
| Confusion avec le lockout de login | `security.rateLimit` = backoff NIST **par identifiant**, brique distincte | Ce sont deux étages différents — cf [@nodefony/security](../../security/docs/index.md) |
|
|
335
|
+
| Une IP « fantôme » n'est jamais comptée | `resolveForwarded()` renvoie `null` (aucun socket fiable) | Attendu : on ne compte jamais sous une clé `null` (qui deviendrait un DoS) |
|
|
336
|
+
|
|
337
|
+
## 🧪 Tests & couverture
|
|
338
|
+
|
|
339
|
+
Les chiffres exacts vivent dans la carte de tests de cette page (régénérée depuis vitest, jamais figés
|
|
340
|
+
dans le Markdown).
|
|
341
|
+
|
|
342
|
+
| Type | Où |
|
|
343
|
+
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
344
|
+
| Unitaires — store | `unit/rateLimit.test.ts` — fenêtre fixe, isolation par IP, borne mémoire + éviction FIFO, `listPage` (tri, filtres, préfixe) |
|
|
345
|
+
| Unitaires — cap WS | `unit/wsConnectionCounter.test.ts` — acquire/refus au plafond, `release`, auto-borne (pas de GC), IP indépendantes |
|
|
346
|
+
| Unitaires — admin | `unit/rateLimitAdminApi.test.ts` — `rate-limit/list` : état désarmé honnête, tri décroissant, `?limited`/`?q`/`?offset`, `ROLE_NODEFONY_ADMIN` |
|
|
347
|
+
| Intégration — WS | `websockets/websocket-limits.test.ts` — bornes de message (taille `maxPayload` → `1009`, séquence, protocole) |
|
|
348
|
+
|
|
349
|
+
Ce qui **manque** aujourd'hui :
|
|
350
|
+
|
|
351
|
+
- Aucun test d'**intégration** ne prouve, sur un serveur vivant, le `429` HTTP **et** les en-têtes
|
|
352
|
+
`X-RateLimit-*` de bout en bout (le comportement du store est prouvé unitairement, son câblage kernel
|
|
353
|
+
ne l'est pas).
|
|
354
|
+
- Aucun test d'intégration ne couvre la **fermeture `1013`** (débit de handshakes et cap concurrent) :
|
|
355
|
+
le compteur est prouvé unitairement, le refus WS de bout en bout est exercé par les bancs E2E
|
|
356
|
+
(`nodefony-load-test` → `run.sh ws-handshake-rl` / `ws-conn-cap`), pas par une suite du module.
|
|
357
|
+
- Pas de banc de **charge dédié** au coût du rate-limit sur le hot-path.
|
|
358
|
+
|
|
359
|
+
Suites : `npm test` (unitaires, serveur non requis), `npm run test:integration` (serveur requis).
|
|
360
|
+
Couverture : `npm run coverage` dans `@nodefony/http` — le pourcentage vit dans le rapport vitest,
|
|
361
|
+
jamais figé ici. Skills associés : `nodefony-load-test`, `nodefony-check-memory-health`,
|
|
362
|
+
`nodefony-security-review`.
|
|
363
|
+
|
|
364
|
+
## 🔗 Pour aller plus loin
|
|
365
|
+
|
|
366
|
+
- ⬆️ **Retour au hub** : [@nodefony/http — vue du module](index.md) · [Toute la documentation](../../../../../docs/index.md)
|
|
367
|
+
- 🧭 **Pages sœurs** : [Serveurs](servers.md) — bornes par message (`maxPayload` → `1009`, backpressure), TLS, arrêt gracieux.
|
|
368
|
+
- L'IP cliente derrière un proxy (`trustProxy`, `X-Forwarded-*`) → [Serveurs](servers.md).
|
|
369
|
+
- Le backoff anti-bruteforce du **login** (brique distincte) → [@nodefony/security](../../security/docs/index.md).
|
|
370
|
+
- Où le rate-limit se branche dans le trajet d'une requête → [pipeline-requete](../../../../../docs/architecture/pipeline-requete.md).
|
|
371
|
+
- Déployer derrière un ingress qui porte le cap global (probes, TLS, edge) → [docker-cloud-native](../../../../../docs/guides/docker-cloud-native.md).
|
|
372
|
+
- Configuration d'application (`defineConfig`, `use`, env) → [configuration](../../../../../docs/guides/configuration.md).
|