惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Help Net Security
Help Net Security
U
Unit 42
T
Tailwind CSS Blog
Y
Y Combinator Blog
阮一峰的网络日志
阮一峰的网络日志
博客园_首页
云风的 BLOG
云风的 BLOG
博客园 - Franky
D
DataBreaches.Net
Last Week in AI
Last Week in AI
人人都是产品经理
人人都是产品经理
Cisco Talos Blog
Cisco Talos Blog
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Blog — PlanetScale
Blog — PlanetScale
Know Your Adversary
Know Your Adversary
宝玉的分享
宝玉的分享
V
Visual Studio Blog
AWS News Blog
AWS News Blog
NISL@THU
NISL@THU
I
Intezer
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
P
Privacy International News Feed
T
Tor Project blog
S
Securelist
Microsoft Security Blog
Microsoft Security Blog
C
Cybersecurity and Infrastructure Security Agency CISA
Recorded Future
Recorded Future
C
Cisco Blogs
P
Palo Alto Networks Blog
Hacker News: Ask HN
Hacker News: Ask HN
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Recent Commits to openclaw:main
Recent Commits to openclaw:main
月光博客
月光博客
T
Threat Research - Cisco Blogs
N
News and Events Feed by Topic
AI
AI
Cyberwarzone
Cyberwarzone
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
MongoDB | Blog
MongoDB | Blog
Microsoft Azure Blog
Microsoft Azure Blog
Scott Helme
Scott Helme
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
Martin Fowler
Martin Fowler
量子位
L
LINUX DO - 热门话题
H
Heimdal Security Blog
GbyAI
GbyAI
P
Privacy & Cybersecurity Law Blog
博客园 - 【当耐特】

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Building a Sliding Window Rate Limiter in Redis for a Multi-Region Video API
ahmet gedik · 2026-06-18 · via DEV Community

Twice in the same week last quarter, our YouTube Data API quota hit zero before 09:00 UTC, and the trending feeds for half our regions went stale for the rest of the day. I run TrendVidStream, a global video streaming discovery site that ingests trending-video data for 8 regions on a cron schedule, and the culprit was not traffic growth. It was our rate limiter. A fixed-window counter let two full bursts of API calls through within seconds of each other whenever a window boundary lined up with the cron schedule — and with 8 regional fetch jobs firing at staggered offsets, boundaries lined up far more often than intuition suggested they would.

This post walks through how we replaced that fixed-window counter with a sliding window log in Redis: the data model, the Lua script that makes it atomic, weighted costs for APIs where one call can be 100x more expensive than another, and the failure modes we hit running it across a multi-region ingestion pipeline. The stack is PHP 8.4 talking to Redis through phpredis, with a Python harness for verifying boundary behavior, but every part of the design transfers to any language with a Redis client.

Why the Fixed Window Failed Us

The original limiter was the one everybody writes first, because it is three lines of Redis and it mostly works:

<?php

function allowFixedWindow(Redis $redis, string $key, int $limit, int $windowSec): bool
{
    $bucket = sprintf('%s:%d', $key, intdiv(time(), $windowSec));
    $count  = $redis->incr($bucket);

    if ($count === 1) {
        $redis->expire($bucket, $windowSec * 2);
    }

    return $count <= $limit;
}

Every request increments a counter named after the current window bucket. When the counter exceeds the limit, requests are rejected until the clock rolls into the next bucket and a fresh counter starts at zero.

The problem is exactly that rollover. Suppose the limit is 100 requests per minute. A client — or in our case, a burst of regional cron jobs — can send 100 requests at 11:59:59 and another 100 at 12:00:01. Both bursts are accepted, because each lands in a different bucket. The effective rate over those two seconds is 200 requests, double the configured limit, and the limiter considers everything fine.

For a public API this is an annoyance. For an ingestion pipeline metered by a hard daily quota, it is a budget leak. The YouTube Data API gives you 10,000 units per day per key. Our fetch pipeline runs for 8 regions (US, GB, DE, FR, IN, BR, AU, CA), and each regional pass makes a sequence of paginated calls. When two regional jobs straddled a window boundary, the limiter happily authorized roughly twice the intended spend in a few seconds. Do that a handful of times overnight and the quota is gone before the morning fetch cycle even starts.

The fix is not a smaller window or a fudge factor on the limit. The fix is a limiter whose window actually slides.

A Sliding Window Log on Sorted Sets

A sliding window log stores a timestamp for every accepted request and answers one question: how many requests happened in the last N seconds, measured from right now — not from the last round-number boundary. Redis sorted sets are almost suspiciously well shaped for this:

  • ZADD key timestamp member records a request, with the timestamp as the score.
  • ZREMRANGEBYSCORE key 0 (now - window) evicts everything older than the window in one call.
  • ZCARD key counts what remains — the exact number of requests inside the sliding window.
  • PEXPIRE key window lets idle keys delete themselves, so abandoned limiters cost nothing.

There is no boundary to straddle because there is no boundary. At any instant, the set contains precisely the requests from the trailing window, and the count is exact rather than the interpolated estimate you get from the two-bucket sliding window counter approach that Cloudflare popularized.

The trade-off is memory: one sorted-set member per accepted request. People reflexively reject the log approach over this, but run the numbers for your actual workload first. A sorted-set entry costs on the order of 100 bytes with overhead. Our API key budget allows roughly 10,000 units per day, so a fully saturated daily window holds about 10,000 members — around 1 MB. For a public per-IP limiter at 60 requests per minute, each IP key tops out at 60 members. Unless you are limiting millions of requests per second against a single key, the exactness is cheap. If you ever do reach that scale, the upgrade path is a bucketed counter, and I will come back to that at the end.

The naive implementation, though, has a race. If you run ZREMRANGEBYSCORE, then ZCARD, then decide in application code whether to ZADD, two concurrent clients can both read a count of 99 against a limit of 100 and both insert. Pipelining does not save you, because the decision depends on a value read mid-sequence. This is exactly what Redis server-side Lua scripting is for: the whole check-and-record becomes one atomic unit.

Making It Atomic With Lua

Here is the limiter we run in production, trimmed of logging. PHP 8.4, phpredis, one Lua script:

<?php

declare(strict_types=1);

final class SlidingWindowLimiter
{
    private const string LUA = <<<'LUA'
        local key    = KEYS[1]
        local window = tonumber(ARGV[1]) -- microseconds
        local limit  = tonumber(ARGV[2])
        local cost   = tonumber(ARGV[3])
        local member = ARGV[4]

        local t   = redis.call('TIME')
        local now = t[1] * 1000000 + t[2]

        redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

        local used = redis.call('ZCARD', key)
        if used + cost > limit then
            local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
            local retryAfter = 1
            if oldest[2] then
                retryAfter = math.ceil((tonumber(oldest[2]) + window - now) / 1000000)
            end
            return {0, limit - used, retryAfter}
        end

        for i = 1, cost do
            redis.call('ZADD', key, now, member .. ':' .. i)
        end
        redis.call('PEXPIRE', key, math.ceil(window / 1000) + 1000)

        return {1, limit - used - cost, 0}
        LUA;

    private ?string $sha = null;

    public function __construct(
        private readonly Redis $redis,
        private readonly int $limit,
        private readonly int $windowSeconds,
    ) {
        if ($limit < 1 || $windowSeconds < 1) {
            throw new InvalidArgumentException('limit and window must be positive');
        }
    }

    public function attempt(string $key, int $cost = 1): RateLimitResult
    {
        if ($cost > $this->limit) {
            throw new InvalidArgumentException('cost exceeds limit; request can never succeed');
        }

        $args = [
            $key,
            (string) ($this->windowSeconds * 1_000_000),
            (string) $this->limit,
            (string) $cost,
            bin2hex(random_bytes(8)),
        ];

        $this->sha ??= $this->redis->script('load', self::LUA);

        try {
            $reply = $this->redis->evalSha($this->sha, $args, 1);
        } catch (RedisException) {
            // script cache flushed (failover, restart) — reload transparently
            $reply = $this->redis->eval(self::LUA, $args, 1);
        }

        return new RateLimitResult(
            allowed: (bool) $reply[0],
            remaining: max(0, (int) $reply[1]),
            retryAfterSeconds: (int) $reply[2],
        );
    }
}

final readonly class RateLimitResult
{
    public function __construct(
        public bool $allowed,
        public int $remaining,
        public int $retryAfterSeconds,
    ) {}
}

A few decisions in there earned their place the hard way:

  • The clock lives inside Redis. The script calls TIME on the server instead of accepting a timestamp from PHP. Our PHP runs on shared LiteSpeed hosting boxes that we deploy to over FTP; we do not control their NTP discipline, and we once watched two web nodes disagree by 1.4 seconds. With eight cron sources writing to one limiter, a single authoritative clock removes a whole category of bugs. One caveat: calling TIME before a write requires Redis 5 or newer, where scripts replicate by effects rather than verbatim. On anything older the script is rejected as non-deterministic.
  • Microsecond scores. With second-resolution scores, two requests in the same second from the same caller could collide on score and member. Microseconds plus a random member suffix make every entry unique.
  • Retry-After is computed, not guessed. The oldest surviving entry plus the window length tells you exactly when capacity frees up. For the public API we surface that in a real Retry-After header on the 429; for cron, it tells the job whether to sleep or abandon the run.
  • EVALSHA with EVAL fallback. Script caching saves bandwidth on every call, and the fallback path means a Redis restart or failover never turns into an application error.
  • The cost-versus-limit guard. A request whose cost exceeds the limit would be rejected forever while looking like a transient throttle. Failing loudly at the call site beats an infinite retry loop at 3 a.m.

Weighted Costs Because API Calls Are Not Equal

That cost parameter is doing more work than it looks like. Upstream quotas are rarely flat per-request. On the YouTube Data API, a videos.list call costs 1 unit while a search.list call costs 100 — a two-orders-of-magnitude spread within one quota pool. A limiter that counts requests instead of units will confidently approve you into a quota lockout.

The script handles weight by inserting cost members per call, each sharing a timestamp and differing by suffix. ZCARD then equals units consumed in the window, and eviction stays a single ZREMRANGEBYSCORE. Inserting 100 members for one expensive search call sounds wasteful, but our pipeline issues a handful of search calls per fetch cycle — the loop runs in microseconds and the memory ceiling is still bounded by the quota itself. If your costs ran into the tens of thousands per call, you would store the cost as part of the member payload and switch the counting strategy; for realistic API quota weights, brute force is simpler and exact.

Here is the shape of the ingestion loop that consumes it, condensed from our fetch job:

<?php

const QUOTA_COSTS = [
    'search.list'   => 100,
    'videos.list'   => 1,
    'channels.list' => 1,
];

const REGIONS = ['US', 'GB', 'DE', 'FR', 'IN', 'BR', 'AU', 'CA'];

// 9500, not 10000: headroom for backfill jobs and manual debugging
$limiter = new SlidingWindowLimiter($redis, limit: 9500, windowSeconds: 86400);

foreach (REGIONS as $region) {
    $result = $limiter->attempt('yt:quota:key1', QUOTA_COSTS['videos.list']);

    if (!$result->allowed) {
        error_log(sprintf(
            'quota exhausted before %s; %d units left, retry in %ds — ending run',
            $region,
            $result->remaining,
            $result->retryAfterSeconds,
        ));
        break; // the next cron tick picks up where we stopped
    }

    fetchTrendingPage($region);
}

Two details matter here. The limit is set below the real quota, because the limiter should run out before the upstream does — a 403 from Google is a worse failure signal than your own clean rejection, and the reserve absorbs ad-hoc API usage during debugging. And on rejection the job breaks instead of sleeping: a daily-window limiter can return a Retry-After of hours, and a cron job that sleeps for hours is a process leak. Stop cleanly, let the schedule resume.

One Limiter, Eight Regions

The multi-region wrinkle is mostly about choosing the right key, and the right key follows the scope of the resource you are protecting:

  • Upstream API quota is per API key, not per region, so all 8 regional jobs share one limiter key per credential: yt:quota:key1. Splitting it per region would just hide overspend until the upstream said no.
  • Public endpoint limits are per client, so the watch and discovery endpoints use api:ip:{addr} keys with a 60-per-minute window.
  • Expensive internal operations get their own narrow keys — search.list calls have a dedicated limiter on top of the shared quota one, because a bug in search pagination once ate 4,000 units in ten minutes and we never want a single endpoint to be able to do that again.

Our deployment model makes the case for Redis sharper than usual. The PHP application is deployed over FTP to LiteSpeed shared hosting — there is no long-lived daemon, no APCu shared across workers that survives meaningfully, and each request or cron invocation is an isolated process. Limiter state has to live outside the PHP lifecycle entirely, so it lives on a small Redis VPS that all environments reach. The regional cron jobs are staggered by minute offsets so their bursts interleave rather than stack, but staggering is an optimization, not a correctness mechanism — the sliding window is what holds the line when schedules drift or a job overruns.

It is also worth engineering down the number of metered calls you need at all. Our user-facing search never touches the upstream API: ingested metadata lands in SQLite with an FTS5 index, and queries are served locally for free. The entire API budget goes to ingestion, where the limiter governs a small number of well-understood jobs instead of unpredictable user traffic. The cheapest rate-limited call is the one you replaced with a local index.

Proving the Boundary Behavior

Claims about window boundaries deserve a demonstration, not a diagram. This Python script lines both limiters up against a window boundary and fires a full-limit burst on each side of it:

import time
import redis

r = redis.Redis(decode_responses=True)

WINDOW = 10   # seconds
LIMIT = 50

sliding = r.register_script('''
local key    = KEYS[1]
local now    = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit  = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
if redis.call('ZCARD', key) >= limit then
    return 0
end
redis.call('ZADD', key, now, tostring(now) .. '-' .. tostring(math.random()))
redis.call('EXPIRE', key, window + 1)
return 1
''')

def fixed_allow(key):
    bucket = f'{key}:{int(time.time() // WINDOW)}'
    n = r.incr(bucket)
    r.expire(bucket, WINDOW * 2)
    return 1 if n <= LIMIT else 0

def sliding_allow(key):
    return int(sliding(keys=[key], args=[time.time(), WINDOW, LIMIT]))

def burst(n, fn):
    return sum(fn() for _ in range(n))

# park ourselves one second before the next window boundary
now = time.time()
boundary = (int(now // WINDOW) + 1) * WINDOW
time.sleep(max(0.0, boundary - 1 - now))

before_f = burst(LIMIT, lambda: fixed_allow('demo:fixed'))
before_s = burst(LIMIT, lambda: sliding_allow('demo:sliding'))

time.sleep(2)  # cross the boundary

after_f = burst(LIMIT, lambda: fixed_allow('demo:fixed'))
after_s = burst(LIMIT, lambda: sliding_allow('demo:sliding'))

print(f'fixed   accepted {before_f + after_f} requests in ~3s (limit {LIMIT}/{WINDOW}s)')
print(f'sliding accepted {before_s + after_s} requests in ~3s (limit {LIMIT}/{WINDOW}s)')

On my machine the output is the whole argument in two lines:

  • fixed accepted 100 requests in ~3s (limit 50/10s)
  • sliding accepted 50 requests in ~3s (limit 50/10s)

The fixed window approved exactly double its stated limit across the boundary, which is the production incident from the introduction reproduced in miniature. The sliding window held the line at 50 regardless of where the burst landed relative to the clock. Same Redis, same limit, same traffic — the only difference is whether the window slides.

Failure Modes Worth Planning For

A rate limiter sits on the critical path of every request it guards, so its own failures need decisions made in advance rather than at incident time:

  • Redis is unreachable. Decide fail-open versus fail-closed per call site, not globally. Our ingestion cron fails open for 1-unit calls — a brief unmetered trickle is cheaper than a stale site — but fails closed for 100-unit search calls, where a limiter outage coinciding with a pagination bug is exactly the scenario that drains a quota. Wrap the attempt in a try/catch and make the policy explicit in code.
  • Clock skew between writers. Solved here by using Redis TIME inside the script. If you instead pass timestamps from application servers, every server's skew becomes window distortion, and the limiter silently becomes more or less permissive per node.
  • Cost greater than limit. Validate at the call site. A request that can never be approved looks identical to a throttled one from the outside and will retry forever.
  • Unbounded key growth. The PEXPIRE on every touch means keys for departed clients evaporate on their own. Without it, a per-IP limiter is a slow memory leak indexed by every address that ever visited.
  • Hot keys at high scale. All operations on one limiter key hit one shard. At our volume — thousands of decisions per hour — this is irrelevant, but past several thousand decisions per second on a single key you would shard the key by a hash suffix and sum counts, or move to a bucketed sliding counter: fixed sub-buckets (say, 1-second hash fields) summed over the trailing window. You trade exactness at the bucket edge for O(1) memory per limiter. Build the exact version first; you will know from your metrics if you ever need the approximation.
  • Script cache eviction. SCRIPT FLUSH, failover, or a restart empties the EVALSHA cache. The EVAL fallback in the PHP class turns that from an error spike into one slightly slower call.

Conclusion

The fixed-window limiter is fine right up until the cost of a boundary burst exceeds the cost of doing it properly — and with hard upstream quotas, that point arrives early. The sliding window log on Redis sorted sets gives you exact counting, honest Retry-After values, weighted costs for unequal calls, and a shared decision point for however many regions, cron jobs, and web nodes are spending from the same budget. The whole thing is one Lua script and a thin client class.

Since swapping it in, the quota has not been exhausted once, regional fetch jobs degrade by stopping cleanly instead of slamming into upstream 403s, and the 429s our public API returns now come with a Retry-After header that tells the truth. If your limiter resets on a round number of the clock, you do not have a rate limit — you have a rate suggestion with a loophole twice per window.