Files
nofx/api/rate_limiter.go
shinchan-zhai fd28aa7fc6 reliability: wrap all 27 bare goroutines with safe.Go/GoNamed panic recovery
Applied safe.GoNamed to:
- 9 exchange order_sync goroutines (OKX, Hyperliquid, Aster, Bybit, KuCoin, Gate, Bitget, Lighter, Binance×2)
- Drawdown monitor (auto_trader_risk.go)
- Brain news scanner + market briefs (agent/brain.go)
- Sentinel scanner (agent/sentinel.go)
- Agent scheduler (agent/scheduler.go)
- x402 idle watchdog (mcp/payment/x402.go)
- MCP stream idle watchdog (mcp/client.go)
- Rate limiter cleanup (api/rate_limiter.go)
- 3 telemetry fire-and-forget sends (telemetry/experience.go)
- CoinAnk WS handler (provider/coinank/coinank_api/kline_ws.go)
- API server goroutine (main.go)

Added manual defer/recover with error reporting to:
- Telegram AI agent handler (sends error msg to user on panic)
- Trader data fetch (returns error result on panic to prevent deadlock)

Before: a panic in ANY of these 27 goroutines would crash the entire
trading process with zero diagnostics. Now all panics are caught, logged
with stack traces, and the process continues running.
2026-03-25 01:05:54 +08:00

134 lines
2.9 KiB
Go

package api
import (
"net/http"
"nofx/safe"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// loginAttempt tracks failed login attempts per IP
type loginAttempt struct {
count int
lastTry time.Time
blockedUntil time.Time
}
// LoginRateLimiter protects login endpoint from brute-force attacks
type LoginRateLimiter struct {
mu sync.Mutex
attempts map[string]*loginAttempt
maxAttempts int // max failures before blocking
blockTime time.Duration // how long to block after max failures
windowTime time.Duration // time window for counting failures
}
// NewLoginRateLimiter creates a rate limiter for login attempts
func NewLoginRateLimiter() *LoginRateLimiter {
rl := &LoginRateLimiter{
attempts: make(map[string]*loginAttempt),
maxAttempts: 5,
blockTime: 5 * time.Minute,
windowTime: 15 * time.Minute,
}
// Clean up stale entries every 10 minutes
safe.GoNamed("rate-limiter-cleanup", func() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for range ticker.C {
rl.cleanup()
}
})
return rl
}
// Check returns true if the IP is allowed to attempt login
func (rl *LoginRateLimiter) Check(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
a, exists := rl.attempts[ip]
if !exists {
return true
}
// Check if still blocked
if !a.blockedUntil.IsZero() && time.Now().Before(a.blockedUntil) {
return false
}
// Check if window expired — reset
if time.Since(a.lastTry) > rl.windowTime {
delete(rl.attempts, ip)
return true
}
return a.count < rl.maxAttempts
}
// RecordFailure records a failed login attempt
func (rl *LoginRateLimiter) RecordFailure(ip string) {
rl.mu.Lock()
defer rl.mu.Unlock()
a, exists := rl.attempts[ip]
if !exists {
a = &loginAttempt{}
rl.attempts[ip] = a
}
// Reset if window expired
if time.Since(a.lastTry) > rl.windowTime {
a.count = 0
a.blockedUntil = time.Time{}
}
a.count++
a.lastTry = time.Now()
if a.count >= rl.maxAttempts {
a.blockedUntil = time.Now().Add(rl.blockTime)
}
}
// RecordSuccess clears failed attempts for an IP after successful login
func (rl *LoginRateLimiter) RecordSuccess(ip string) {
rl.mu.Lock()
defer rl.mu.Unlock()
delete(rl.attempts, ip)
}
// cleanup removes stale entries
func (rl *LoginRateLimiter) cleanup() {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
for ip, a := range rl.attempts {
// Remove if both window and block have expired
if now.Sub(a.lastTry) > rl.windowTime && (a.blockedUntil.IsZero() || now.After(a.blockedUntil)) {
delete(rl.attempts, ip)
}
}
}
// LoginRateLimitMiddleware creates a gin middleware for login rate limiting
func LoginRateLimitMiddleware(rl *LoginRateLimiter) gin.HandlerFunc {
return func(c *gin.Context) {
ip := c.ClientIP()
if !rl.Check(ip) {
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "Too many login attempts. Please try again later.",
})
c.Abort()
return
}
c.Next()
}
}