From 469ee709d1212922e0a32cfd58a6b59ba5501345 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pierre=20Por=C3=A9e?=
<37620083+Ph4rell@users.noreply.github.com>
Date: Fri, 6 Mar 2026 15:42:04 +0100
Subject: [PATCH] Support limit-rpm annotation for ingress-nginx
---
.../kubernetes/ingress-nginx.md | 4 +-
.../kubernetes/ingress-nginx/annotations.go | 1 +
.../ingress-nginx/annotations_test.go | 2 +
.../ingresses/ingress-with-limit-rpm.yml | 42 ++++++++
.../kubernetes/ingress-nginx/kubernetes.go | 20 ++++
.../ingress-nginx/kubernetes_test.go | 98 +++++++++++++++++++
6 files changed, 165 insertions(+), 2 deletions(-)
create mode 100644 pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-limit-rpm.yml
diff --git a/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md b/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md
index b096e4c53..8b60b7a4a 100644
--- a/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md
+++ b/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md
@@ -381,6 +381,7 @@ The following annotations are organized by category for easier navigation.
| Annotation | Limitations / Notes |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |-----------------------------------------------------------------------------------------------------------|
| `nginx.ingress.kubernetes.io/limit-rps` | Exceeding the limit returns `429 Too Many Requests` instead of NGINX's default `503 Service Unavailable`. |
+| `nginx.ingress.kubernetes.io/limit-rpm` | Exceeding the limit returns `429 Too Many Requests` instead of NGINX's default `503 Service Unavailable`. |
### Buffering
@@ -453,8 +454,7 @@ In practice, Traefik is slightly more lenient under bursty load, as it smooths o
| `nginx.ingress.kubernetes.io/disable-proxy-intercept-errors` | |
| `nginx.ingress.kubernetes.io/limit-rate-after` | |
| `nginx.ingress.kubernetes.io/limit-rate` | |
-| `nginx.ingress.kubernetes.io/limit-whitelist` | | |
-| `nginx.ingress.kubernetes.io/limit-rpm` | |
+| `nginx.ingress.kubernetes.io/limit-whitelist` | |
| `nginx.ingress.kubernetes.io/limit-burst-multiplier` | |
| `nginx.ingress.kubernetes.io/limit-connections` | |
| `nginx.ingress.kubernetes.io/global-rate-limit` | |
diff --git a/pkg/provider/kubernetes/ingress-nginx/annotations.go b/pkg/provider/kubernetes/ingress-nginx/annotations.go
index c9c61f51d..25a1d54ba 100644
--- a/pkg/provider/kubernetes/ingress-nginx/annotations.go
+++ b/pkg/provider/kubernetes/ingress-nginx/annotations.go
@@ -80,6 +80,7 @@ type ingressConfig struct {
WhitelistSourceRange *string `annotation:"nginx.ingress.kubernetes.io/whitelist-source-range"`
AllowlistSourceRange *string `annotation:"nginx.ingress.kubernetes.io/allowlist-source-range"`
+ LimitRPM *int `annotation:"nginx.ingress.kubernetes.io/limit-rpm"`
LimitRPS *int `annotation:"nginx.ingress.kubernetes.io/limit-rps"`
CustomHeaders *string `annotation:"nginx.ingress.kubernetes.io/custom-headers"`
diff --git a/pkg/provider/kubernetes/ingress-nginx/annotations_test.go b/pkg/provider/kubernetes/ingress-nginx/annotations_test.go
index 22b225c53..a6abdbee2 100644
--- a/pkg/provider/kubernetes/ingress-nginx/annotations_test.go
+++ b/pkg/provider/kubernetes/ingress-nginx/annotations_test.go
@@ -37,6 +37,7 @@ func Test_parseIngressConfig(t *testing.T) {
"nginx.ingress.kubernetes.io/proxy-buffer-size": "16k",
"nginx.ingress.kubernetes.io/proxy-buffers-number": "8",
"nginx.ingress.kubernetes.io/proxy-max-temp-file-size": "100m",
+ "nginx.ingress.kubernetes.io/limit-rpm": "120",
},
expected: ingressConfig{
SSLPassthrough: ptr.To(true),
@@ -59,6 +60,7 @@ func Test_parseIngressConfig(t *testing.T) {
ProxyBufferSize: ptr.To("16k"),
ProxyBuffersNumber: ptr.To(8),
ProxyMaxTempFileSize: ptr.To("100m"),
+ LimitRPM: ptr.To(120),
},
},
{
diff --git a/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-limit-rpm.yml b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-limit-rpm.yml
new file mode 100644
index 000000000..9c96520bd
--- /dev/null
+++ b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-limit-rpm.yml
@@ -0,0 +1,42 @@
+---
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: ingress-with-limit-rpm
+ namespace: default
+ annotations:
+ nginx.ingress.kubernetes.io/limit-rpm: "10"
+spec:
+ ingressClassName: nginx
+ rules:
+ - host: whoami.localhost
+ http:
+ paths:
+ - path: /
+ pathType: Exact
+ backend:
+ service:
+ name: whoami
+ port:
+ number: 80
+---
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: ingress-with-limit-rpm-zero
+ namespace: default
+ annotations:
+ nginx.ingress.kubernetes.io/limit-rpm: "0"
+spec:
+ ingressClassName: nginx
+ rules:
+ - host: whoami-zero.localhost
+ http:
+ paths:
+ - path: /
+ pathType: Exact
+ backend:
+ service:
+ name: whoami
+ port:
+ number: 80
diff --git a/pkg/provider/kubernetes/ingress-nginx/kubernetes.go b/pkg/provider/kubernetes/ingress-nginx/kubernetes.go
index 7ee954727..0a00ab833 100644
--- a/pkg/provider/kubernetes/ingress-nginx/kubernetes.go
+++ b/pkg/provider/kubernetes/ingress-nginx/kubernetes.go
@@ -1203,6 +1203,8 @@ func (p *Provider) applyMiddlewares(namespace, ingressName, routerKey, rulePath,
applyUpstreamVhost(routerKey, ingressConfig, rt, conf)
+ applyLimitRPMConfiguration(routerKey, ingressConfig, rt, conf)
+
applyLimitRPSConfiguration(routerKey, ingressConfig, rt, conf)
if err := p.applyAuthTLSPassCertificateToUpstream(namespace, routerKey, ingressConfig, rt, conf); err != nil {
@@ -1307,6 +1309,24 @@ func (p *Provider) applyCustomHTTPErrors(namespace, ingressName, routerName stri
return nil
}
+func applyLimitRPMConfiguration(routerName string, ingressConfig ingressConfig, rt *dynamic.Router, conf *dynamic.Configuration) {
+ limitRPM := ptr.Deref(ingressConfig.LimitRPM, 0)
+ if limitRPM <= 0 {
+ return
+ }
+
+ rateLimitMiddlewareName := routerName + "-limit-rpm"
+ conf.HTTP.Middlewares[rateLimitMiddlewareName] = &dynamic.Middleware{
+ RateLimit: &dynamic.RateLimit{
+ Average: int64(limitRPM),
+ Period: ptypes.Duration(time.Minute),
+ Burst: int64(limitRPM) * defaultLimitBurstMultiplier,
+ },
+ }
+
+ rt.Middlewares = append(rt.Middlewares, rateLimitMiddlewareName)
+}
+
func applyLimitRPSConfiguration(routerName string, ingressConfig ingressConfig, rt *dynamic.Router, conf *dynamic.Configuration) {
limitRPS := ptr.Deref(ingressConfig.LimitRPS, 0)
if limitRPS <= 0 {
diff --git a/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go b/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go
index a8beab8ea..91f58d2b5 100644
--- a/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go
+++ b/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go
@@ -6638,6 +6638,104 @@ func TestLoadIngresses(t *testing.T) {
TLS: &dynamic.TLSConfiguration{},
},
},
+ {
+ desc: "Limit RPM",
+ paths: []string{
+ "services.yml",
+ "ingressclasses.yml",
+ "ingresses/ingress-with-limit-rpm.yml",
+ },
+ expected: &dynamic.Configuration{
+ TCP: &dynamic.TCPConfiguration{
+ Routers: map[string]*dynamic.TCPRouter{},
+ Services: map[string]*dynamic.TCPService{},
+ },
+ HTTP: &dynamic.HTTPConfiguration{
+ Routers: map[string]*dynamic.Router{
+ "default-ingress-with-limit-rpm-rule-0-path-0": {
+ Rule: "Host(`whoami.localhost`) && Path(`/`)",
+ RuleSyntax: "default",
+ Middlewares: []string{"default-ingress-with-limit-rpm-rule-0-path-0-limit-rpm", "default-ingress-with-limit-rpm-rule-0-path-0-retry"},
+ Service: "default-ingress-with-limit-rpm-whoami-80",
+ },
+ "default-ingress-with-limit-rpm-zero-rule-0-path-0": {
+ Rule: "Host(`whoami-zero.localhost`) && Path(`/`)",
+ RuleSyntax: "default",
+ Middlewares: []string{"default-ingress-with-limit-rpm-zero-rule-0-path-0-retry"},
+ Service: "default-ingress-with-limit-rpm-zero-whoami-80",
+ },
+ },
+ Middlewares: map[string]*dynamic.Middleware{
+ "default-ingress-with-limit-rpm-rule-0-path-0-retry": {
+ Retry: &dynamic.Retry{
+ Attempts: 3,
+ },
+ },
+ "default-ingress-with-limit-rpm-rule-0-path-0-limit-rpm": {
+ RateLimit: &dynamic.RateLimit{
+ Average: 10,
+ Burst: 50,
+ Period: ptypes.Duration(time.Minute),
+ },
+ },
+ "default-ingress-with-limit-rpm-zero-rule-0-path-0-retry": {
+ Retry: &dynamic.Retry{
+ Attempts: 3,
+ },
+ },
+ },
+ Services: map[string]*dynamic.Service{
+ "default-ingress-with-limit-rpm-whoami-80": {
+ LoadBalancer: &dynamic.ServersLoadBalancer{
+ Servers: []dynamic.Server{
+ {URL: "http://10.10.0.1:80"},
+ {URL: "http://10.10.0.2:80"},
+ },
+ Strategy: "wrr",
+ PassHostHeader: ptr.To(true),
+ ResponseForwarding: &dynamic.ResponseForwarding{
+ FlushInterval: dynamic.DefaultFlushInterval,
+ },
+ ServersTransport: "default-ingress-with-limit-rpm",
+ },
+ },
+ "default-ingress-with-limit-rpm-zero-whoami-80": {
+ LoadBalancer: &dynamic.ServersLoadBalancer{
+ Servers: []dynamic.Server{
+ {URL: "http://10.10.0.1:80"},
+ {URL: "http://10.10.0.2:80"},
+ },
+ Strategy: "wrr",
+ PassHostHeader: ptr.To(true),
+ ResponseForwarding: &dynamic.ResponseForwarding{
+ FlushInterval: dynamic.DefaultFlushInterval,
+ },
+ ServersTransport: "default-ingress-with-limit-rpm-zero",
+ },
+ },
+ },
+ ServersTransports: map[string]*dynamic.ServersTransport{
+ "default-ingress-with-limit-rpm": {
+ ForwardingTimeouts: &dynamic.ForwardingTimeouts{
+ DialTimeout: ptypes.Duration(60 * time.Second),
+ ReadTimeout: ptypes.Duration(60 * time.Second),
+ WriteTimeout: ptypes.Duration(60 * time.Second),
+ IdleConnTimeout: ptypes.Duration(60 * time.Second),
+ },
+ },
+ "default-ingress-with-limit-rpm-zero": {
+ ForwardingTimeouts: &dynamic.ForwardingTimeouts{
+ DialTimeout: ptypes.Duration(60 * time.Second),
+ ReadTimeout: ptypes.Duration(60 * time.Second),
+ WriteTimeout: ptypes.Duration(60 * time.Second),
+ IdleConnTimeout: ptypes.Duration(60 * time.Second),
+ },
+ },
+ },
+ },
+ TLS: &dynamic.TLSConfiguration{},
+ },
+ },
}
for _, test := range testCases {