Quarkus REST Client: Timeouts, Retries, and Redaction
Build an outbound HTTP template with explicit time budgets, one safe retry, useful API errors, and tests for slow or flaky dependencies.
I do not trust outbound HTTP code that only ran against a fast local stub. That is enough for a demo. It is not enough when your API has a real latency budget and a dependency that likes to go slow at exactly the wrong time.
Quarkus REST Client still defaults to a 15 second connect timeout and a 30 second read timeout. I would not call that resilience. Your request can sit there burning somebody else’s budget until the failure finally shows up in the wrong place. Turn on request-response logging with no masking and the bearer token you needed for debugging is now in the log stream. Add retries on the wrong call and one flaky dependency can turn into duplicate work.
This tutorial builds a small service with explicit per-client timeouts, one bounded retry on a safe read path, masked outbound logs, useful downstream error translation, and tests that cover the bad days as well as the happy path.
What we build
We will build carrier-bridge, a Quarkus service that exposes GET /tracking/{trackingId} and calls a downstream carrier status API through a declarative REST client. By the end you will have:
a declarative REST client using
rest-client-jacksonexplicit
connect-timeoutandread-timeouton that clientone bounded retry for a transient
503on an idempotent readrequest-response logging with redacted auth headers
client-side and server-side error mapping into clean JSON
WireMock Dev Service tests through the Quarkus WireMock extension for slow, flaky, and permanently broken downstream responses
The retry example is a read operation on purpose. Automatic retry is only safe when the call is safe to repeat. If you retry writes, charges, or submit flows, you need idempotency keys or another guardrail. That rule comes from HTTP semantics, not from Quarkus magic.
What you need
You need Java 21, the Quarkus CLI, and basic familiarity with declarative REST clients. The walkthrough takes about two ☕️☕️.
Java 21
Quarkus CLI (
quarkus create app)Basic Quarkus REST Client knowledge
Build the base
This article uses Quarkus 3.36.1 and Java 21. Create the project:
quarkus create app org.acme:carrier-bridge \
--extension='rest-jackson,rest-client-jackson,smallrye-fault-tolerance,smallrye-openapi' \
--java=21 \
--no-codeExtensions:
rest-jacksonfor the inbound APIrest-client-jacksonfor the declarative outbound clientsmallrye-fault-tolerancefor retry policysmallrye-openapiso the service still looks like something a team would ship
Add the Quarkus WireMock extension. It starts WireMock as a Dev Service in dev and test mode, so you do not manage server lifecycle yourself:
./mvnw quarkus:add-extension -Dextensions="io.quarkiverse.wiremock:quarkus-wiremock"And yes. I am using the mvn command here instead the CLI. I keep mixing both, depending on which command I remember first. So please bear with me. Both integrations are great and I do not want anybody to feel like they have to use the Quarkus CLI.
The command adds quarkus-wiremock to the build. Pin the Quarkiverse version and add the test helper module in pom.xml:
<properties>
<quarkus-wiremock.version>1.6.3</quarkus-wiremock.version>
</properties>
<dependencies>
<dependency>
<groupId>io.quarkiverse.wiremock</groupId>
<artifactId>quarkus-wiremock</artifactId>
<version>${quarkus-wiremock.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.quarkiverse.wiremock</groupId>
<artifactId>quarkus-wiremock-test</artifactId>
<version>${quarkus-wiremock.version}</version>
</dependency>
</dependencies>quarkus-wiremock-test brings in @ConnectWireMock, which injects a WireMock client into your tests. The extension publishes quarkus.wiremock.devservices.port so the REST client URL can point at the running stub without hard-coding a port.
Make it work
Let’s follow one boring happy path first, then widen it. Start with the payload types and failure exceptions, then the client, service, and resource.
Payload and error types
The downstream carrier returns a full tracking document. Our public API returns a trimmed response. Failures become typed exceptions with a stable downstreamStatus field for the caller.
Create src/main/java/org/acme/carrier/bridge/CarrierTrackingPayload.java:
package org.acme.carrier.bridge;
import java.time.Instant;
record CarrierTrackingPayload(String trackingId, String carrier, String status, Instant lastUpdated) {
}Create src/main/java/org/acme/carrier/bridge/TrackingResponse.java:
package org.acme.carrier.bridge;
import java.time.Instant;
public record TrackingResponse(String trackingId, String carrier, String status, Instant lastUpdated) {
}
Create src/main/java/org/acme/carrier/bridge/ApiError.java:
package org.acme.carrier.bridge;
public record ApiError(String code, String message, Integer downstreamStatus) {
}Create src/main/java/org/acme/carrier/bridge/CarrierFailures.java:
package org.acme.carrier.bridge;
abstract class CarrierFailure extends RuntimeException {
private final Integer downstreamStatus;
CarrierFailure(String message, Integer downstreamStatus) {
super(message);
this.downstreamStatus = downstreamStatus;
}
CarrierFailure(String message, Integer downstreamStatus, Throwable cause) {
super(message, cause);
this.downstreamStatus = downstreamStatus;
}
Integer downstreamStatus() {
return downstreamStatus;
}
}
final class TrackingNotFoundException extends CarrierFailure {
TrackingNotFoundException(String trackingId) {
super("Carrier API could not find tracking ID '%s'.".formatted(trackingId), 404);
}
}
final class CarrierUnavailableException extends CarrierFailure {
CarrierUnavailableException() {
super("Carrier API is temporarily unavailable.", 503);
}
}
final class CarrierTimeoutException extends CarrierFailure {
CarrierTimeoutException(Throwable cause) {
super("Carrier API did not respond before the outbound read timeout.", null, cause);
}
}
final class CarrierInvocationException extends CarrierFailure {
CarrierInvocationException(Throwable cause) {
super("Carrier API call failed before a usable response was returned.", null, cause);
}
}Each exception maps to one caller-facing HTTP status later. A timeout is not the same shape as a missing tracking ID, and neither should look like a generic transport failure.
Outbound auth filter
Real carrier APIs expect credentials on every call. A ClientRequestFilter is the right place to attach them so the rest of the code stays focused on business logic.
Create src/main/java/org/acme/carrier/bridge/CarrierAuthFilter.java:
package org.acme.carrier.bridge;
import io.quarkus.arc.Unremovable;
import jakarta.ws.rs.client.ClientRequestContext;
import jakarta.ws.rs.client.ClientRequestFilter;
import jakarta.ws.rs.core.HttpHeaders;
@Unremovable
public class CarrierAuthFilter implements ClientRequestFilter {
static final String DEMO_BEARER_TOKEN = "carrier-demo-bearer-token";
static final String DEMO_API_KEY = "carrier-demo-api-key";
@Override
public void filter(ClientRequestContext requestContext) {
requestContext.getHeaders().putSingle(HttpHeaders.AUTHORIZATION, "Bearer " + DEMO_BEARER_TOKEN);
requestContext.getHeaders().putSingle("X-Carrier-Key", DEMO_API_KEY);
}
}@Unremovable keeps Arc from dropping the filter when nothing else injects it directly. In production these values come from configuration or a secrets store, not constants. The test suite uses the constants to prove redaction works.
Declarative REST client
The client interface is the outbound contract. Register it with configKey = "carrier-api" so timeouts, URL, and logging stay in one configuration namespace.
Create src/main/java/org/acme/carrier/bridge/CarrierStatusClient.java:
package org.acme.carrier.bridge;
import java.net.URI;
import io.quarkus.rest.client.reactive.ClientExceptionMapper;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
@Path("/carrier-api")
@RegisterRestClient(configKey = "carrier-api")
@RegisterProvider(CarrierAuthFilter.class)
public interface CarrierStatusClient {
@GET
@Path("/tracking/{trackingId}")
CarrierTrackingPayload getTracking(@PathParam("trackingId") String trackingId);
@ClientExceptionMapper
static RuntimeException toException(Response response, URI uri) {
return switch (response.getStatus()) {
case 404 -> new TrackingNotFoundException(uri.getPath().substring(uri.getPath().lastIndexOf('/') + 1));
case 503 -> new CarrierUnavailableException();
default -> null;
};
}
}@ClientExceptionMapper runs before the response body is unmarshalled into CarrierTrackingPayload. That is why a 404 with an error JSON body does not explode into a Jackson mapping exception. Returning null leaves other status codes to the default client error handling.
@RegisterProvider(CarrierAuthFilter.class) wires the auth filter without touching the interface method signatures.
Service with retry and timeout handling
Retries belong on the service method, not on the client interface. That keeps the retry policy tied to one business operation and one failure type.
Create src/main/java/org/acme/carrier/bridge/TrackingService.java:
package org.acme.carrier.bridge;
import java.net.SocketTimeoutException;
import java.util.concurrent.TimeoutException;
import org.eclipse.microprofile.faulttolerance.Retry;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.ProcessingException;
@ApplicationScoped
class TrackingService {
private final CarrierStatusClient carrierStatusClient;
TrackingService(@RestClient CarrierStatusClient carrierStatusClient) {
this.carrierStatusClient = carrierStatusClient;
}
@Retry(retryOn = CarrierUnavailableException.class)
TrackingResponse fetchTracking(String trackingId) {
try {
CarrierTrackingPayload payload = carrierStatusClient.getTracking(trackingId);
return new TrackingResponse(
payload.trackingId(),
payload.carrier(),
payload.status(),
payload.lastUpdated());
} catch (ProcessingException e) {
if (hasTimeoutCause(e)) {
throw new CarrierTimeoutException(e);
}
throw new CarrierInvocationException(e);
}
}
private boolean hasTimeoutCause(Throwable throwable) {
Throwable current = throwable;
while (current != null) {
if ((current instanceof SocketTimeoutException) || (current instanceof TimeoutException)) {
return true;
}
String simpleName = current.getClass().getSimpleName();
if (simpleName.contains("Timeout")) {
return true;
}
current = current.getCause();
}
return false;
}
}@Retry(retryOn = CarrierUnavailableException.class) retries only transient carrier outages. A 404 does not retry. A read timeout does not retry either, because CarrierTimeoutException is outside retryOn. That is the behavior you want: one safe retry for a blip, not a second slow wait on an already late call.
ProcessingException wraps transport-level failures from the REST client. The hasTimeoutCause walk is ugly but practical. Vert.x timeout types do not always surface as SocketTimeoutException at the top of the stack.
Inbound resource and API error mapping
The resource stays thin. Exception mapping turns typed failures into stable JSON for callers.
Create src/main/java/org/acme/carrier/bridge/TrackingResource.java:
package org.acme.carrier.bridge;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.jboss.resteasy.reactive.RestResponse;
import org.jboss.resteasy.reactive.server.ServerExceptionMapper;
@Path("/tracking")
@Produces(MediaType.APPLICATION_JSON)
public class TrackingResource {
private final TrackingService trackingService;
TrackingResource(TrackingService trackingService) {
this.trackingService = trackingService;
}
@GET
@Path("/{trackingId}")
public RestResponse<TrackingResponse> tracking(@PathParam("trackingId") String trackingId) {
return RestResponse.ok(trackingService.fetchTracking(trackingId));
}
@ServerExceptionMapper
RestResponse<ApiError> mapTrackingNotFound(TrackingNotFoundException exception) {
return RestResponse.status(
Response.Status.NOT_FOUND,
new ApiError("tracking_not_found", exception.getMessage(), exception.downstreamStatus()));
}
@ServerExceptionMapper
RestResponse<ApiError> mapCarrierUnavailable(CarrierUnavailableException exception) {
return RestResponse.status(
Response.Status.SERVICE_UNAVAILABLE,
new ApiError("carrier_unavailable", exception.getMessage(), exception.downstreamStatus()));
}
@ServerExceptionMapper
RestResponse<ApiError> mapCarrierTimeout(CarrierTimeoutException exception) {
return RestResponse.status(
Response.Status.GATEWAY_TIMEOUT,
new ApiError("carrier_timeout", exception.getMessage(), exception.downstreamStatus()));
}
@ServerExceptionMapper
RestResponse<ApiError> mapCarrierInvocation(CarrierInvocationException exception) {
return RestResponse.status(
Response.Status.BAD_GATEWAY,
new ApiError("carrier_invocation_failed", exception.getMessage(), exception.downstreamStatus()));
}
}Each mapper picks a deliberate HTTP status. 504 for timeout, 503 for downstream outage, 404 for unknown tracking ID, 502 for everything else that failed before a usable response arrived. Callers and monitors can tell these apart without reading stack traces.
Configure it
Add src/main/resources/application.properties:
carrier.api.url=http://localhost:8089
quarkus.rest-client."carrier-api".url=${carrier.api.url}
quarkus.rest-client."carrier-api".connect-timeout=100
quarkus.rest-client."carrier-api".read-timeout=200
quarkus.rest-client.logging.scope=request-response
quarkus.rest-client.logging.body-limit=120
quarkus.rest-client.logging.masked-headers=Authorization,Cookie,X-Carrier-Key
quarkus.log.category."org.jboss.resteasy.reactive.client.logging".min-level=DEBUG
quarkus.log.category."org.jboss.resteasy.reactive.client.logging".level=DEBUG
quarkus.fault-tolerance."org.acme.carrier.bridge.TrackingService/fetchTracking".retry.max-retries=1
quarkus.fault-tolerance."org.acme.carrier.bridge.TrackingService/fetchTracking".retry.delay=50
quarkus.fault-tolerance."org.acme.carrier.bridge.TrackingService/fetchTracking".retry.delay-unit=millis
quarkus.fault-tolerance."org.acme.carrier.bridge.TrackingService/fetchTracking".retry.jitter=25
quarkus.fault-tolerance."org.acme.carrier.bridge.TrackingService/fetchTracking".retry.jitter-unit=milliscarrier.api.url - Base URL for the downstream carrier. In tests, the %test. profile override below points this at the WireMock Dev Service port.
connect-timeout=100 and read-timeout=200 - One attempt gets about 300 ms before the client gives up. Without these, Quarkus falls back to 15 s connect and 30 s read. That is a long time to block a caller-facing thread.
logging.scope=request-response - Logs outbound requests and responses. Useful for debugging, dangerous without header masking.
logging.body-limit=120 - Truncates logged response bodies. Downstream JSON may still appear in logs at this limit. We are not logging full payloads to callers; this setting only caps what operations sees in log lines.
logging.masked-headers - Replaces matching header values with <hidden>. Setting this property replaces the default list. If you want Authorization and Cookie masked, keep them in your explicit list alongside any custom headers like X-Carrier-Key.
REST client log category at DEBUG - Request-response logging does not show up in tests unless this category is enabled. The redaction test depends on it.
Fault tolerance properties - One retry, 50 ms base delay, 25 ms jitter. Worst case for a permanent 503 is two outbound attempts plus roughly 75 ms of backoff, still inside a sub-second caller budget.
Point the REST client at the WireMock Dev Service during tests. Create src/test/resources/application.properties:
%test.carrier.api.url=http://localhost:${quarkus.wiremock.devservices.port}The ${quarkus.wiremock.devservices.port} expression is published by the Quarkus WireMock extension when the Dev Service starts. Main application.properties keeps carrier.api.url=http://localhost:8089 for manual dev runs against a real or standalone stub.
Make it survive
Retry only where repetition is safe
The @Retry on fetchTracking is scoped to CarrierUnavailableException. That is a read and a transient outage shape. Do not copy this pattern onto charge, create, or submit endpoints without idempotency keys. One duplicate tracking lookup is annoying. One duplicate charge is a incident.
Keep the retry budget small
max-retries=1 means one extra attempt after the first failure, not an open-ended loop. Under load, generous retry policies turn one slow dependency into a traffic multiplier. If you need more than one retry, you probably need a circuit breaker or async recovery path instead of another blind repeat.
Separate failure shapes for callers and operators
A timeout (504), a downstream outage (503), a missing ID (404), and a broken transport call (502) should not collapse into one generic error. The mappers above give callers that separation. Quarkus REST Client also exposes http.clients metrics for declarative clients when Micrometer is on the classpath. That is worth wiring in production, but metrics deserve their own article once the failure mapping is correct.
Response body logging can still leak operational detail even when headers are masked. Treat log access with the same care as credential storage. Do not forward raw downstream error JSON to your public API; the ApiError record is the contract.
Prove it
Stub helper
The Quarkus WireMock extension injects a WireMock client when the test class carries @ConnectWireMock. Keep the stub recipes in a small helper so the test class stays readable.
Create src/test/java/org/acme/carrier/bridge/CarrierStubs.java:
package org.acme.carrier.bridge;
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.get;
import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
import static com.github.tomakehurst.wiremock.client.WireMock.okJson;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import com.github.tomakehurst.wiremock.client.WireMock;
import com.github.tomakehurst.wiremock.stubbing.Scenario;
final class CarrierStubs {
private CarrierStubs() {
}
static void reset(WireMock wireMock) {
wireMock.resetMappings();
wireMock.resetRequests();
wireMock.resetScenarios();
}
static void stubSuccess(WireMock wireMock, String trackingId) {
wireMock.register(get(urlEqualTo(path(trackingId)))
.willReturn(okJson(successBody(trackingId, "IN_TRANSIT", "2026-06-05T12:30:00Z"))));
}
static void stubSlow(WireMock wireMock, String trackingId) {
wireMock.register(get(urlEqualTo(path(trackingId)))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withFixedDelay(450)
.withBody(successBody(trackingId, "IN_TRANSIT", "2026-06-05T12:30:00Z"))));
}
static void stubUnavailableThenSuccess(WireMock wireMock, String trackingId) {
String scenarioName = "carrier-retry-" + trackingId;
wireMock.register(get(urlEqualTo(path(trackingId)))
.inScenario(scenarioName)
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(aResponse()
.withStatus(503)
.withHeader("Content-Type", "application/json")
.withBody(errorBody("carrier unavailable")))
.willSetStateTo("recovered"));
wireMock.register(get(urlEqualTo(path(trackingId)))
.inScenario(scenarioName)
.whenScenarioStateIs("recovered")
.willReturn(okJson(successBody(trackingId, "DELIVERED", "2026-06-05T12:31:00Z"))));
}
static void stubUnavailable(WireMock wireMock, String trackingId) {
wireMock.register(get(urlEqualTo(path(trackingId)))
.willReturn(aResponse()
.withStatus(503)
.withHeader("Content-Type", "application/json")
.withBody(errorBody("carrier unavailable"))));
}
static void stubNotFound(WireMock wireMock, String trackingId) {
wireMock.register(get(urlEqualTo(path(trackingId)))
.willReturn(aResponse()
.withStatus(404)
.withHeader("Content-Type", "application/json")
.withBody(errorBody("unknown tracking"))));
}
static int requestCount(WireMock wireMock, String trackingId) {
return wireMock.findAll(getRequestedFor(urlEqualTo(path(trackingId)))).size();
}
private static String path(String trackingId) {
return "/carrier-api/tracking/" + trackingId;
}
private static String successBody(String trackingId, String status, String lastUpdated) {
return """
{
"trackingId": "%s",
"carrier": "Parcel Rocket",
"status": "%s",
"lastUpdated": "%s"
}
""".formatted(trackingId, status, lastUpdated);
}
private static String errorBody(String message) {
return """
{
"error": "%s"
}
""".formatted(message);
}
}stubSlow uses a 450 ms fixed delay against a 200 ms read timeout, so the timeout test fails for the right reason. stubUnavailableThenSuccess uses WireMock scenarios to return 503 once, then 200 on the second call.
Log capture helper
Create src/test/java/org/acme/carrier/bridge/InMemoryLogHandler.java:
package org.acme.carrier.bridge;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.logging.Handler;
import java.util.logging.LogRecord;
final class InMemoryLogHandler extends Handler {
private final List<String> messages = new CopyOnWriteArrayList<>();
@Override
public void publish(LogRecord record) {
if (record != null) {
messages.add(record.getMessage());
}
}
@Override
public void flush() {
// nothing to flush
}
@Override
public void close() {
messages.clear();
}
void clear() {
messages.clear();
}
String joinedMessages() {
return String.join("\n", messages);
}
}Failure tests
Create src/test/java/org/acme/carrier/bridge/TrackingResourceTest.java:
package org.acme.carrier.bridge;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.nullValue;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.logging.Level;
import java.util.logging.Logger;
import com.github.tomakehurst.wiremock.client.WireMock;
import io.quarkiverse.wiremock.devservice.ConnectWireMock;
import io.quarkus.test.common.http.TestHTTPEndpoint;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@QuarkusTest
@ConnectWireMock
@TestHTTPEndpoint(TrackingResource.class)
class TrackingResourceTest {
private static final String REST_CLIENT_LOG_CATEGORY = "org.jboss.resteasy.reactive.client.logging";
private static Logger restClientLogger;
private static InMemoryLogHandler logHandler;
WireMock wiremock;
@BeforeAll
static void installLogHandler() {
restClientLogger = Logger.getLogger(REST_CLIENT_LOG_CATEGORY);
logHandler = new InMemoryLogHandler();
restClientLogger.addHandler(logHandler);
restClientLogger.setLevel(Level.FINE);
}
@AfterAll
static void removeLogHandler() {
if (restClientLogger != null && logHandler != null) {
restClientLogger.removeHandler(logHandler);
}
}
@BeforeEach
void resetState() {
CarrierStubs.reset(wiremock);
logHandler.clear();
}
@Test
void returnsTrackingStatus() {
CarrierStubs.stubSuccess(wiremock, "TRACK-123");
given()
.when().get("/TRACK-123")
.then()
.statusCode(200)
.body("trackingId", equalTo("TRACK-123"))
.body("carrier", equalTo("Parcel Rocket"))
.body("status", equalTo("IN_TRANSIT"))
.body("lastUpdated", equalTo("2026-06-05T12:30:00Z"));
}
@Test
void returnsGatewayTimeoutWhenCarrierIsSlow() {
CarrierStubs.stubSlow(wiremock, "TRACK-SLOW");
given()
.when().get("/TRACK-SLOW")
.then()
.statusCode(504)
.body("code", equalTo("carrier_timeout"))
.body("message", equalTo("Carrier API did not respond before the outbound read timeout."))
.body("downstreamStatus", nullValue());
}
@Test
void retriesOnceAndSucceedsAfterTransientFailure() {
CarrierStubs.stubUnavailableThenSuccess(wiremock, "TRACK-RETRY");
given()
.when().get("/TRACK-RETRY")
.then()
.statusCode(200)
.body("trackingId", equalTo("TRACK-RETRY"))
.body("status", equalTo("DELIVERED"));
assertEquals(2, CarrierStubs.requestCount(wiremock, "TRACK-RETRY"));
}
@Test
void returnsServiceUnavailableAfterPermanentCarrierFailure() {
CarrierStubs.stubUnavailable(wiremock, "TRACK-DOWN");
given()
.when().get("/TRACK-DOWN")
.then()
.statusCode(503)
.body("code", equalTo("carrier_unavailable"))
.body("message", equalTo("Carrier API is temporarily unavailable."))
.body("downstreamStatus", equalTo(503));
assertEquals(2, CarrierStubs.requestCount(wiremock, "TRACK-DOWN"));
}
@Test
void returnsNotFoundWhenCarrierDoesNotKnowTrackingId() {
CarrierStubs.stubNotFound(wiremock, "TRACK-MISSING");
given()
.when().get("/TRACK-MISSING")
.then()
.statusCode(404)
.body("code", equalTo("tracking_not_found"))
.body("message", equalTo("Carrier API could not find tracking ID 'TRACK-MISSING'."))
.body("downstreamStatus", equalTo(404));
assertEquals(1, CarrierStubs.requestCount(wiremock, "TRACK-MISSING"));
}
@Test
void masksSensitiveHeadersInRestClientLogs() {
CarrierStubs.stubSuccess(wiremock, "TRACK-LOGS");
given()
.when().get("/TRACK-LOGS")
.then()
.statusCode(200);
String logs = logHandler.joinedMessages();
assertFalse(logs.contains(CarrierAuthFilter.DEMO_BEARER_TOKEN));
assertFalse(logs.contains(CarrierAuthFilter.DEMO_API_KEY));
assertTrue(logs.contains("Authorization"));
assertTrue(logs.contains("X-Carrier-Key"));
assertTrue(logs.contains("<hidden>"));
}
}Before you run the retry test, predict how many calls WireMock should see for TRACK-RETRY. The answer is two: first call gets 503, retry gets 200. For TRACK-DOWN it is also two, then the API returns 503 to the caller. For TRACK-MISSING it is one, because 404 is not in retryOn.
Run the suite:
./mvnw testAll six tests should pass. The slow-downstream test proves the read timeout surfaces as 504. The permanent failure test proves retries stop after one extra attempt. The redaction test proves fake secrets never appear in captured log lines.
Conclusion
We built an outbound HTTP path that fails on purpose instead of by accident. Explicit timeouts stop slow dependencies from eating the caller’s budget, one bounded retry handles a transient 503 on a safe read, masked headers keep credentials out of logs, and separate error codes give callers something useful when the carrier misbehaves.
The complete code is on my GitHub for you to check out.



I have a question why you are not using java 25 in your examples