tailscale/tailscale · warning

cannot accept connection; rate limited

Error message

cannot accept connection; rate limited

What it means

derper wraps its listener with a token-bucket rate limiter (--accept-connection-limit rate with --accept-connection-burst burst; both effectively unlimited by default, derper.go:88-89). When the bucket is empty, Accept() returns errLimitedConn and bumps counter_rejected_connections; the comment notes connections are still accepted and immediately closed so clients see an active server and retry with backoff rather than piling up in the kernel backlog.

Source

Thrown at cmd/derper/derper.go:511

	numRejects expvar.Int

	net.Listener

	lim *rate.Limiter
}

func newRateLimitedListener(ln net.Listener, limit rate.Limit, burst int) *rateLimitedListener {
	return &rateLimitedListener{Listener: ln, lim: rate.NewLimiter(limit, burst)}
}

func (ln *rateLimitedListener) ExpVar() expvar.Var {
	m := new(metrics.Set)
	m.Set("counter_accepted_connections", &ln.numAccepts)
	m.Set("counter_rejected_connections", &ln.numRejects)
	return m
}

var errLimitedConn = errors.New("cannot accept connection; rate limited")

func (ln *rateLimitedListener) Accept() (net.Conn, error) {
	// Even under a rate limited situation, we accept the connection immediately
	// and close it, rather than being slow at accepting new connections.
	// This provides two benefits: 1) it signals to the client that something
	// is going on on the server, and 2) it prevents new connections from
	// piling up and occupying resources in the OS kernel.
	// The client will retry as needing (with backoffs in place).
	cn, err := ln.Listener.Accept()
	if err != nil {
		return nil, err
	}
	if !ln.lim.Allow() {
		ln.numRejects.Add(1)
		cn.Close()
		return nil, errLimitedConn
	}
	ln.numAccepts.Add(1)

View on GitHub (pinned to cfe32b8be6)

Solutions

  1. Treat it as expected behavior first — Tailscale clients retry with backoff, so brief bursts are harmless
  2. If legitimate traffic is being shed, raise --accept-connection-limit and --accept-connection-burst to fit your client population
  3. Check /debug/vars counter_rejected_connections over time to size the limit instead of guessing
  4. If caused by health-check probes, spread or slow the probes rather than raising the global limit

Example fix

# before
derper -a :443 --accept-connection-limit=5

# after (sized for reconnect storms)
derper -a :443 --accept-connection-limit=100 --accept-connection-burst=200
Defensive patterns

Strategy: retry

Validate before calling

curl -s "http://derper-host/debug/vars" | python3 -c "import json,sys; d=json.load(sys.stdin); r=d.get('counter_rejected_connections',{}); print('rejected:', r)"

Try / catch

// Client-side accept loop pattern when embedding derper's listener type:
for {
    conn, err := ln.Accept()
    if err != nil {
        if err.Error() == "cannot accept connection; rate limited" || errors.Is(err, errLimitedConn) {
            time.Sleep(backoff.Next()) // server closed conn intentionally; retry with backoff
            continue
        }
        return err // real listener error
    }
    go handle(conn)
}

Prevention

When it happens

Trigger: derper started with a finite --accept-connection-limit (or -connection-rate-limit style config) and the incoming connection rate exceeding the rate+burst budget — e.g. after a derper restart when all clients reconnect at once, an aggressive health checker, or a connection flood.

Common situations: Operators who set a low accept rate for protection then see reconnect storms (mass client reconnect, LB health probes from many sources) hit the limit; visible via the derper expvar metrics counter_rejected_connections on /debug/vars.

Related errors


AI-assisted analysis of tailscale/tailscale@cfe32b8be6 (2026-08-15). Data as JSON: /api/errors/cc71af32584f385d. Report an issue: GitHub.