Testing Downstream Timeouts, Faults and Slow Responses with WireMock
- Published on
- Reading time
- 5 mins read
On this page
Most HTTP client tests I see stub a 200 and check that the JSON maps to a DTO. That covers the easy part. The code that matters when a downstream service misbehaves (timeouts, retries, error handling) often goes untested until production tests it for you.
WireMock can produce those failures on demand, and deterministically. For a database I'd rather test against the real engine, but a real downstream service can't be told to time out on cue. These are the failure tests I'd write for any HTTP client.
The client under test
A RestTemplate client with timeouts and a Resilience4j retry:
public class CustomerClient {
private final RestTemplate restTemplate;
private final Retry retry = Retry.of("customer-service", RetryConfig.custom()
.maxAttempts(3)
.waitDuration(Duration.ofMillis(100))
.retryExceptions(ResourceAccessException.class, HttpServerErrorException.class)
.build());
public CustomerClient(RestTemplateBuilder builder, String baseUrl) {
this.restTemplate = builder
.rootUri(baseUrl)
.setConnectTimeout(Duration.ofMillis(500))
.setReadTimeout(Duration.ofSeconds(1))
.build();
}
public Customer getCustomer(String id) {
return retry.executeSupplier(
() -> restTemplate.getForObject("/customers/{id}", Customer.class, id));
}
}
I/O errors such as timeouts and resets surface as ResourceAccessException, and 5xx responses as HttpServerErrorException. Anything else, like a 404, fails immediately.
The test builds the client through the same constructor production uses. That way it catches a missing timeout in the real wiring, not in a RestTemplate the test configured itself.
@WireMockTest
class CustomerClientTest {
static final String CUSTOMER = "{\"id\":\"42\",\"name\":\"Ada\"}";
CustomerClient client;
@BeforeEach
void setUp(WireMockRuntimeInfo wireMock) {
client = new CustomerClient(new RestTemplateBuilder(), wireMock.getHttpBaseUrl());
}
// tests below
}
@WireMockTest arrived in WireMock 2.31. It starts a server on a random port, configures the static DSL (stubFor, verify), and resets stubs and recorded requests before each test.
Slow responses: prove the timeout exists
A plain new RestTemplate() has no read timeout, because the JDK's HttpURLConnection waits indefinitely by default. So the first test proves ours is configured:
@Test
void givesUpOnASlowService() {
stubFor(get("/customers/42").willReturn(okJson(CUSTOMER).withFixedDelay(3_000)));
assertTimeoutPreemptively(Duration.ofSeconds(5), () -> {
assertThrows(ResourceAccessException.class, () -> client.getCustomer("42"));
});
verify(3, getRequestedFor(urlEqualTo("/customers/42")));
}
Each of the three attempts times out after one second. If someone removes setReadTimeout, the first attempt simply gets the delayed 200 and the test fails.
The same kind of test works for WebClient. There, both timeouts are set on the Reactor Netty HttpClient:
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 500)
.responseTimeout(Duration.ofSeconds(1));
WebClient webClient = WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
A read timeout is not a total timeout
The read timeout limits how long a single socket read may wait, not the whole call:
stubFor(get("/customers/42")
.willReturn(okJson(CUSTOMER).withChunkedDribbleDelay(10, 5_000)));
WireMock now sends the body in 10 chunks, 500 ms apart. No single read has to wait a full second, so the read timeout never fires and the call succeeds after about five seconds. Reactor Netty's responseTimeout works the same way: it is the maximum gap between reads. If you need a hard upper bound, add one on top, such as timeout(...) on the WebClient Mono or a Resilience4j TimeLimiter.
Faults, and the retries you didn't write
@Test
void retriesWhenTheConnectionIsReset() {
stubFor(get("/customers/42").willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
assertThrows(ResourceAccessException.class, () -> client.getCustomer("42"));
verify(6, getRequestedFor(urlEqualTo("/customers/42")));
}
Six, not three. Unless Apache HttpClient or OkHttp is on the classpath, RestTemplateBuilder falls back to the JDK's HttpURLConnection, which quietly retries a GET once when the connection breaks before the response headers arrive. So each of our three attempts reaches WireMock twice. Apache HttpClient 4.x, if present, has its own default retry handler for idempotent requests. Counting requests is how you learn what an overloaded downstream will actually receive.
The other faults deserve a test each, because they break the exchange at different points. EMPTY_RESPONSE triggers the same hidden JDK retry as the reset, while MALFORMED_RESPONSE_CHUNK and RANDOM_DATA_THEN_CLOSE don't, so WireMock sees one request per attempt. One caveat from WireMock's docs: CONNECTION_RESET_BY_PEER only behaves reliably on Unix-like systems; on Windows the connection tends to hang instead.
Retry only what should be retried
A retry test is incomplete without its opposite:
@Test
void doesNotRetryClientErrors() {
stubFor(get("/customers/42").willReturn(notFound()));
assertThrows(HttpClientErrorException.class, () -> client.getCustomer("42"));
verify(1, getRequestedFor(urlEqualTo("/customers/42")));
}
For "fails twice, then succeeds", WireMock scenarios (inScenario, whenScenarioStateIs, willSetStateTo) let a stub change its answer after each match. You can script two 503s followed by a 200 and assert exactly three requests.
Random delays (withUniformRandomDelay, withLogNormalRandomDelay) are useful for exploring long-tail latency, but they make assertions flaky. For tests, fixed delays and dribbles keep the outcome deterministic.
Takeaways
- Stub failures, not just 200s: fixed delays, dribbled bodies, faults and 5xx responses.
- Build the client the way production does, so the test catches a missing timeout.
- A read timeout limits each read, not the whole call. Add a total time limit if you need one.
- Always
verifyrequest counts. They reveal retries hidden inside the HTTP client itself. - Test that non-retryable errors are not retried.