A Spring Boot Microservices Example, and the 22 Seconds Nobody Shows You

Two services and one call is enough to be genuinely distributed. An orders service calling an inventory service by name on a Docker network answered in 0.18 seconds — and with the downstream stopped, the same request took 22.7 seconds to fail, because nothing asked it to give up sooner. With the downstream merely slow at 8 seconds, the caller took 8.06. The default is to wait, which is why timeouts come before registries.

Search this and you get four services, a registry, a gateway and a config server. That teaches you a topology. It does not teach you the thing that makes distributed systems hard, because every one of those tutorials shows the happy path.

Two services and one call is enough. Here is that, and then the two measurements that are the actual content of this article: what the caller does when the other service is stopped, and what it does when the other service is merely slow.

Both services are Spring Boot 4.1.1, running in containers on one Docker network.

The call itself

The downstream — an inventory service with one endpoint, and a switch to make it slow on purpose:

@RestController
public class StockController {
    @GetMapping("/stock/{sku}")
    public Map<String, Object> stock(@PathVariable String sku,
                                     @RequestParam(defaultValue = "0") long delayMs)
                                     throws Exception {
        if (delayMs > 0) Thread.sleep(delayMs);
        return Map.of("sku", sku, "onHand", sku.hashCode() % 50 + 50);
    }
}

And the caller:

@RestController
public class OrderController {
    private final RestClient inventory;

    OrderController(@Value("${inventory.url}") String url) {
        this.inventory = RestClient.create(url);
    }

    @GetMapping("/orders/{sku}")
    public Map<String, Object> order(@PathVariable String sku,
                                     @RequestParam(defaultValue = "0") long delayMs) {
        Map<?,?> stock = inventory.get()
            .uri("/stock/{sku}?delayMs={d}", sku, delayMs)
            .retrieve().body(Map.class);
        return Map.of("sku", sku, "canFulfil", true, "inventorySaid", stock);
    }
}
inventory.url=http://inventory:8080

inventory, not localhost. A service name on a Docker network is a hostname — the same thing is true of a Service in Kubernetes. There is no registry here and none is needed yet.

The happy path:

{"sku":"WIDGET-1","canFulfil":true,
 "inventorySaid":{"sku":"WIDGET-1","onHand":18}}
HTTP 200 in 0.18s

Two services, one call, 180 milliseconds. That is the whole of what most tutorials demonstrate, and it is the easy part.

Note

One thing worth recording because it cost a restart: injecting RestClient.Builder into the constructor failed at startup — "required a bean of type org.springframework.web.client.RestClient$Builder that could not be found". RestClient.create(url) works and is what the code above does. The cause turns out to be a missing artifact rather than anything about the code, and it is the same one that makes the timeout properties do nothing — see Timeouts are the first thing. RestClient is the current synchronous client; RestTemplate still exists and is in maintenance.

Now stop the other service

docker stop inventory
curl "localhost:8080/orders/WIDGET-1"
{"timestamp":"...","status":500,"error":"Internal Server Error","path":"/orders/WIDGET-1"}
HTTP 500 in 22.689549s

Twenty-two and a half seconds.

Nothing in that code asked to wait. There is no timeout in the controller, no timeout in the properties, and no timeout on the RestClient — so the client used its defaults, and the defaults are close enough to "keep trying" that a user-facing request hung for twenty-two seconds and then failed.

What the caller's log said:

ResourceAccessException: I/O error on GET request for ...

And what the client saw was Spring Boot's generic 500, with no indication that a downstream service was the cause. Somebody debugging this from the outside has a 500 and a 22-second latency and no idea where to look.

Now make it slow instead

The downstream is healthy and takes 8 seconds:

curl "localhost:8080/orders/WIDGET-1?delayMs=8000"
HTTP 200 in 8.057265s

The caller waited the entire time and returned a correct answer. Which is worse than the outage, and this is the part worth internalising:

  • A dead dependency gives you errors. Errors are visible, alertable and obviously somebody's fault.
  • A slow dependency gives you correct answers slowly, while every one of those requests holds a thread and a connection for the full duration.

Enough concurrent requests and the caller's own thread pool is full of requests waiting on someone else. At that point the orders service stops serving requests that have nothing to do with inventory at all — it has been taken down by a service that never went down. Do that two links deep and the outage has a shape nobody can trace.

Common mistake

Treating the stopped-service case as the scenario to design for. It is the easy one. The slow-but-working case is what actually takes systems down, and it does not show up in any tutorial because nothing in a tutorial is ever slow.

Timeouts are the first thing, not the last

Before a registry, before a gateway, before a circuit breaker: decide how long you are willing to wait.

There are two of them and they answer different questions. Connect is how long to spend establishing a connection — this is the one that was eating most of those 22 seconds. Read is how long to wait for a response once connected — this is the one that caps the 8-second case.

In Spring Boot 4.1.1 both are properties, and the name is plural:

spring.http.clients.connect-timeout=1s
spring.http.clients.read-timeout=2s

With that read timeout set, the 8-second downstream from earlier failed in 2.03 seconds with ResourceAccessException: I/O error on GET request for "http://localhost:8080/slow": Request cancelled. Aimed at an address that never answers, the connect timeout failed in 1.04 seconds with HTTP connect timed out. With no timeout property at all, the same read call took 8.13 seconds and succeeded — the caller waited out the whole delay, as it did in the 8.06-second measurement earlier.

Two traps sit around those two lines, and both are silent.

The singular name does nothing, and warns about nothing. spring.http.client.read-timeout — no s on client — is the form most writing on this subject uses, this article's first draft included. It is real: spring-boot-http-client-4.1.1.jar lists it in its own configuration metadata, marked deprecated with spring.http.clients.read-timeout as the replacement. Deprecated there does not mean "works, with a warning". Measured: the singular property set to 2s produced an 8.08-second call, indistinguishable from setting nothing, and not one line of the startup log mentioned a deprecation. The singular connect timeout behaves the same way — set to 1s, the call to the dead address had still not returned after 30 seconds.

Neither property does anything without the right starter. spring-boot-starter-webmvc does not bring spring-boot-http-client, so the property has nothing to bind to and is dropped without comment: on a webmvc-only project the plural property set to 2s still gave an 8.07-second call. That missing jar is also why RestClient.Builder was not injectable — the builder is auto-configured in the same artifact, so asking for it in a constructor fails the application at startup with "Parameter 0 of constructor ... required a bean of type 'org.springframework.web.client.RestClient$Builder' that could not be found". Adding spring-boot-starter-restclient brings the jar and fixes both at once, which is the single change that made every number above possible.

The reference page for calling REST services is still worth reading for your own version, because this is clearly an area that moves.

Pick the numbers from the downstream's real latency, not from a round figure. If inventory answers in 20 milliseconds at the 99th percentile, a 2-second read timeout is already enormously generous; 30 seconds is not a timeout, it is a formality.

What a timeout buys is not correctness — the call still fails. It buys a fast failure, which you can catch, log with a cause, and answer with a cached value, a partial response, or an honest 503. A slow failure gives you none of those options because you are still inside it.

Then the machinery everyone shows first

With the above in place, the standard microservices furniture stops being a starting topology and becomes a set of answers to problems you have now watched happen:

  • A circuit breaker — once you know inventory is failing, stop calling it. A timeout protects one request; a breaker stops you spending a thread on the ninety-ninth request to a service you already know is down, and gives the downstream room to recover.
  • A registry — once service addresses stop being knowable in advance. On one Docker network inventory:8080 is enough; across dynamic instances it is not, which is where something like a Eureka server starts paying for itself.
  • A gateway — once there are more services than any client should have to know about, and cross-cutting concerns need one place to live.
  • Health checks — so the platform stops routing to an instance before your caller has to discover it the hard way, which is the whole point of readiness being separate from liveness.

Each of those is worth adding when you have the problem. None of them fixes a missing timeout.

If you are building the two services to try this, creating each project takes a minute and the endpoint in each is the shape from earlier in this series. Where they run — one host with a network, or something scheduling them — changes less than you would think about the code in this article, and nothing at all about the 22 seconds.

Frequently asked questions

Do I need Eureka or a gateway for two services?
No. On a Docker network or in Kubernetes, a service name is already a hostname — the orders service here reaches the other at http://inventory:8080 with no registry at all. A registry earns its place when addresses stop being knowable in advance; a gateway when clients should not know how many services there are.
What happens if the service I call is down?
Measured here: HTTP 500 after 22.7 seconds, with ResourceAccessException in the caller's log and a generic 500 reaching the client. Nothing in the code asked to wait that long — no timeout was configured, so the client used its defaults, which are effectively "keep trying".
Why is a slow dependency worse than a dead one?
Because it holds your resources. A dead one fails; a slow one makes you slow — measured, an 8-second downstream produced an 8.06-second response. Those requests hold threads and connections the whole time, so enough of them exhausts the caller's pool and it stops serving requests that have nothing to do with the dependency.
Should I use RestClient or RestTemplate?
RestClient for new code. It is the current synchronous client with a fluent API; RestTemplate still works and is in maintenance. Note that RestClient.Builder was not injectable in this setup — the app failed at startup asking for the bean — so RestClient.create(url) was used directly.
Where do circuit breakers fit?
After timeouts, not instead of them. A timeout stops one request hanging; a circuit breaker stops you making the call at all once you know the downstream is failing, which protects both you and it. Adding a breaker without a timeout leaves each individual call able to hang for as long as it likes.

References

  1. RestClient (Spring Framework docs)Spring
  2. Spring Boot Reference: Calling REST ServicesSpring
  3. Networking in ComposeDocker
  4. Spring Boot Reference: Actuator EndpointsSpring