A Spring Cloud Gateway Example, Run Until Every Default Showed Itself
A working Spring Cloud Gateway example on Boot 4.1.1 and gateway 5.0.3, with every result measured. Routes belong under spring.cloud.gateway.server.webflux.routes; the old spring.cloud.gateway.routes prefix gave a 404 and logged nothing. A stopped downstream returned 500, not 503. With no response-timeout a 70-second call went through. A Retry filter allowed to retry POST ran one charge four times. RequestRateLimiter with no KeyResolver answered 403 to all ten requests.
Most Spring Cloud Gateway examples stop at one route and one 200. This one has one gateway, two tiny downstream services that print back exactly what they received, and a Redis container. I kept running it until each default showed up in a status code or a hit count.
Versions: Spring Boot 4.1.1, Spring Cloud 2025.1.3, which brings spring-cloud-gateway-server-webflux 5.0.3, and Redis 7.4.11, all on JDK 25. The downstreams are a 43-line com.sun.net.httpserver program. For each request it counts hits per method and path, then replies with the method, the path and every header it got.
The project, and which gateway starter
start.spring.io offers two gateways for Boot 4.1.1. Reactive Gateway adds spring-cloud-starter-gateway-server-webflux. Gateway, described as for Servlet-based applications, adds spring-cloud-starter-gateway-server-webmvc. Everything below uses the WebFlux one. If you are new to generating a project, creating one from Initializr is the same process with these dependencies ticked.
<spring-cloud.version>2025.1.3</spring-cloud.version>
...
<artifactId>spring-cloud-starter-gateway-server-webflux</artifactId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
spring:
cloud:
gateway:
server:
webflux:
routes:
- id: orders
uri: http://localhost:18610
predicates:
- Path=/api/orders/**
filters:
- StripPrefix=1
- id: legacy
uri: http://localhost:18610
predicates:
- Path=/legacy/**
filters:
- RewritePath=/legacy/?(?<segment>.*), /v2/$\{segment}
- id: raw
uri: http://localhost:18610
predicates:
- Path=/raw/**
- id: withbase
uri: http://localhost:18610/base
predicates:
- Path=/withbase/**
- id: shop-broad
uri: http://localhost:18611
predicates:
- Path=/shop/**
- id: shop-cart
uri: http://localhost:18610
predicates:
- Path=/shop/cart/**
Look at the prefix: spring.cloud.gateway.server.webflux.routes. It matters more than anything else in the file, and there is a section on it below.
What the downstream actually receives
This is what each request to the gateway on port 18600 looked like when it reached a downstream:
GET /api/orders/42?x=1 -> orders-svc got GET /orders/42?x=1
GET /legacy/users/7 -> orders-svc got GET /v2/users/7
GET /legacy -> orders-svc got GET /v2/
GET /raw/orders/42 -> orders-svc got GET /raw/orders/42 (no filter)
GET /withbase/orders/42 -> orders-svc got GET /withbase/orders/42
GET /shop/cart/1 -> catalog-svc got GET /shop/cart/1
StripPrefix=1 removed /api and kept the query string. RewritePath turned a bare /legacy into /v2/, trailing slash included. With no filter, the downstream got the full gateway path.
Two results here trip people up.
The path in uri is ignored. The withbase route points at http://localhost:18610/base, but the downstream got /withbase/orders/42 with no /base in it. The gateway takes the scheme, host and port from uri and nothing else. If the downstream needs a base path, add it with RewritePath or PrefixPath.
The first matching route wins, even if a later one is more specific. /shop/cart/1 matches both shop routes, and it went to catalog-svc because shop-broad comes first in the list. The actuator's /actuator/gateway/routes listed every route with order 0, so the order they appear in the YAML decides.
The headers were less expected. By default the downstream got just these:
Accept: */*
Host: localhost:18610
User-agent: curl/8.7.1
There was no X-Forwarded-For and no Forwarded header. Host had been changed to the downstream's own address. A request that sent its own X-Forwarded-For: 6.6.6.6 arrived without it too. The HttpHeadersFilters page explains why: "To activate this filter set the spring.cloud.gateway.server.webflux.trusted-proxies property to a Java Regular Expression." With trusted-proxies=127\.0\.0\.1 set, the same request arrived like this:
Forwarded: proto=http;host="127.0.0.1:18600";for="127.0.0.1:62562"
Host: localhost:18610
X-forwarded-for: 127.0.0.1
X-forwarded-host: 127.0.0.1:18600
X-forwarded-port: 18600
X-forwarded-prefix: /api
X-forwarded-proto: http
X-Forwarded-Prefix: /api is the header a downstream needs if it builds links back through the gateway after StripPrefix has removed that segment.
The old property prefix is ignored, with no warning
In older releases routes lived under spring.cloud.gateway.routes. I started the same app with only that route written the old way:
spring:
cloud:
gateway:
routes:
- id: orders
uri: http://localhost:18610
predicates:
- Path=/api/orders/**
GET /api/orders/42 -> 404 {"path":"/api/orders/42","status":404,"error":"Not Found",...}
Nothing in the startup log mentioned the old prefix. I added spring-boot-properties-migrator, which exists to report renamed properties, and got the same 404 with nothing logged. The timeout shows the same problem. spring.cloud.gateway.httpclient.response-timeout=2s against an 8-second downstream returned 200 after 8.03 s (median of three). The new name, spring.cloud.gateway.server.webflux.httpclient.response-timeout=2s, returned 504 after 2.01 s.
The jars explain it. spring-cloud-gateway-server-4.3.5.jar contains a GatewayServerWebfluxPropertiesMigrationListener, and its metadata marks spring.cloud.gateway.routes as deprecated since 4.3.0. spring-cloud-gateway-server-webflux-5.0.3.jar has neither, so nothing in 5.0.3 knows the old name existed.
Common mistake
Copying the global timeout example from the reference docs. The 5.0.3 Http timeouts configuration page shows
spring: cloud: gateway: httpclient: connect-timeout: 1000 response-timeout: 5s. That is the old prefix, which the measurement above shows 5.0.3 ignores. Putserver.webfluxin the path.
Down, slow and unreachable give different results
The microservices example measured what a caller does with no timeout. A gateway adds another layer that can wait, so here are the same three failures through the gateway:
downstream port refused -> 500 in 0.008 s (median of 3)
address that never answers -> 500 in 30.04 s ConnectTimeoutException: connection timed out after 30000 ms
downstream sleeps 70 s -> 200 in 70.05 s (no response-timeout set)
downstream sleeps 8 s, 2s limit -> 504 in 2.01 s (median of 3)
A downstream that is down gives 500, not 503. The body is Spring Boot's generic error with no mention of which route failed. The log shows Connection refused: localhost/127.0.0.1:18619. The connect timeout defaults to 30 seconds, and hitting it also gives 500. Only a response timeout gives 504, and 5.0.3 has none by default: a 70-second call held the connection and came back with 200. Set response-timeout globally, and use route metadata for routes that really need longer.
Retry sends a POST again, if you allow it
Out of the box the Retry filter is fairly safe. The Retry filter docs list the defaults as three retries, the 5XX series, the GET method, and IOException and TimeoutException. With retries: 3, one request per status gave these downstream hit counts:
GET 500/502/503/504 -> 4 hits each POST 500/502/503/504 -> 1 hit each
GET 404 -> 1 hit POST 404 -> 1 hit
The usual next step is to add POST so that writes retry as well:
- id: retry-post
uri: http://localhost:18610
predicates:
- Path=/retrypost/**
filters:
- name: Retry
args:
retries: 3
methods: GET,POST
metadata:
response-timeout: 1000
The downstream took 1.5 s, longer than the 1-second route timeout. One POST /retrypost/charge from the client ran on the downstream four times. The downstream log printed the request four times, each after it finished its work. The client got a 504 after 4.27 s and was never told the work had happened. A timeout means the gateway stopped waiting. The downstream may still have done the work. Only allow POST to retry when the endpoint is idempotent.
Rate limiting: 403 before you ever see a 429
A RequestRateLimiter with replenishRate: 1, burstCapacity: 5 and no key-resolver rejected every request:
10 requests -> 403 403 403 403 403 403 403 403 403 403 (empty body, 0 downstream hits, 0 Redis keys)
The default resolver uses the authenticated principal's name. With no security configured there is no principal, so the key is empty and the request is refused before Redis is involved. The rate limiter docs say so: "By default, if the KeyResolver does not find a key, requests are denied." If your gateway checks a token as in the JWT setup, the default works. If not, give it a key:
@Bean
KeyResolver ipKeyResolver() {
return exchange -> Mono.just(
exchange.getRequest().getRemoteAddress().getAddress().getHostAddress());
}
With that bean, four rounds of 20 rapid requests came back as 6/14, 5/15, 6/14 and 5/15 for 200/429. Downstream hits matched the number of 200s each time. The 429 carried X-RateLimit-Remaining: 0, X-RateLimit-Burst-Capacity: 5 and X-RateLimit-Replenish-Rate: 1.
Then I stopped Redis while the gateway kept running. A common assumption is that the limiter fails closed. It lets traffic through, because the RedisRateLimiter source turns a failed Redis call into an allowed response. The measured part is how slowly: three concurrent requests each returned 200 after 60.11 s, after RedisRateLimiter : Error calling rate limiter lua and QueryTimeoutException: Redis command timed out. With spring.data.redis.timeout=500ms the same test returned 200 in 0.54 to 0.59 s. The aggregate /actuator/health reported DOWN while Redis was stopped.
If you run Redis next to the gateway in Docker Compose, set that timeout on day one. Leave it unset and a Redis outage looks like a gateway that takes a minute per request.
[!TAKEAWAY] For a Spring Cloud Gateway 5.0.3 example that behaves in production: put routes and
httpclientsettings underspring.cloud.gateway.server.webflux, because the old prefix does nothing and nothing warns you. Set aresponse-timeout, since there is none by default. Retry POST only on idempotent endpoints. GiveRequestRateLimiteraKeyResolverand give Redis a short timeout. Settrusted-proxiesif a downstream needsX-Forwarded-*. Forlb://routing, a Eureka registry plugs into the sameurifield.
Frequently asked questions
- Should I use spring-cloud-starter-gateway-server-webflux or -webmvc?
- Both exist on start.spring.io for Spring Boot 4.1.1. The WebFlux one is "Reactive Gateway" and the WebMVC one is "Gateway", described as for Servlet-based applications. Every result on this page is from the WebFlux variant, gateway 5.0.3. The two are configured under different prefixes, so a guide written for one will not work if you paste it into the other.
- Why do my gateway routes return 404 after upgrading?
- Check the prefix first. On gateway 5.0.3 the routes list is spring.cloud.gateway.server.webflux.routes. A route written under the older spring.cloud.gateway.routes gave a 404 here and the startup log said nothing about it, even with spring-boot-properties-migrator on the classpath. The 4.3.5 jar still has migration code and deprecation metadata for the old names. The 5.0.3 jar has neither.
- What status does Spring Cloud Gateway return when the downstream service is down?
- Measured on 5.0.3: 500, with a generic body. A port that refused connections gave 500 in a few milliseconds. An address that never answered gave 500 after the 30-second default connect timeout. 504 came back only when a response-timeout was set and the downstream was slower than it.
- Does RequestRateLimiter keep working if Redis goes down?
- It lets traffic through, and it can be slow about it. With Redis stopped while the gateway was running, each limited request waited 60.11 seconds for the Redis command timeout and then returned 200 from the downstream. Setting spring.data.redis.timeout=500ms brought that down to about 0.59 seconds. The gateway's /actuator/health also reported DOWN while Redis was stopped.
- Why does my downstream service not receive X-Forwarded-For from the gateway?
- On 5.0.3 the X-Forwarded-* and Forwarded headers are only added once spring.cloud.gateway.server.webflux.trusted-proxies is set to a regular expression. Without it, the downstream here got no forwarding headers at all. Headers the client sent itself did not get through either. With it set to 127\.0\.0\.1, the downstream got X-Forwarded-For, -Host, -Port, -Prefix, -Proto and a Forwarded header.
References
- Spring Cloud Gateway 5.0.3: Http timeouts configurationSpring
- Spring Cloud Gateway 5.0.3: Retry GatewayFilter FactorySpring
- Spring Cloud Gateway 5.0.3: RequestRateLimiter GatewayFilter FactorySpring
- Spring Cloud Gateway 5.0.3: HttpHeadersFiltersSpring
- spring-cloud-gateway source, RedisRateLimiter.javaSpring