Table of contents (6 sections)
The guard, the check that accepts or refuses a value at the top of a function, fit on one line: if perSecond <= 0 { return error }.
It refuses zero, it refuses negatives, it looks complete.
Then an idiotic question: what if the rate were not a number?
I wrote NaN ("not a number") in the configuration, then sent 100 requests at once to a limiter set to 5 per second.
All 100 got through. No error, not one log line.
That limiter is a rate limiter: a bouncer that counts a client's requests (the calls received) and blocks the ones that exceed a set rate. The library is not to blame, a reflex is. With integers, "refuse zero and negatives" is enough, and I had applied it to a float, a number with a decimal point, which can be NaN.
In Go (the language) it is often written with the library golang.org/x/time/rate. It is built on a bucket of tokens: each request takes one token, and with no token left it is blocked.
The bucket refills at the speed of the rate, a number with a decimal point. The burst is the size of the bucket, full at the start: with 3, a client can send 3 requests at once.
This guard checks the rate, the perSecond parameter.
Two writings that look equivalent do not behave the same with NaN. What follows shows it.
A guard that looks complete
The first reflex is to refuse zero and negatives. Zero is dangerous too, but the opposite of NaN: according to the library code, a zero rate never refills the bucket. The client burns its burst, then everything is blocked. The naive guard:
func New(perSecond float64, burst int) (*rate.Limiter, error) {
if perSecond <= 0 || burst <= 0 {
return nil, fmt.Errorf("rate must be positive, got %v req/s, burst %d", perSecond, burst)
}
return rate.NewLimiter(rate.Limit(perSecond), burst), nil
}
It refuses 0 and −1 and reads without effort.
The rate comes from text, read from an environment variable (a setting read at startup).
The strconv.ParseFloat function converts it to a float (float64 in Go), the name of a number with a decimal point.
A float can be NaN, a special value that means "not a number".
ParseFloat returns the number and an error, but checking the error is not enough. I looked at what it accepts (Go 1.26):
"NaN"and"nan"give NaN, with no error;"Inf"and"+Inf"give+Inf(positive infinity), with no error;"1e400"(1 followed by 400 zeros, too big for afloat64) gives+Inf, with an error.
So a "nan" text in configuration gives a NaN, without the slightest error.
I tried caarlos0/env (v11.4.1), a configuration library that reads these variables for you.
The rate (RATE_PER_SECOND) is a float read by ParseFloat. The burst (RATE_BURST) is an integer, a number without a decimal point, read by ParseInt.
Here is what it returns when I give it NaN or Inf:
RATE_PER_SECOND=NaN RATE_BURST=3 -> {PerSecond:NaN Burst:3} err=<nil>
RATE_PER_SECOND=5 RATE_BURST=NaN -> {PerSecond:5 Burst:0} err=env: parse error on field "Burst" of type "int": strconv.ParseInt: parsing "NaN": invalid syntax
RATE_PER_SECOND=Inf RATE_BURST=3 -> {PerSecond:+Inf Burst:3} err=<nil>
For the burst, the read returns an error: an integer can never be NaN, as we will see below. For the rate, everything goes through silently.
What does the naive guard do with a NaN?
NaN: a bit pattern that is not a number
It all comes down to how each type is stored in memory, as bits (0s and 1s).
A 64-bit integer (int64) uses its 64 bits to write a number.
There are 264 combinations of 0 and 1, and each one is a precise number.
No combination is free to mean "this is not a number": an integer is therefore never NaN.
A float64 follows the IEEE 754 standard, which reserves bit patterns for infinity and for NaN (Not a Number).
Why? A calculation on floats can have no result, like 0 divided by 0 (I measured in Go, with variables: NaN). NaN says so, rather than crashing the program.
The trap: if a variable n is NaN, all the ordering and equality comparisons answer false, even n == n. Only != answers true.
n := math.NaN()
fmt.Println(n == n, n > 0, n <= 0) // false false false
fmt.Println(n != n, math.IsNaN(n)) // true true
n > 0 and n <= 0 are both false, because NaN is neither positive nor negative.
The naive guard asks "perSecond <= 0: is the rate negative or zero?". For NaN the answer is no, so it accepts the value.
Written the other way round, the condition becomes !(perSecond > 0): "the rate is not greater than zero" (the ! means "not"). "Greater than zero" is false for NaN, so the condition is true and the value is refused.
What it does to the limiter: nothing is limited anymore
Here is a program you can run. It reads "NaN" with ParseFloat.
Then it sends 100 requests at the same instant, so with no time to refill the bucket, to two limiters: one with rate 5, one with rate NaN. The burst is 3.
package main
import (
"fmt"
"math"
"strconv"
"time"
"golang.org/x/time/rate"
)
func main() {
v, err := strconv.ParseFloat("NaN", 64)
fmt.Println(v, err, v <= 0, !(v > 0))
now := time.Now()
for _, perSecond := range []float64{5, math.NaN()} {
l := rate.NewLimiter(rate.Limit(perSecond), 3)
allowed := 0
for range 100 {
if l.AllowN(now, 1) {
allowed++
}
}
fmt.Printf("rate %v: %d of 100 allowed\n", perSecond, allowed)
}
}
NaN <nil> false true
rate 5: 3 of 100 allowed
rate NaN: 100 of 100 allowed
In the first line, the value read (v) is NaN, without error (nil means "none"). Then <= 0 is false, so the naive guard accepts NaN, and !(v > 0) is true, so the other writing refuses it.
In the next two, a rate of 5 lets 3 requests out of 100 through (the burst), a rate of NaN lets them all through.
The table adds the rates 0 and +Inf, measured locally with golang.org/x/time v0.16.0.
| Rate | Naive guard <= 0 |
Requests allowed out of 100 (rate passed as is) | Final guard (next section) |
|---|---|---|---|
| 5 | value accepted | 3 | value accepted |
| 0 | value refused | 3 | value refused |
| NaN | value accepted | 100 | value refused |
| +Inf | value accepted | 100 | value refused |
With NaN, everything gets through. Here is why, in the order it happens. On every call, the limiter adds "elapsed time × rate" tokens to the bucket, without exceeding its size, then takes out the request's token. If the number of tokens falls below zero, there were none left: the request is blocked. With a NaN rate, the addition is NaN, since any arithmetic operation (+, −, ×, ÷) that touches a NaN gives NaN. The number of tokens becomes NaN, the "below zero?" comparison answers false, and the request goes through.
+Inf also gives 100 out of 100, by another path that I do not detail here.
The worst part is the silence. The service starts normally, and the library prints no message. A limit that disappears breaks nothing as long as nobody abuses it. A test (a small program that checks the code) that only tries normal values does not see it either.
I am not the only one passing the rate straight to the limiter. The rate limiting middleware of echo (v4.13.4), a piece of code that runs before every request in this Go web framework, does it at line 244 of rate_limiter.go.
Nothing there checks the rate first, so it is up to you to refuse a NaN upstream. I did not test what a NaN does inside echo.
Write what is valid, refuse the rest
The fix comes down to one principle: instead of listing what you refuse, write what you accept.
A valid rate is a strictly positive number, small enough to be a real limit: not NaN, not infinite, not the largest value of a float64.
The final guard:
func New(perSecond float64, burst int) (*rate.Limiter, error) {
valid := perSecond > 0 && perSecond < math.MaxFloat64
if !valid || burst <= 0 {
return nil, fmt.Errorf("rate must be a finite positive number, got %v req/s, burst %d", perSecond, burst)
}
return rate.NewLimiter(rate.Limit(perSecond), burst), nil
}
perSecond > 0 is false for NaN, so the final guard refuses it without a special case.
perSecond < math.MaxFloat64 refuses +Inf, and also math.MaxFloat64 itself: the library names it rate.Inf and treats it as "no limit".
In a real service I would rather use a business bound, for example perSecond <= 100_000: nobody needs 10308 requests per second, and it reads by itself.
The test of the final guard is a few lines long:
func TestNewRefusesRatesThatAreNotUsable(t *testing.T) {
for _, perSecond := range []float64{0, -1, math.NaN(), math.Inf(1), math.Inf(-1), math.MaxFloat64} {
if _, err := New(perSecond, 3); err == nil {
t.Errorf("rate %v accepted, want an error", perSecond)
}
}
}
I checked that it is worth something: with the naive guard in its place, New returns no error for NaN and the test fails, as intended.
It prints rate NaN accepted, want an error.
A test that never fails proves nothing.
Conclusion
With integers, "refuse zero and negatives" is enough, because every integer is negative, zero or positive. A float has one value that is neither negative, nor zero, nor positive: NaN. My code reasoned as if on integers, and that reflex breaks silently on a float.
My view: a guard should describe what you accept, never what you refuse. Refusing a list of cases is a bet that you thought of them all. Its test must contain the values that only a float has: NaN, the infinities and the largest float. Put it at startup: a service that refuses to start gets noticed, a silent limiter does not. For the token bucket mechanism itself, I covered it in golang.org/x/time/rate: a per-IP rate limiter in Go (full example). For middleware order and traps in production, see Go middleware best practices in production.
Sources
Documentation consulted on November 2, 2026:
golang.org/x/time/rate, token bucket limiter: pkg.go.dev/golang.org/x/time/ratestrconv.ParseFloat, accepted values: pkg.go.dev/strconv#ParseFloatmath.NaN,math.MaxFloat64: pkg.go.dev/math#NaN- Environment variable reading,
caarlos0/envv11.4.1: pkg.go.dev/github.com/caarlos0/env/v11 - echo rate limiting middleware: echo.labstack.com/docs/middleware/rate-limiter
- echo middleware code (v4.13.4): github.com/labstack/echo, middleware/rate_limiter.go
- IEEE 754, float format: en.wikipedia.org/wiki/IEEE_754