I recently built and open-sourced Flaky HTTP, a small Java 11 library for deliberately making HTTP calls less reliable.
That may sound like an unusual goal. Most of the time, we work hard to make HTTP calls reliable. We add retries, timeouts, circuit breakers, fallbacks, caches, and monitoring. But eventually we need to answer a more difficult question:
How do we know any of that behavior actually works?
The original idea was simple: wrap Java's standard HttpClient, add controlled latency or synthetic HTTP errors to selected requests, and leave the rest of the application unchanged.
That simple idea led to a few interesting decisions around API design, asynchronous cancellation, response body handling, deterministic testing, and the boundary between application-level failure injection and real network chaos.
This article goes beyond a launch announcement. I want to explain why I built the library, how it works internally, where it is useful, and where it is deliberately limited.
TL;DR
Flaky HTTP is a lightweight wrapper around Java 11's java.net.http.HttpClient.
It can:
- add fixed or random latency;
- return synthetic HTTP errors with a configurable probability;
- target requests using a full-URI regular expression;
- handle synchronous and asynchronous calls;
- propagate cancellation for delayed asynchronous work; and
- run without runtime dependencies beyond Java 11.
The Maven coordinate is com.tapadyuti:flaky-http:1.0.0. The shortest useful test setup is a deterministic failure:
FlakyConfig config = FlakyConfig.builder()
.failureRate(1.0)
.errorStatus(503)
.build();
Every targeted call now returns an empty synthetic 503 response without reaching the network. Replace 1.0 with 0.0 and add LatencyStrategy.fixed(500) when the test should exercise slowness without an HTTP error.
It is intended for integration tests, resilience tests, local development, and controlled demonstrations. It is not a replacement for a network proxy or a full chaos-engineering platform.
AI note: AI helped structure and polish this article. The underlying architecture, codebase, and implementation decisions are entirely my own.
The problem I wanted to solve
The successful path of an HTTP integration is usually easy to test.
Start a test server, return 200 OK, deserialize the response, and assert the result. But production dependencies rarely fail in one neat way. They become slow. They return 429, 500, or 503. They recover after a retry. They stay unhealthy long enough to open a circuit breaker. Sometimes one endpoint fails while everything else continues to work.
The surrounding application is expected to handle all of this correctly.
In many test suites, however, the failure setup becomes more complicated than the behavior being tested. We may need to modify a mock server, add proxy rules, change container networking, or create one-off test doubles for every client abstraction.
I wanted something smaller for a common case:
My application already uses Java's
HttpClient. I want selected calls to become slow or return an error, without changing the real service.
That became the scope of Flaky HTTP.
The basic model
The library uses composition. An application gives FlakyHttpClient a real HttpClient and an immutable configuration.
Application
|
v
FlakyHttpClient
|
+-- URI does not match ----------> Real HttpClient
|
+-- URI matches
|
+-- apply latency
|
+-- failure selected ------> Synthetic HTTP response
|
+-- otherwise -------------> Real HttpClient
For a targeted request, latency is applied first. The client then makes a failure decision. If failure is selected, it returns a synthetic response without touching the network. Otherwise, it delegates the original request and body handler to the real client.
A minimal configuration looks like this:
FlakyConfig config = FlakyConfig.builder()
.failureRate(0.30)
.latency(LatencyStrategy.random(100, 500))
.errorStatus(503)
.targetUrls("https://api\\.example\\.com/.*")
.build();
This configuration adds between 100 and 500 milliseconds of latency to matching requests. After the delay, 30 percent of those requests receive a synthetic 503 Service Unavailable. The remaining requests go to the real service.
The code using it remains close to normal HttpClient code:
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/orders/42"))
.GET()
.build();
try (FlakyHttpClient client =
new FlakyHttpClient(HttpClient.newHttpClient(), config)) {
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode());
}
Why I chose composition
One of the first design decisions was whether FlakyHttpClient should behave as another HttpClient subtype or be an explicit wrapper.
I chose an explicit composition-based wrapper.
This makes the boundary visible. Code that opts into failure injection receives a FlakyHttpClient; code that should always use the original client can continue using HttpClient. It also keeps the library focused on the two operations it needs to control: send and sendAsync.
There is a tradeoff. A FlakyHttpClient cannot be passed directly to a method that requires an HttpClient. In a larger application, I would normally place HTTP access behind an application-owned interface anyway. That interface can then be backed by the real client or by the flaky wrapper in tests.
The key word is explicit. Failure injection should not quietly appear in unrelated calls.
Synthetic responses still need to respect BodyHandler
Returning an integer status code is easy. Returning a useful HttpResponse<T> is more subtle.
Java's HTTP API lets the caller decide how a response body should be converted:
HttpResponse.BodyHandlers.ofString()
HttpResponse.BodyHandlers.ofByteArray()
HttpResponse.BodyHandlers.discarding()
If Flaky HTTP simply cast an empty string to T, it would work for one handler and fail for others. The synthetic path therefore applies the caller's real BodyHandler to response metadata, creates its BodySubscriber, and completes that subscriber with an empty body.
As a result:
-
ofString()receives""; -
ofByteArray()receives an empty byte array; -
discarding()receives its normal result; and - a custom handler still controls conversion.
The synthetic response also includes Content-Length: 0, the original request and URI, the configured error status, and the wrapped client's preferred HTTP version.
This was one of the most important implementation details. A failure-testing utility should not introduce a completely different response contract from the API it wraps.
Synchronous and asynchronous latency are different problems
For synchronous calls, artificial latency is straightforward. The calling thread sleeps before the failure decision or real network call. If it is interrupted, the request is not delegated and InterruptedException is propagated.
The asynchronous path needed more care.
Calling Thread.sleep inside sendAsync would make an asynchronous API block the caller, which defeats the purpose. Flaky HTTP instead uses a ScheduledExecutorService to begin the next step after the configured delay.
client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenAccept(response ->
System.out.println(response.statusCode()))
.join();
Cancellation also has two possible states:
- The artificial delay is still pending.
- The delay has finished and the real HTTP request has started.
Cancelling the returned CompletableFuture cancels the scheduled delay when possible. If delegation has already started, it attempts to cancel the delegate future as well.
The default constructor creates a small internal daemon scheduler. Because that is a resource with a lifecycle, FlakyHttpClient implements AutoCloseable and works naturally with try-with-resources.
Applications that already manage executors can supply their own scheduler:
ScheduledExecutorService scheduler =
Executors.newScheduledThreadPool(2);
try {
try (FlakyHttpClient client =
new FlakyHttpClient(HttpClient.newHttpClient(), config, scheduler)) {
client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join();
}
} finally {
scheduler.shutdown();
}
The ownership rule is intentionally simple: the client closes a scheduler it creates, but never closes one supplied by the caller.
Deterministic tests and probabilistic experiments
A random failure rate is useful when exploring system behavior locally. It is usually a poor foundation for a repeatable automated test.
For tests, the boundary values are more useful:
FlakyConfig alwaysUnavailable = FlakyConfig.builder()
.failureRate(1.0)
.errorStatus(503)
.targetUrls("http://localhost:8080/orders/.*")
.build();
A rate of 1.0 always returns the synthetic error. A rate of 0.0 never injects an error, but can still add latency. Both configurations are deterministic.
For example, an integration test can make the upstream orders endpoint consistently unavailable and verify the application's fallback path:
@Test
void usesFallbackWhenOrdersApiIsUnavailable() throws Exception {
FlakyConfig chaos = FlakyConfig.builder()
.failureRate(1.0)
.errorStatus(503)
.targetUrls("http://localhost:8080/orders/.*")
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:8080/orders/42"))
.GET()
.build();
try (FlakyHttpClient client =
new FlakyHttpClient(HttpClient.newHttpClient(), chaos)) {
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString());
OrderResult result = orderService.handle(response);
assertEquals(503, response.statusCode());
assertTrue(result.isFallback());
}
}
OrderResult and orderService represent the application code under test. The important part is that the request never needs a cooperating failure mode from the local server.
For exploratory testing, a value such as 0.2 or 0.3 is useful. It creates a mixed stream of successful and unsuccessful calls that can expose assumptions in logs, metrics, retry behavior, and user-facing error handling.
It was a good reminder that deterministic testing and random fault injection solve related but different problems. The library supports both, but the caller should choose deliberately.
Targeting only the dependency that matters
Most applications call more than one endpoint. Making every request fail can hide the behavior we actually want to observe.
targetUrls accepts a Java regular expression and matches it against the complete URI string:
// Every path on one host
.targetUrls("https://api\\.example\\.com/.*")
// Only the payments endpoint, with an optional query string
.targetUrls("https://api\\.example\\.com/payments(?:\\?.*)?")
// Any URI containing /experimental/
.targetUrls(".*/experimental/.*")
The full-match behavior matters. A pattern such as /payments does not search inside the URI. Callers need .* when they want a partial match.
Requests that do not match bypass both latency and failure injection.
This makes a few useful scenarios possible:
| Scenario | Injection | What it can verify |
|---|---|---|
| Retry policy | Repeated 503 responses |
Attempt limits, backoff, and eventual failure |
| Rate limiting | A synthetic 429
|
Backpressure and retry suppression |
| Circuit breaker | Guaranteed 500 or 503
|
Open, half-open, and recovery behavior |
| Fallback or cache | Failure on one endpoint | Degraded or cached responses are selected |
| Timeout handling | Fixed latency beyond an app deadline | Cancellation, cleanup, and error propagation |
| Observability | Random delay and intermittent errors | Logs, traces, metrics, dashboards, and alerts |
| Bulkhead behavior | Concurrent delayed async calls | A slow dependency does not consume unrelated capacity |
| Local demos | Predictable failures | Error states can be reproduced without changing a service |
The library is small, but its most valuable use cases sit outside the library. They are tests of the application behavior surrounding an unreliable dependency.
Here are three compact configurations I expect to use most often:
// Verify retry, circuit-breaker, or fallback behavior
FlakyConfig.builder()
.failureRate(1.0)
.errorStatus(503)
.build();
// Verify an application-level timeout without injecting an error
FlakyConfig.builder()
.failureRate(0.0)
.latency(LatencyStrategy.fixed(1_000))
.build();
// Verify rate-limit handling only for the payments API
FlakyConfig.builder()
.failureRate(1.0)
.errorStatus(429)
.targetUrls("https://api\\.example\\.com/payments(?:\\?.*)?")
.build();
These boundary-value configurations are predictable enough for CI. I would reserve random latency and partial failure rates for exploratory tests, longer-running resilience suites, and demonstrations.
A subtle timeout boundary
Artificial latency happens before the real request is delegated to HttpClient.
That means a timeout configured directly on HttpRequest does not include the injected pre-request delay. The wrapped client has not seen the request yet.
When I want to test an end-to-end deadline, I apply the timeout around the complete operation. For asynchronous code, that could be a future timeout:
client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.orTimeout(300, TimeUnit.MILLISECONDS)
.join();
This makes the caller observe an application-level timeout around the whole future. One more detail matters: orTimeout completes a future exceptionally; it does not mean the underlying work was cancelled. A test that specifically verifies cancellation should retain the original future and call cancel(true), which lets Flaky HTTP cancel a pending artificial delay and attempt to cancel a delegated request.
This distinction is small but important. A test should be clear about whether it is exercising the transport's request timeout, the application's complete-operation deadline, or explicit cancellation.
What Flaky HTTP intentionally does not simulate
Flaky HTTP sits immediately above HttpClient. It is not a proxy and it does not alter packets on the wire.
It can simulate application-visible latency and HTTP error responses. It cannot faithfully reproduce:
- DNS resolution failures;
- connection refusal;
- TLS negotiation errors;
- connection resets;
- truncated response bodies;
- malformed HTTP;
- bandwidth restrictions; or
- packet loss.
Those tests need a different layer: a fault-injecting proxy, a container networking tool, a faulty test server, or a network emulator.
I think this limitation is healthy. Small libraries are more useful when their boundary is explicit. Flaky HTTP covers the cases where the application needs to observe a slow call or an HTTP error. It does not pretend that every distributed-systems failure is equivalent to a 503.
A few decisions that paid off
A few implementation choices had an outsized effect on the final library.
1. Immutable configuration
FlakyConfig is built once and then shared safely. The failure rate, latency strategy, error status, and URL pattern cannot change underneath an in-flight request.
2. Validation at the boundary
Failure rates must be finite values from 0.0 through 1.0. Error statuses must be between 400 and 599. The built-in latency factories reject negative values and invalid ranges; custom strategies are required by their API contract to return a non-negative delay. Invalid regular expressions fail during configuration rather than during a later request.
3. No runtime dependencies
The implementation uses Java 11's HTTP, concurrency, and flow APIs. Keeping the runtime dependency list empty makes the library easier to introduce into test suites without creating version conflicts.
4. Explicit scheduler ownership
The constructor determines who owns the scheduler, and close() follows that decision. This avoids both leaked internal threads and surprising shutdowns of shared application infrastructure.
5. Honest documentation of edge cases
The README and Javadocs describe full-URI matching, body-handler behavior, timeout boundaries, asynchronous cancellation, and the difference between synthetic HTTP failures and network faults.
For a testing library, those details are part of the API. A false assumption in a failure test can be worse than having no test at all.
Installation
The Maven coordinates for version 1.0.0 are:
<dependency>
<groupId>com.tapadyuti</groupId>
<artifactId>flaky-http</artifactId>
<version>1.0.0</version>
</dependency>
For Gradle:
testImplementation("com.tapadyuti:flaky-http:1.0.0")
The project requires Java 11 or later. The repository also includes a comprehensive demo covering fixed latency, random latency, URL targeting, guaranteed failures, asynchronous calls, and a caller-owned scheduler. The test suite additionally verifies cancellation of a pending asynchronous delay.
Flaky HTTP is available under the Apache License 2.0. Focused issues and pull requests are welcome, especially when they include a reproducible failure scenario and tests.
git clone https://github.com/tapadyutichatterjee/flaky-http.git
cd flaky-http
mvn clean verify
java -cp target/classes com.tapadyuti.flakyhttp.FlakyHttpDemo
What I would improve next
The current API is deliberately small, but there are several useful directions for future versions:
- Injectable failure selection for deterministic sequences such as “fail twice, then succeed.”
-
Richer synthetic responses with configurable headers and bodies, especially for
Retry-Afterand structured error payloads. - Transport-style failure modes that complete with selected exceptions, while keeping their semantics clearly separate from HTTP responses.
- More timing strategies, including progressive latency and scripted delay sequences.
- Examples with common resilience libraries to show retry, circuit-breaker, and time-limiter tests end to end.
I would add these carefully. The main value of the project is that a reader can understand its behavior quickly. More features should not turn a small failure-injection wrapper into an unpredictable simulation framework.
What I learned
A few lessons stood out while building this project.
Failure paths deserve the same API fidelity as success paths. Respecting BodyHandler, cancellation, interruption, and resource ownership matters even when the response is synthetic.
Randomness is a tool, not a testing strategy by itself. Random failure is useful for exploration. Guaranteed boundary values are better for regression tests.
The layer of injection defines the failures you can claim to test. An HTTP wrapper can validate application behavior around latency and status codes. It cannot prove behavior under DNS, TLS, or connection-level faults.
Small utilities benefit from detailed documentation. The code may be compact, but users still need to understand timing, matching, lifecycle, and failure semantics.
The hardest part was not generating a 503. It was making that generated response behave enough like the real API that application tests remain meaningful.
Closing thoughts
Flaky HTTP started from a narrow question: can I make an existing Java HTTP integration fail on purpose, without changing the remote service?
The result is intentionally modest. It is a wrapper, not a platform. But it creates a useful seam for testing retries, timeouts, circuit breakers, fallbacks, caches, observability, and other behavior that is easy to design and surprisingly easy to leave unverified.
The project is available on GitHub, with the complete README, examples, Javadocs, and tests.
If you work with Java HTTP integrations, I would be interested to hear which failure modes are hardest to reproduce in your own test suites.
You can also find me at tapadyuti.com and here on DEV Community.
Top comments (0)