Skip to content

Testing a Go Rate Limiter Without Waiting for Real Time

Adityo Guni Waluyo

Swap the clock in Go tests: a rate limiter proven without sleep, plus when to graduate to testing/synctest.

TL;DR

Rate limiter tests shouldn't chase real time with time.Sleep, which makes them slow and flaky. Instead, expose a Now func() time.Time field on the struct and swap in a fixed clock, letting tests advance time instantly with no sleeps. For code that sleeps internally, Go's stable testing/synctest bubble fakes the clock.

I was writing a test for a rate limiter. The requirement is blunt: the fourth request from the same key within one minute must be rejected, complete with the remaining reset time. The problem is classic. To make that fourth hit happen inside a test, I have to wait, and waiting a real minute inside a suite that runs continuously is not a sensible option.

The shortcut everyone tries first is time.Sleep. I have been down that road, and both outcomes are bad: the test gets slow, then it still fails at random on a busy CI box where even a one-minute pause can be too short. The official Go documentation sums up this dilemma honestly for a similar case: sleep-based tests are slow and flaky at once, and you cannot make them fast and reliable together [4]. While the clock is real time, you only get two bad choices.

A Clock You Can Swap from the Test Package

In the commit I am looking at, the fix does not add a shroud around the component; it swaps the component itself: the clock. The rate limiter struct carries a Now func() time.Time field that defaults to time.Now. The field was exported on purpose so tests can install their own clock. It used to be private, with the tests sitting in the same package. Once the tests moved to an external package, the only door left is a public one, so the clock is what got offered through that door.

func TestRateLimiterWindow(t *testing.T) {
    now := time.Now()
    l := middleware.NewRateLimiter(3, time.Minute)
    l.Now = func() time.Time { return now } // clock swapped in from the test package

    for i := 0; i < 3; i++ {
        if ok, _ := l.Allow("k"); !ok {
            t.Fatalf("hit %d should pass", i+1)
        }
    }

    // 4th hit inside the window: denied, with the reset delay returned
    if ok, retryAfter := l.Allow("k"); ok || retryAfter <= 0 {
        t.Fatalf("4th hit should be blocked, got ok=%v retry=%v", ok, retryAfter)
    }

    // another key: its own bucket
    if ok, _ := l.Allow("other"); !ok {
        t.Fatal("other key should be independent")
    }

    // shift the clock past the window: quota is fully back
    now = now.Add(time.Minute + time.Second)
    if ok, _ := l.Allow("k"); !ok {
        t.Fatal("hit after window should pass")
    }
}

There is not a single time.Sleep in there. The test advances the clock with now.Add(time.Minute + time.Second), and every old assertion still holds verbatim: the fourth hit is denied with a positive retryAfter, another key owns its own bucket, and the quota is fully back once the window passes. The whole test runs as fast as any other.

The design note reads well too: a func field is enough, no need for an interface with a single implementation. The contract is exactly one thing, "when is now", and it is readable straight off the function signature. The same pattern shows up in the neighboring commits: the avatar directory is injected through the NewService parameter, and field length caps such as MaxTitleLen are exported so tests can assert the exact boundaries. One family: whatever the test needs gets offered through a public door.

When to Graduate to synctest

The manual pattern above is portable and dependency-free, but the Go ecosystem now ships a built-in answer. The testing/synctest package became stable in Go 1.25 [2]. Before that, in Go 1.24, it was experimental behind GOEXPERIMENT=synctest [3], with an old API scheduled for removal in Go 1.26; if you stumble on older examples calling synctest.Run, that is legacy code now.

It works through the bubble concept: synctest.Test runs the test inside an isolated group of goroutines. Inside a bubble, the time package uses a fake clock owned by that bubble, starting at midnight UTC 2000-01-01 [1]. The clock only jumps forward when every goroutine in the bubble is durably blocked, so a two-second sleep finishes a moment later. The Wait function blocks until every goroutine in the bubble is blocked. The boundary is clear: if what you are testing is single-flow time logic, a func field is enough; if the code under test sleeps on its own or has goroutines that need choreography, the bubble wins.

The rule of thumb I take from this commit: never chase real time in tests. Swap the clock, or isolate time itself. Production code here did not change at all; one field got promoted to public, and the suite gained a path that is fast and dependable at the same time.

Sources

Related articles