Go: a float can be NaN, and the rate limiter stops limiting anything

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 a float64) 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)
5value accepted3value accepted
0value refused3value refused
NaNvalue accepted100value refused
+Infvalue accepted100value 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.

Four steps: a NaN rate turns the number of tokens in the bucket into NaN, the "below zero" comparison is false, so no request is blocked. 1. perSecond is NaN the config "NaN" is read without error 2. addition: time × NaN = NaN new number of tokens = old + NaN = NaN 3. tokens < 0 ? False NaN is neither smaller nor greater than 0 4. not blocked: request allowed
One NaN going in, and no request is blocked.

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:

Comments (0)