How the DST Fall-Back Hour Made Our Rate Limiter Forget the Last Minute, Every Minute, for an Hour
← Back
September 4, 2026Architecture9 min read

How the DST Fall-Back Hour Made Our Rate Limiter Forget the Last Minute, Every Minute, for an Hour

Published September 4, 20269 min read

01:47 EST, Sunday, November 1st. PagerDuty: "checkout-api: p99 latency 4.2s, up from 180ms." Two minutes later, a second alert: "inventory-svc: request rate 2.3x baseline." Nothing had deployed in six hours. Nothing had changed in the traffic mix. The dashboards just started climbing at 01:00 and kept going.


the scramble

First theory, the obvious one for a Sunday night: bot traffic. On-call pulled the Cloudflare firewall events for the last hour. Nothing unusual, no new ASNs, no spike in a single IP range, no user-agent anomaly. Whatever was hitting checkout was behaving like normal browser traffic, just more of it than the rate limiter should have allowed through.

Second theory: the rate limiter itself was down and failing open. It wasn't. The limiter's own health check was green, and its own admitted-request counter, the one it logs internally, still showed traffic under the configured cap of 600 requests per minute per client. That was the detail that mattered and got skipped past twice before anyone noticed it: the limiter believed it was doing its job. The actual request volume hitting inventory-svc downstream said otherwise.

Third theory came from someone who'd been stress-testing checkout the week before and still had a terminal open on the limiter's Redis instance. Go look at what the buckets actually contain.

redis-cli, 01:52 EST
> KEYS ratelimit:client_8841:*
ratelimit:client_8841:202611010147
> TTL ratelimit:client_8841:202611010147
(integer) 43
> GET ratelimit:client_8841:202611010146
(nil)

One bucket for the current minute, nothing for the one right before it. On a normal night that nil would be unremarkable, buckets expire, that's the point. But the sliding window logic summed the current minute's count with the previous minute's count to decide whether a request was over the limit, and if the previous bucket kept coming back empty, the limiter was only ever seeing one minute of traffic at a time. Effectively half its memory, every single minute, for the whole hour.


the hunt

The bucket key was the first thing worth reading closely. 202611010147 wasn't a UTC timestamp, it was a formatted local datetime: YYYYMMDDHHMM in America/New_York. That had been true since the limiter shipped eight months earlier, written by someone who wanted human-readable Redis keys for on-call debugging and didn't think about what "local" meant for arithmetic.

ratelimit/sliding_window.py, before
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo

LOCAL_TZ = ZoneInfo("America/New_York")
BUCKET_TTL_SECONDS = 120
LIMIT_PER_MINUTE = 600

def bucket_key(client_id: str, when: datetime) -> str:
    local = when.astimezone(LOCAL_TZ)
    return f"ratelimit:{client_id}:{local.strftime('%Y%m%d%H%M')}"

def is_allowed(client_id: str) -> bool:
    now = datetime.now(LOCAL_TZ)
    current_key = bucket_key(client_id, now)
    previous_key = bucket_key(client_id, now - timedelta(minutes=1))

    current = int(redis.get(current_key) or 0)
    previous = int(redis.get(previous_key) or 0)

    # weight the previous bucket by how far into the current minute we are
    elapsed_fraction = now.second / 60
    weighted = current + previous * (1 - elapsed_fraction)

    if weighted >= LIMIT_PER_MINUTE:
        return False

    pipe = redis.pipeline()
    pipe.incr(current_key)
    pipe.expire(current_key, BUCKET_TTL_SECONDS)
    pipe.execute()
    return True

This is a standard sliding-window-counter implementation, the kind that shows up in most rate limiting writeups, ours included. It works because now - timedelta(minutes=1) should point at the bucket key that was written sixty real seconds ago. On November 1st at 2:00 AM local, clocks in America/New_York fell back to 1:00 AM, and the hour from 1:00 to 2:00 happened twice. During the second pass through it, now - timedelta(minutes=1), computed on an aware datetime, correctly subtracted sixty real seconds, that part of Python's datetime arithmetic is timezone-correct. The break was one line earlier: bucket_key() immediately threw that correctness away by formatting the result back down to a naive string with no offset in it. 01:47 EDT and 01:47 EST, sixty minutes apart in real time, format to the exact same key: 202611010147.

That collision wasn't the direct problem, the bucket from the first pass through 1:47 AM had long since expired (120 second TTL) by the time the clock looped back to it an hour later. The direct problem was what the collision did to the previous-minute lookup. During the repeated hour, now was local time that had already been used once before, an hour earlier in real time. Subtracting one minute from it produced a previous-key string that had also already been used and expired an hour ago, well outside its 120-second TTL. Every single lookup for the previous bucket came back nil, for the entire repeated hour, not because of a collision but because the naive key format made the service ask Redis for a bucket that, from Redis's perspective, was ancient history.

With previous pinned at zero, the weighted sum in is_allowed() reduced to just the current minute's count. The limiter wasn't blocking anything until a single minute bucket alone hit 600, instead of the rolling 600-per-any-sixty-seconds it was designed to enforce. A client sending 550 requests in the last five seconds of one minute and another 550 in the first five seconds of the next sailed through both checks, because neither minute's bucket crossed the threshold on its own. Sustained throughput roughly doubled for the hour, and it landed on a Sunday night when nothing else was different enough to make it obvious sooner.


the find

Root cause: the rate limiter's sliding window depended on the previous minute's Redis key being reachable by subtracting one minute and reformatting. Formatting that lookup key from local wall clock time instead of UTC meant the fall-back DST transition, where local time is not monotonic, produced previous-minute keys that pointed at buckets from an hour earlier rather than sixty seconds earlier. Those buckets had long since expired, so the previous-minute contribution silently dropped to zero for the entire repeated hour, and the limiter enforced roughly half its configured strictness without any error, alert, or log line indicating degraded behavior. It looked, from every internal metric the limiter exposed, like it was working correctly.


the fix

The fix was to stop using local time in the bucket key entirely. UTC doesn't observe DST, so a UTC-epoch-derived key is monotonic across every real minute boundary, including the ones that happen twice or get skipped on local clocks:

ratelimit/sliding_window.py, after
import time

BUCKET_TTL_SECONDS = 120
LIMIT_PER_MINUTE = 600

def bucket_key(client_id: str, epoch_seconds: float) -> str:
    minute_bucket = int(epoch_seconds // 60)
    return f"ratelimit:{client_id}:{minute_bucket}"

def is_allowed(client_id: str) -> bool:
    now = time.time()  # UTC epoch seconds, unaffected by local DST rules
    current_key = bucket_key(client_id, now)
    previous_key = bucket_key(client_id, now - 60)

    current = int(redis.get(current_key) or 0)
    previous = int(redis.get(previous_key) or 0)

    elapsed_fraction = (now % 60) / 60
    weighted = current + previous * (1 - elapsed_fraction)

    if weighted >= LIMIT_PER_MINUTE:
        return False

    pipe = redis.pipeline()
    pipe.incr(current_key)
    pipe.expire(current_key, BUCKET_TTL_SECONDS)
    pipe.execute()
    return True

The local-time formatting is still useful for on-call debugging, so we kept it, but as a separate, non-functional label attached to the same Redis key rather than as the key itself:

redis-cli, after the fix
> HGETALL ratelimit:client_8841:29283412
count            412
local_label      2026-11-01 01:47 EST

We also added a test that fast-forwards the process clock across a synthetic DST fall-back boundary and asserts the previous-minute bucket is still reachable, so this class of bug fails in CI instead of on the one Sunday night a year it can actually happen:

tests/test_sliding_window_dst.py
def test_previous_bucket_reachable_across_dst_fallback(fake_redis, freeze_time):
    # 2026-11-01 01:47 EDT, then again 2026-11-01 01:47 EST, one real hour later
    freeze_time.move_to("2026-11-01T05:47:00+00:00")  # 01:47 EDT
    is_allowed("client_test")
    freeze_time.move_to("2026-11-01T06:47:00+00:00")  # 01:47 EST, same local clock string
    key_now = bucket_key("client_test", time.time())
    key_60s_ago = bucket_key("client_test", time.time() - 60)
    assert key_now != key_60s_ago

Last, an alert directly on the signal that would have surfaced this in minutes instead of the 47 it actually took: a comparison between the limiter's admitted-request count and the actual request volume the downstream service recorded.

datadog monitor
metric: ratio(inventory_svc.requests_received, ratelimit.requests_admitted)
scope: service:checkout-api
threshold: > 1.15 for 10 minutes
message: "Downstream request volume exceeds what the rate limiter reports admitting.
  Check whether the limiter's window logic is degraded before assuming a traffic spike."

the aftermath

47 min Time from first alert to root cause
60 min Total window during which the limiter was degraded
~2.1x Peak sustained throughput over the configured limit
0 Errors, alerts, or log lines from the limiter itself during the incident

Inventory-svc held, barely, its own connection pool queued instead of rejecting, which is the only reason this stayed a latency incident instead of a checkout outage. Nothing was overwritten, no orders were lost, no data was wrong. The cost was entirely in degraded service during a window when nobody could explain why a system that claimed to be enforcing its limit clearly wasn't.

  • Never format a timestamp to local wall-clock time before using it in arithmetic that assumes time moves forward monotonically. UTC doesn't observe DST; local time can repeat an hour or skip one, twice a year, on a schedule you can look up in advance.
  • A rate limiter that silently under-enforces is more dangerous than one that fails closed. It produces no error, no alert, and every internal metric it reports looks correct, because from its own point of view it is correctly enforcing a window, just the wrong window.
  • If a system's job is to compare "now" against "the past," test it across the two nights a year when local time isn't a monotonic function of real time. It costs one frozen-clock test.
  • Watch the ratio between what a control system reports admitting and what the thing it protects actually receives. That gap is often the only signal you get when the control system is wrong about its own behavior.

The fix shipped Monday morning, five lines changed. The bug had been live for eight months, correct every single day except the one night a year local time folds back on itself, which is exactly the kind of bug that survives code review, survives load testing, and only introduces itself once, on a clock nobody thought to distrust.

Share this
← All Posts9 min read