Renames: - platform/ → workspace-server/ (Go module path stays as "platform" for external dep compat — will update after plugin module republish) - workspace-template/ → workspace/ Removed (moved to separate repos or deleted): - PLAN.md — internal roadmap (move to private project board) - HANDOFF.md, AGENTS.md — one-time internal session docs - .claude/ — gitignored entirely (local agent config) - infra/cloudflare-worker/ → Molecule-AI/molecule-tenant-proxy - org-templates/molecule-dev/ → standalone template repo - .mcp-eval/ → molecule-mcp-server repo - test-results/ — ephemeral, gitignored Security scrubbing: - Cloudflare account/zone/KV IDs → placeholders - Real EC2 IPs → <EC2_IP> in all docs - CF token prefix, Neon project ID, Fly app names → redacted - Langfuse dev credentials → parameterized - Personal runner username/machine name → generic Community files: - CONTRIBUTING.md — build, test, branch conventions - CODE_OF_CONDUCT.md — Contributor Covenant 2.1 All Dockerfiles, CI workflows, docker-compose, railway.toml, render.yaml, README, CLAUDE.md updated for new directory names. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
113 lines
2.8 KiB
Go
113 lines
2.8 KiB
Go
// Package middleware provides HTTP middleware for the platform API.
|
|
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// RateLimiter implements a simple token bucket rate limiter per IP.
|
|
type RateLimiter struct {
|
|
mu sync.Mutex
|
|
buckets map[string]*bucket
|
|
rate int // tokens per interval
|
|
interval time.Duration
|
|
}
|
|
|
|
type bucket struct {
|
|
tokens int
|
|
lastReset time.Time
|
|
}
|
|
|
|
// NewRateLimiter creates a rate limiter with the given rate per interval.
|
|
// Pass a context to stop the cleanup goroutine on shutdown.
|
|
func NewRateLimiter(rate int, interval time.Duration, ctx context.Context) *RateLimiter {
|
|
rl := &RateLimiter{
|
|
buckets: make(map[string]*bucket),
|
|
rate: rate,
|
|
interval: interval,
|
|
}
|
|
go func() {
|
|
ticker := time.NewTicker(5 * time.Minute)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
rl.mu.Lock()
|
|
cutoff := time.Now().Add(-10 * time.Minute)
|
|
for ip, b := range rl.buckets {
|
|
if b.lastReset.Before(cutoff) {
|
|
delete(rl.buckets, ip)
|
|
}
|
|
}
|
|
rl.mu.Unlock()
|
|
}
|
|
}
|
|
}()
|
|
return rl
|
|
}
|
|
|
|
// Middleware returns a Gin middleware that rate limits by client IP.
|
|
func (rl *RateLimiter) Middleware() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
ip := c.ClientIP()
|
|
|
|
rl.mu.Lock()
|
|
b, exists := rl.buckets[ip]
|
|
if !exists {
|
|
b = &bucket{tokens: rl.rate, lastReset: time.Now()}
|
|
rl.buckets[ip] = b
|
|
}
|
|
|
|
// Reset tokens if interval has passed
|
|
if time.Since(b.lastReset) >= rl.interval {
|
|
b.tokens = rl.rate
|
|
b.lastReset = time.Now()
|
|
}
|
|
|
|
// Issue #105 — advertise the current bucket state so clients and
|
|
// monitoring tools can back off proactively. Headers are set on every
|
|
// response (both allowed and throttled) so they're observable against
|
|
// any endpoint — /health, /metrics, and every /workspaces/* route.
|
|
//
|
|
// The `reset` value is seconds until the current bucket refills,
|
|
// matching the RFC 6585 Retry-After spec for 429 responses and the
|
|
// de-facto X-RateLimit-Reset convention (GitHub, Stripe, etc.).
|
|
remaining := b.tokens - 1
|
|
if remaining < 0 {
|
|
remaining = 0
|
|
}
|
|
resetSeconds := int(time.Until(b.lastReset.Add(rl.interval)).Seconds())
|
|
if resetSeconds < 0 {
|
|
resetSeconds = 0
|
|
}
|
|
c.Header("X-RateLimit-Limit", strconv.Itoa(rl.rate))
|
|
c.Header("X-RateLimit-Remaining", strconv.Itoa(remaining))
|
|
c.Header("X-RateLimit-Reset", strconv.Itoa(resetSeconds))
|
|
|
|
if b.tokens <= 0 {
|
|
rl.mu.Unlock()
|
|
// Retry-After is the canonical 429 signal per RFC 6585.
|
|
c.Header("Retry-After", strconv.Itoa(resetSeconds))
|
|
c.JSON(http.StatusTooManyRequests, gin.H{
|
|
"error": "rate limit exceeded",
|
|
"retry_after": resetSeconds,
|
|
})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
b.tokens--
|
|
rl.mu.Unlock()
|
|
|
|
c.Next()
|
|
}
|
|
}
|