Part of Distributed Rate Limiter

Fail-open local fallback when Redis is unreachable

Reworked the limiter so a Redis outage degrades to a bounded per-instance window instead of failing every request, keeping ingestion alive under a dependency loss.

The Redis path was correct for quota decisions but wrong for dependency failure. Allow returned the raw pipe.Exec error to the caller, so a short Redis outage made every guarded handler fail even when the caller would have preferred a capped local admission rate. That made Redis availability part of the request path’s liveness contract, which was too strict for an ingress guard whose first job was to shed load before a service ran out of capacity.

The new path keeps over-limit and transport failure separate. If Redis answers, the shared sorted set still decides the request. If Redis cannot be reached, the limiter uses an in-process sliding window keyed by the same identity and sized as limit / expectedReplicas. The local window is not stored as a mode flag; every call tries Redis first, then falls back only for that call. That keeps recovery tied to the next successful Redis round trip instead of a background timer.

func Allow(ctx context.Context, rdb *redis.Client, key string, limit int, window time.Duration) (bool, error) {
	ok, err := allowRedis(ctx, rdb, key, limit, window)
	if err == nil {
		return ok, nil
	}
	// Transport failure: degrade, do not fail the request outright.
	metrics.RedisFallback.Inc()
	log.Warn("rate limiter: redis unreachable, using local window", "key", key, "err", err)
	return localWindow.Allow(key, limit/expectedReplicas, window), nil
}

The tradeoff is that a Redis outage can admit up to one local window per process, so the aggregate ceiling can exceed the configured global limit until Redis comes back. That is bounded by the replica count and the same window duration as the shared limiter. Operators get a separate redis_fallback_total counter, which makes the degradation visible without turning it into request errors.