ClaudeGate expose Claude Code CLI comme une API REST. Chaque requête POST /api/v1/jobs
crée un job qui lance un process CLI — ce n'est pas un handler HTTP qui fait une requête SQL.
C'est un process fils, de la RAM, du CPU, du temps.
Sans protection, un client agressif peut saturer la machine en quelques secondes.
La solution évidente : un rate limiter par IP. Le package
golang.org/x/time/rate implémente l'algorithme token bucket,
qui est exactement ce qu'il faut ici. Voici comment l'intégration s'est faite,
les décisions prises, et ce qu'on a délibérément laissé de côté.
Token bucket : la mécanique en deux phrases
Un token bucket stocke des jetons. Chaque requête consomme un jeton. Les jetons se régénèrent à un taux constant (les RPS). Si le bucket est vide, la requête est rejetée.
golang.org/x/time/rate expose ça proprement via
rate.NewLimiter(limit, burst) :
import "golang.org/x/time/rate"
// 5 requêtes/seconde, burst de 5 (pas de crédit accumulé au-delà)
limiter := rate.NewLimiter(rate.Limit(5), 5)
if limiter.Allow() {
// traiter la requête
} else {
// 429 Too Many Requests
}
rate.Limit est un float64 qui représente les événements par seconde.
Le burst définit le nombre de jetons maximum dans le bucket — autrement dit,
la taille de la rafale autorisée avant que la limite s'applique.
Dans ClaudeGate, on aligne burst = rps : pas de crédit accumulé,
le plafond par seconde est strict.
Un limiter par IP, pas un limiter global
Un limiter global limiterait l'ensemble des clients simultanément. Un client légèrement actif serait pénalisé par un autre client abusif. Ce n'est pas le comportement voulu.
La structure RateLimiter maintient une map IP → limiter :
type ipLimiter struct {
limiter *rate.Limiter
lastSeen time.Time
}
type RateLimiter struct {
mu sync.Mutex
ips map[string]*ipLimiter
rps rate.Limit
burst int
}
func NewRateLimiter(rps int) *RateLimiter {
rl := &RateLimiter{
ips: make(map[string]*ipLimiter),
rps: rate.Limit(rps),
burst: rps,
}
go rl.cleanup()
return rl
}
func (rl *RateLimiter) allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
l, ok := rl.ips[ip]
if !ok {
l = &ipLimiter{limiter: rate.NewLimiter(rl.rps, rl.burst)}
rl.ips[ip] = l
}
l.lastSeen = time.Now()
return l.limiter.Allow()
}
Le sync.Mutex protège l'accès concurrent à la map.
Chaque IP obtient son propre rate.Limiter à la première requête.
Le champ lastSeen sert uniquement au cleanup.
La goroutine de cleanup — éviter le memory leak
Sans cleanup, la map grossit indéfiniment. Chaque IP qui touche l'API crée une entrée qui ne sera jamais supprimée. Sur une API publique exposée à Internet, c'est un vecteur de fuite mémoire trivial à exploiter.
La solution : une goroutine qui tourne en arrière-plan et expurge les entrées inactives depuis plus de 5 minutes :
func (rl *RateLimiter) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
rl.mu.Lock()
cutoff := time.Now().Add(-5 * time.Minute)
for ip, l := range rl.ips {
if l.lastSeen.Before(cutoff) {
delete(rl.ips, ip)
}
}
rl.mu.Unlock()
}
}
Deux points importants. Premièrement, le ticker est lancé avec defer ticker.Stop()
— si la goroutine venait à se terminer, la ressource est libérée proprement.
Deuxièmement, le mutex est acquis pour toute la durée du parcours de la map :
on ne peut pas modifier une map pendant qu'une autre goroutine la lit.
La goroutine ne reçoit pas de contexte. Dans ClaudeGate, le rate limiter vit
tant que le serveur tourne — aucune raison de l'arrêter proprement.
Si la cohérence de shutdown est importante dans votre cas, passer un ctx context.Context
et ajouter un case <-ctx.Done(): return dans le select.
X-Forwarded-For : l'IP réelle derrière un proxy
Derrière un reverse proxy (nginx, Caddy, un load balancer), r.RemoteAddr
retourne l'IP du proxy — pas du client. Toutes les requêtes auraient la même "IP",
le rate limiter deviendrait global. Il faut lire X-Forwarded-For :
func clientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
if idx := strings.Index(fwd, ","); idx != -1 {
return strings.TrimSpace(fwd[:idx])
}
return strings.TrimSpace(fwd)
}
addr := r.RemoteAddr
if idx := strings.LastIndex(addr, ":"); idx != -1 {
return addr[:idx]
}
return addr
}
X-Forwarded-For peut contenir une liste d'IPs si la requête passe par
plusieurs proxies : client, proxy1, proxy2. On prend la première valeur —
c'est l'IP du client d'origine telle que vue par le premier proxy de la chaîne.
Le strings.TrimSpace évite les espaces parasites.
Attention : ce header peut être forgé par le client si votre infrastructure n'est pas configurée pour le setter. Dans un environnement controlé où vous maîtrisez le proxy qui set ce header, c'est fiable. Sur une API directement exposée, c'est un vecteur de contournement potentiel.
Middleware ciblé : uniquement POST /api/v1/jobs
Le rate limiter ne s'applique qu'aux requêtes qui créent des jobs. Les endpoints GET (polling de statut, stream SSE) n'en ont pas besoin : ce sont des lectures légères.
func RateLimit(rps int) Middleware {
if rps <= 0 {
return func(next http.Handler) http.Handler { return next }
}
rl := NewRateLimiter(rps)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && r.URL.Path == "/api/v1/jobs" {
ip := clientIP(r)
if !rl.allow(ip) {
writeError(w, http.StatusTooManyRequests, "rate limit exceeded, slow down")
return
}
}
next.ServeHTTP(w, r)
})
}
}
La vérification rps <= 0 permet de désactiver le rate limiter
avec une config RATE_LIMIT=0 — utile pour les tests d'intégration
ou un déploiement interne sans exposition publique.
Le no-op middleware retourné ne fait aucune allocation.
Les tests : rps=1 burst=1 pour forcer le rejet
Tester un rate limiter demande de forcer le cas de rejet, ce qui n'est pas trivial
avec des burst élevés. La solution : rps=1, burst=1.
La deuxième requête dans la même seconde est systématiquement bloquée :
func TestRateLimit_BlocksOverLimit(t *testing.T) {
handler := RateLimit(1)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs", nil)
req.RemoteAddr = "1.2.3.4:5678"
// Première requête : passe
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
// Deuxième requête immédiate : bloquée
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusTooManyRequests {
t.Fatalf("expected 429, got %d", rr.Code)
}
}
Les tests couvrent également :
- Désactivation (
rps=0) : le middleware doit laisser passer toutes les requêtes sans créer de limiter - Sous la limite (
rps=10) : la première requête doit passer - Méthodes non-POST : les GET sur
/api/v1/jobsne sont jamais bloqués, même avecrps=1
httptest.NewRequest et httptest.NewRecorder suffisent.
Pas de serveur, pas de port, pas de goroutine. Les tests s'exécutent en quelques millisecondes.
Ce qu'on n'a pas fait — et pourquoi
Plusieurs approches plus sophistiquées ont été écartées volontairement.
Redis distribué. Un rate limiter Redis (ou Valkey) est indispensable si plusieurs instances de l'API tournent derrière un load balancer — les compteurs doivent être partagés. ClaudeGate tourne sur une seule instance. Redis ajouterait une dépendance externe, une connexion réseau sur chaque requête, et un point de défaillance supplémentaire. Le gain est nul dans ce contexte.
Sliding window. Le token bucket accumule des crédits entre les requêtes. Une fenêtre glissante (sliding window counter) donne une limite stricte sur n'importe quelle fenêtre temporelle, sans pic possible aux frontières. Plus précis, mais aussi plus complexe à implémenter correctement sans Redis. Pour une gateway personnelle, le token bucket est suffisant.
Headers de quota dans la réponse. X-RateLimit-Limit,
X-RateLimit-Remaining, X-RateLimit-Reset — les clients
API les attendent souvent. rate.Limiter expose des méthodes comme
Tokens() et Reserve() qui permettraient de les calculer,
mais ça n'a pas été jugé nécessaire pour ce cas d'usage.
Conclusion
Moins de 80 lignes. Une goroutine de cleanup. Pas de dépendance externe. Le rate limiter de ClaudeGate couvre le cas d'usage réel sans sur-ingénierie.
golang.org/x/time/rate est la bonne abstraction pour un token bucket
en Go : thread-safe, précis, documenté. Le travail d'intégration consiste surtout
à gérer ce qui est autour — la map par IP, le cleanup, l'extraction de l'IP réelle,
et le ciblage du middleware sur les endpoints coûteux.
Si le contexte change — multi-instance, seuils par utilisateur authentifié, quota journalier — le design devra évoluer. Mais on n'anticipe pas ce qui n'existe pas encore.