Faults Reference
A fault is what a test applies to a target to make the dependency behind it fail in a chosen way.
Overview
Every fault method returns an ActiveFault.
Close it to end the fault, and use try-with-resources so a test can assert recovery in the same method:
try (ActiveFault f = faults.target(InventoryClient.class).unavailable()) {
// the dependency is down
}
// the dependency is back
Anything left open is ended after the test.
A fault on a whole target has it to itself. Applying a second while one is still active throws IllegalStateException naming the active one, rather than replacing it or combining the two. The same goes for faults.all(), or a datasource with two connections, when any connection behind it already has a fault. Faults on different requests of one target, on the other hand, are active together.
|
Connection Faults
| Fault | What the application sees | Faithful? |
|---|---|---|
|
New connections refused ( |
Yes |
|
New connection attempts hang until the connect timeout of the client; live connections keep working |
Yes, Linux only |
|
Connections stay open, nothing is relayed: the read timeout of the client is the only bound |
Yes |
|
Relayed up to a moment in the response, then the connection hangs; the rest follows when the fault ends |
Yes |
|
Every buffer is delayed |
Yes |
|
The connection is reset when the upstream starts responding |
Yes |
|
The connection is reset at a moment in the response |
Yes |
|
The connection is closed cleanly, a FIN rather than a reset, when the upstream starts responding: the request was processed and nothing came back |
Yes |
|
The connection is closed cleanly at a moment in the response: a body cut short of its |
Yes |
|
Every request is answered with a synthetic HTTP response of that status instead of reaching the upstream |
Yes, plaintext HTTP/1.x only |
|
One-shot: every pooled connection is reset now |
Yes |
connectTimeout() relies on the Linux kernel silently dropping SYNs when the accept queue of a listener is full. On other operating systems it throws UnsupportedOperationException.
|
Moments
The moment at which diesMidRequest(moment), closes(moment) and hangs(moment) strike is a Moment, built from its static methods.
The ones that read an HTTP response are grouped under Moment.Http.
| Moment | Where the fault strikes |
|---|---|
|
After |
|
When the client next sends data, so the request never reaches the upstream |
|
After the status line and headers of a plaintext HTTP/1.x response, before any of the body |
|
After |
|
After |
diesMidRequest() without a moment is diesMidRequest(afterBytes(0)): the reset comes as soon as the upstream starts to respond; closes() is closes(afterBytes(0)) the same way.
The difference between the two is what the client sees: a reset is an error on the socket, a clean close is an end of stream.
A client that knows how long the response is, from Content-Length or the chunked encoding, notices that it is short and fails, with a different error from a reset; a client reading a response of unknown length until the connection closes takes a truncated one for complete.
HTTP Moments
The Http moments read the response, so they need plaintext HTTP/1.x.
They can only be applied to a target reached over plaintext HTTP, an http:// or ws:// address.
Applying an HTTP moment, or http().respondsWith(status), to any other target throws UnsupportedOperationException: a datasource, a Redis or MongoDB client, or a client configured with an https:// URL. The message names the target and the property its address came from. A target made of several connections is refused as a whole when any of them is not plaintext HTTP.
|
The responses on a connection are followed message by message:
-
Chunks and events are counted from the start of their response, so on a connection that is kept alive the fault strikes in the first response that gets that far
-
An interim response, such as
100 Continue, is relayed, and the response after it is the one that is cut -
afterChunks(n)strikes in a chunked response only; a response that is not chunked is relayed whole -
afterSseEvents(n)counts the events of atext/event-streamresponse, whether or not it is chunked; any other response is relayed whole
They are made for streamed responses; see Testing streamed responses.
HTTP Responses
http().respondsWith(status) answers every request with a synthetic HTTP response instead of relaying it to the upstream, so the application sees the remote API fail the way it would for that status.
The response carries Connection: close and the connection is closed cleanly once it is written, so a pooled client opens a fresh connection for its next call, which is answered too.
The returned HttpResponse is the ActiveFault, and refines the response:
| Method | Effect |
|---|---|
|
The response body, sent as UTF-8. A |
|
A body of that many bytes of filler, streamed rather than held in memory, with |
|
The |
|
An extra response header; may be called more than once |
Which requests are answered is decided before the fault is applied, with http().forFirst(n), every(n) or after(n): target.http().forFirst(2).http().respondsWith(503) fails two calls and lets a @Retry succeed on the third.
A response that is not valid for its content type needs no fault of its own: contentType("application/json").body("{") is one, and so is a 200 with a body the client cannot map.
try (ActiveFault f = faults.target(InventoryClient.class)
.http().respondsWith(429)
.contentType("application/json")
.body("{\"error\":\"rate limited\"}")
.header("Retry-After", "30")) {
when()
.post("/orders?item=a")
.then()
.statusCode(503);
}
| The response is synthesised from a plaintext HTTP/1.x request, so the target has to be reached over plaintext HTTP; see HTTP Moments for what happens otherwise. |
| The upstream still has to be reachable. The proxy connects to it before answering, although nothing is sent to it; against a dependency that is not running the application gets a connection reset, and the guard reports the unreachable upstream. |
Faults on Some Requests
A fault on a target acts on everything that goes through its proxy.
A fault on an Endpoint acts on some of the requests only, and relays the others untouched, even on the same pooled connection.
A method reference names the requests one method of a REST Client sends, from its @GET, @POST and the like and its @Path:
try (ActiveFault f = faults.target(InventoryClient::reserve).hangs()) {
when()
.post("/orders?item=a")
.then()
.statusCode(503);
// the other methods of InventoryClient keep working
}
http().requests(method, path) names them on any plaintext HTTP target, such as the embedding calls of a model:
faults.target(ChatModel.class).http().requests("POST", "/embeddings").http().respondsWith(429);
The path is compared with the end of the request path, so a base path in the URL of the client does not have to be repeated: /embeddings covers POST /v1/embeddings.
A segment may be a template parameter as @Path writes them, {id} for any one segment or {id: [0-9]}+ for one that matches a regular expression, and the query string is not compared.
Calling requests on an endpoint narrows it further.
Some of the Requests, by Count
http().forFirst(n), http().every(n) and http().after(n) take some of the requests an endpoint covers, counted across its connections in the order they arrive, and return an endpoint that any fault can be applied to:
| Selection | The requests the fault acts on |
|---|---|
|
The first |
|
Every |
|
Every one after the first |
// one reservation in two takes longer than the caller's @Timeout
try (ActiveFault f = faults.target(InventoryClient::reserve).http().every(2).slow(Duration.ofMillis(1500))) {
when().post("/orders?item=a").then().statusCode(503);
when().post("/orders?item=b").then().statusCode(201);
when().post("/orders?item=c").then().statusCode(503);
}
The count is deterministic: the test knows which calls fail, because it sends them.
One selection per endpoint; a second throws IllegalStateException.
Faults that fire at random or on a clock were left out on purpose: a test that fails one run in twenty is worse than no test.
An endpoint takes every fault of a target except the three that act on connections before any request is sent on them: unavailable(), connectTimeout() and dropConnections().
On an endpoint:
-
hangs()holds a covered request as it arrives, and its connection with it; it reaches the upstream when the fault ends -
A moment counts the response of a covered request, and
onNextWrite()strikes when the client sends one -
slow,advanced()andrespondsWithdelay, throttle or answer the covered requests and their responses only
Telling one request from another needs plaintext HTTP/1.x, so a fault on an endpoint can only be applied to a target reached over an http:// or ws:// address.
|
Several at Once
Faults on different requests of one target are active together, each acting on its own requests, so one method of a client can hang while another fails with a status:
try (ActiveFault hang = faults.target(InventoryClient::reserve).hangs();
ActiveFault error = faults.target(InventoryClient::stockUpdates).http().respondsWith(500)) {
when()
.post("/orders?item=c")
.then()
.statusCode(503);
when()
.get("/stock/updates")
.then()
.statusCode(502);
}
-
Ending one leaves the others in place
-
Two faults on exactly the same requests are refused with
IllegalStateException -
Where the requests of two endpoints overlap, such as
requests("POST", "/inventory/{action}")andInventoryClient::reserve, the fault applied first acts on the requests they share -
A fault on the whole target is refused while faults on some of its requests are active, and the other way round
Method References
A method reference must be of the form Type::method: a lambda, or a reference bound to an instance such as client::reserve, throws IllegalArgumentException.
A method with up to five parameters can be named, and one that is overloaded with the same number of parameters needs a cast to pick one.
Advanced Faults
advanced() holds faults in the style of the toxics of Toxiproxy, for tests that need more control than the faults above.
Each takes a Direction: UPSTREAM for data from the application to the upstream, DOWNSTREAM for data from the upstream to the application, or BOTH.
| Fault | Effect |
|---|---|
|
Delays each buffer by |
|
Limits throughput to that number of bytes per second |
|
Splits every buffer larger than |
try (ActiveFault f = target.advanced().latency(Duration.ofMillis(100),
Duration.ofMillis(50), Advanced.Direction.UPSTREAM)) {
// every request is delayed by 100 to 150 ms on its way to the upstream
}
Every Target at Once
faults.all() is every known target, for "everything is down" tests:
try (ActiveFault f = faults.all().unavailable()) {
when()
.post("/orders?item=h")
.then()
.statusCode(503);
}
What Happens When a Fault Ends
A fault leaves consequences behind in the application: pooled connections flagged as broken, circuit breakers left open. When a fault ends, and after every test, the extension undoes them:
-
Agroal pools are flushed
-
Every SmallRye Fault Tolerance circuit breaker is reset
faults.resetAll() ends every active fault and runs the same cleanup on demand.