Last Updated: June 1, 2026 at 14:00

Micronaut HTTP Clients Explained for Spring Developers

Learn Micronaut's declarative @Client, low-level HttpClient, retries, circuit breakers, and filters through familiar Spring comparisons

Most Spring developers reach for RestTemplate, WebClient, or OpenFeign when one service needs to call another. Micronaut provides the same capabilities through its compile-time generated @Client interfaces and low-level HttpClient API, but with less reflection, lower startup overhead, and tighter framework integration. In this guide you will learn how Micronaut handles synchronous HTTP communication, client configuration, retries, circuit breakers, filters, and testing using concepts that will already feel familiar if you come from Spring Boot.

Image

How Micronaut HTTP Clients Work: The Mental Model

Micronaut gives you two ways to call other HTTP services.

The declarative @Client lets you define an annotated interface and have Micronaut generate the implementation at compile time. This is the direct equivalent of OpenFeign in Spring Cloud — declare an interface, get an implementation for free.

The low-level HttpClient is injected and used directly for dynamic or programmatic HTTP calls, similar to RestTemplate or WebClient.

In practice, the declarative client covers the vast majority of use cases and is the recommended starting point. It is type-safe, testable, integrates with service discovery, and requires no boilerplate.

One important difference from Spring: OpenFeign is a separate dependency (spring-cloud-openfeign). In Micronaut, @Client is part of the core framework — no additional Cloud dependency needed.

The underlying HTTP engine for both is Netty, which is non-blocking by nature. .toBlocking() wraps the underlying non-blocking operation so that the calling thread blocks and waits for the result — Netty itself continues to operate non-blocking underneath. Use it when you need synchronous semantics in a service method or a test, and understand that it ties up a thread for the duration of the call.

1. Project Setup and Dependencies

Add these dependencies to build.gradle for a synchronous HTTP client setup:

dependencies {
implementation("io.micronaut:micronaut-http-client")
implementation("io.micronaut.retry:micronaut-retry")
}

If you later want to return reactive types such as Mono<T> or Flux<T> from your client methods, add micronaut-reactor-http-client. It is not needed for ordinary synchronous clients.

2. The Declarative @Client: Micronaut's OpenFeign Equivalent

Defining a Basic Client Interface

import io.micronaut.http.annotation.*;
import io.micronaut.http.client.annotation.Client;

@Client("http://inventory-service")
public interface InventoryClient {

@Get("/inventory/{productId}")
InventoryStatus getInventory(Long productId);

@Get("/inventory")
List getAllInventory();

@Post("/inventory/reserve")
ReservationResult reserve(@Body ReservationRequest request);

@Put("/inventory/{productId}")
InventoryStatus update(Long productId, @Body UpdateInventoryRequest request);

@Delete("/inventory/{productId}/reservation/{reservationId}")
void cancelReservation(Long productId, Long reservationId);
}

Inject and use the client like any other bean:

@Singleton
public class OrderService {

private final InventoryClient inventoryClient;
private final OrderRepository orderRepository;

public OrderService(InventoryClient inventoryClient,
OrderRepository orderRepository) {
this.inventoryClient = inventoryClient;
this.orderRepository = orderRepository;
}

public Order placeOrder(CreateOrderRequest request) {
InventoryStatus inventory = inventoryClient
.getInventory(request.getProductId());

if (inventory.getAvailable() < request.getQuantity()) {
throw new InsufficientInventoryException(request.getProductId());
}

ReservationResult reservation = inventoryClient.reserve(
new ReservationRequest(request.getProductId(), request.getQuantity()));

return orderRepository.save(
new Order(request.getCustomerId(), reservation.getReservationId()));
}
}

Route Binding Annotations on Client Interfaces

All the same binding annotations from server-side controllers work on the client side.

@Client("http://product-service")
public interface ProductClient {

// Path variable — inferred from {id} in the route
@Get("/products/{id}")
Product findById(Long id);

// Query parameters
@Get("/products")
List findByCategory(
@QueryValue String category,
@QueryValue(defaultValue = "0") int page,
@QueryValue(defaultValue = "20") int size);

// Request body
@Post("/products")
Product create(@Body CreateProductRequest request);

// Per-request header
@Get("/products/{id}")
Product findByIdWithTenant(
Long id,
@Header("X-Tenant-ID") String tenantId);

// Full response with status and headers
@Get("/products/{id}")
HttpResponse findByIdFull(Long id);
}

One thing worth noting here: Micronaut uses the same annotations on both the server and client sides, so there is nothing new to learn if you have already written controllers. If you have used OpenFeign, the binding annotations will also look familiar — Feign borrows Spring MVC's @PathVariable, @RequestParam, @RequestBody, and @RequestHeader for its client interfaces. Spring itself has no native client-side binding annotations; that is a Feign convention. The return type HttpResponse<T> maps to Feign/Spring's ResponseEntity<T>.

Applying Headers to Every Request with Interface-Level @Header

You can apply headers to every method on a client interface using @Header at the interface level. This is useful for things like a consistent User-Agent or a static Accept version header:

@Client(id = "inventory-service")
@Header(name = "User-Agent", value = "order-service")
@Header(name = "Accept-Version", value = "v2")
public interface InventoryClient {
// All methods inherit these headers automatically
}

Externalising the Base URL with Configuration Properties

Rather than hardcoding a URL in @Client, you can reference a configuration property:

@Client("${inventory.service.url}")
public interface InventoryClient {
// ...
}


inventory:
service:
url: "http://inventory-service:8080"

The ${} syntax is Micronaut's standard property placeholder and supports a default value with a colon, so "${inventory.service.url:http://localhost:8080}" falls back to localhost if the property is not set. This makes it straightforward to point different environments at different hosts without touching code.

The named service approach (@Client(id = "inventory-service") with micronaut.http.services.inventory-service.* in YAML) is the more capable option — it lets you co-locate the URL, timeouts, pool size, and SSL configuration for a service in one block. The property placeholder approach is simpler and works well when the URL is the only thing that needs to vary across environments.

One important distinction: a URL pattern (@Client("http://inventory-service")) always calls that exact address and bypasses service discovery entirely. A service ID (@Client(id = "inventory-service")) enables dynamic URL resolution through Consul, Eureka, or Kubernetes — the actual address is looked up at call time. If you plan to use service discovery in production, use the id form from the start.

Accessing HTTP Status Codes and Response Headers

When you need the HTTP status, response headers, or want to inspect error responses without catching exceptions, return HttpResponse<T>:

HttpResponse response = productClient.findByIdFull(42L);

if (response.getStatus() == HttpStatus.OK) {
Product product = response.body();
} else if (response.getStatus() == HttpStatus.NOT_FOUND) {
// handle not found
}

String etag = response.header("ETag");

3. Configuring Micronaut HTTP Clients

Configuring Timeouts per HTTP Client in Micronaut

micronaut:
http:
services:
inventory-service:
url: "http://inventory-service:8080"
read-timeout: 5s
connect-timeout: 2s
pool:
enabled: true
max-connections: 50

product-service:
url: "http://product-service:8080"
read-timeout: 10s

Reference the configured service by its ID in @Client:

@Client(id = "inventory-service")
public interface InventoryClient {
// ...
}

This is cleaner than Spring's approach of setting timeouts on HttpComponentsClientHttpRequestFactory, and the values can be overridden per environment using environment variables.

Setting Global HTTP Client Timeout Defaults in Micronaut

micronaut:
http:
client:
read-timeout: 10s
connect-timeout: 3s
max-content-length: 10485760 # 10 MB
follow-redirects: true

4. HTTP Client Error Handling

Default behaviour: exceptions for non-2xx responses

By default, the declarative client throws HttpClientResponseException for any non-2xx response:

import io.micronaut.http.client.exceptions.HttpClientResponseException;

try {
Product product = productClient.findById(99L);
} catch (HttpClientResponseException e) {
if (e.getStatus() == HttpStatus.NOT_FOUND) {
// handle 404
} else if (e.getStatus() == HttpStatus.UNAUTHORIZED) {
// handle 401
} else {
throw e;
}
}

Handling 404 Responses with Optional<T>

A cleaner pattern for endpoints that may legitimately return 404:

@Client("http://product-service")
public interface ProductClient {

@Get("/products/{id}")
Optional findById(Long id);
}


Optional product = productClient.findById(42L);
product.ifPresent(p -> System.out.println(p.getName()));

It is worth noting that only 404 responses are silently converted to Optional.empty(). Any other 4xx or 5xx response still results in an HttpClientResponseException. This is not a generic error-handling mechanism — it specifically handles the not-found case.

Mapping Error Responses to Domain Exceptions

The try/catch approach works, but it means every call site has to remember to handle status codes consistently. A cleaner alternative is to centralise error translation in one place: a custom HttpClientResponseExceptionDecoder.

Every time the declarative client receives a non-2xx response, Micronaut hands the raw HttpResponse to the decoder and asks it to produce a Throwable. The default implementation always returns HttpClientResponseException. By replacing it with your own, you can inspect the response body — which downstream services typically populate with a structured error payload — and throw a meaningful domain exception instead. Your service code then catches ResourceNotFoundException or ValidationException rather than checking status codes everywhere.

@Replaces(HttpClientResponseExceptionDecoder.class) tells Micronaut to use this bean instead of the built-in one. defaultDecoder.decodeResponse(...) delegates to the default implementation for any status code you do not explicitly handle, preserving the standard behaviour for those cases. Note that DefaultHttpClientResponseExceptionDecoder is package-private in Micronaut, so you implement the HttpClientResponseExceptionDecoder interface directly and hold the default decoder as a delegate rather than extending it.

import io.micronaut.http.client.exceptions.HttpClientResponseExceptionDecoder;
import io.micronaut.http.client.DefaultHttpClientResponseExceptionDecoder;

@Singleton
@Replaces(HttpClientResponseExceptionDecoder.class)
public class DomainExceptionDecoder implements HttpClientResponseExceptionDecoder {

// Hold the default decoder as a delegate — DefaultHttpClientResponseExceptionDecoder
// is package-private so it cannot be extended directly
private final HttpClientResponseExceptionDecoder defaultDecoder =
new DefaultHttpClientResponseExceptionDecoder();

@Override
public Throwable decodeResponse(HttpRequest request,
HttpResponse response,
Argument errorType) {

// Attempt to deserialise the error body into your error DTO.
// Downstream services typically return JSON like:
// { "code": "PRODUCT_NOT_FOUND", "message": "No product with id 42" }
Optional body = response.getBody(ErrorResponse.class);

if (body.isPresent()) {
String code = body.get().getCode();

// Map status codes to domain exceptions using the parsed body
return switch (response.getStatus().getCode()) {
case 404 -> new ResourceNotFoundException(
"Resource not found: " + code);
case 409 -> new ConflictException(
"Conflict: " + body.get().getMessage());
case 422 -> new ValidationException(
"Validation error: " + body.get().getMessage());
// Anything else falls through to the default HttpClientResponseException
default -> defaultDecoder.decodeResponse(request, response, errorType);
};
}

// Body could not be parsed — fall back to default behaviour
return defaultDecoder.decodeResponse(request, response, errorType);
}
}

5. Retry with @Retryable

Micronaut's @Retryable annotation retries a method automatically on failure. Apply it to the service method that makes the client call, not to the client interface itself. If you place @Retryable on the client interface, the retry happens inside the filter chain before error decoding runs — this means your custom error decoder may not see the final exception, leading to unexpected error mapping behaviour.

import io.micronaut.retry.annotation.Retryable;

@Singleton
public class InventoryService {

private final InventoryClient inventoryClient;

public InventoryService(InventoryClient inventoryClient) {
this.inventoryClient = inventoryClient;
}

@Retryable(
attempts = "3",
delay = "500ms",
multiplier = "2.0",
includes = HttpClientResponseException.class
)
public InventoryStatus getInventory(Long productId) {
return inventoryClient.getInventory(productId);
}
}

delay is the initial wait before the first retry. multiplier is a backoff factor applied after each failed attempt — each subsequent wait is the previous one multiplied by this value. With delay = "500ms" and multiplier = "2.0", the first retry waits 500ms, and the second waits 1000ms (500 × 2.0). This exponential backoff gives a struggling downstream service progressively more breathing room to recover rather than hammering it with retries at a fixed rate.

Adding Jitter to @Retryable in Micronaut

One drawback of pure exponential backoff is that multiple instances of the same service will often fail and retry in lockstep after a shared outage, potentially overwhelming the downstream service again just as it recovers. Adding jitter — a small random offset to each wait — desynchronises retries across instances so the load is spread out.

@Retryable does not have a built-in jitter parameter, so you implement it by catching the exception, sleeping for a random duration, then re-throwing so @Retryable applies its own backoff on top. The two delays stack, meaning each instance ends up waiting a different total amount. Note that Thread.sleep() is safe here because this article covers synchronous blocking clients — if you are using the reactive client with Reactor, blocking the calling thread this way would stall the Netty event loop. In a reactive context, use Reactor's Retry.backoff(...).jitter(0.2) instead.

@Retryable(
attempts = "3",
delay = "500ms",
multiplier = "2.0",
includes = HttpClientResponseException.class
)
public InventoryStatus getInventory(Long productId) {
try {
return inventoryClient.getInventory(productId);
} catch (HttpClientResponseException e) {
// Add up to 200ms of random jitter before @Retryable applies its backoff delay.
// This desynchronises retries across multiple instances hitting the same service.
long jitter = ThreadLocalRandom.current().nextLong(200);
try {
Thread.sleep(jitter);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
throw e;
}
}

Retrying Only on Specific HTTP Status Codes

Not every failure is worth retrying. A 503 Service Unavailable or 429 Too Many Requests suggests a transient problem on the downstream side — retrying makes sense. A 400 Bad Request or 404 Not Found means the request itself is wrong, and retrying it will never produce a different result. The pattern below re-throws the exception only for status codes worth retrying, and wraps everything else in a NonRetryableException that @Retryable does not include, so those failures propagate immediately without burning through retry attempts.

@Retryable(
attempts = "3",
delay = "200ms",
includes = HttpClientResponseException.class
)
public InventoryStatus getInventoryWithRetry(Long productId) {
try {
return inventoryClient.getInventory(productId);
} catch (HttpClientResponseException e) {
// Only retry on 503 or 429
if (e.getStatus() == HttpStatus.SERVICE_UNAVAILABLE
|| e.getStatus().getCode() == 429) {
throw e;
}
// Do not retry on 4xx client errors
throw new NonRetryableException(e);
}
}

Configuring @Retryable with application.yml

Externalise retry settings so they can be tuned per environment:

inventory:
retry:
attempts: "${INVENTORY_RETRY_ATTEMPTS:3}"
delay: "${INVENTORY_RETRY_DELAY:500ms}"
multiplier: "${INVENTORY_RETRY_MULTIPLIER:2.0}"


@Retryable(
attempts = "${inventory.retry.attempts}",
delay = "${inventory.retry.delay}",
multiplier = "${inventory.retry.multiplier}"
)
public InventoryStatus getInventory(Long productId) {
return inventoryClient.getInventory(productId);
}

6. Circuit Breaker with @CircuitBreaker

The circuit breaker pattern prevents cascading failures. If a downstream service is consistently failing, the circuit opens and subsequent calls fail immediately rather than queuing threads waiting for timeouts.

import io.micronaut.retry.annotation.CircuitBreaker;

@Singleton
public class PaymentService {

private final PaymentGatewayClient paymentClient;

public PaymentService(PaymentGatewayClient paymentClient) {
this.paymentClient = paymentClient;
}

@CircuitBreaker(
attempts = "3",
delay = "500ms",
reset = "30s"
)
public PaymentResult processPayment(PaymentRequest request) {
return paymentClient.process(request);
}
}

The circuit breaker tracks failures over repeated invocations. After enough failures, the circuit opens and subsequent calls fail immediately without touching the network. After the reset period expires, the circuit moves to HALF-OPEN — not directly to CLOSED. In HALF-OPEN state, one trial call is allowed through. Only if that call succeeds does the circuit close back to normal operation; if it fails, the circuit opens again and the reset timer restarts.

The circuit has three states: CLOSED means normal operation, all calls go through. OPEN means the circuit has tripped and calls fail immediately with CircuitOpenException. HALF-OPEN means the reset window has elapsed and a single trial call is allowed through to test if the downstream service has recovered.

Micronaut @CircuitBreaker Fallback with @Fallback

Rather than letting a CircuitOpenException propagate to the caller, you can provide a fallback — a safe default behaviour that runs automatically whenever the circuit is open. Micronaut links the fallback to the circuit breaker through method signature matching: it scans the application context for a bean annotated with @Fallback that has a method with the same name and parameter types as the @CircuitBreaker method. When the circuit opens, Micronaut calls the fallback implementation instead of the real one, transparently, with no changes needed at the call site.

In this example, PaymentServiceFallback.processPayment(PaymentRequest) matches PaymentService.processPayment(PaymentRequest) by name and signature. Micronaut wires them together automatically at startup — there is no explicit registration required.

import io.micronaut.retry.annotation.Fallback;

@Singleton
public class PaymentServiceFallback {

@Fallback
public PaymentResult processPayment(PaymentRequest request) {
// Called automatically when PaymentService.processPayment() has its circuit open.
// Same method name and parameter types is all Micronaut needs to link them.
log.warn("Payment gateway unavailable, queuing payment: {}",
request.getOrderId());
paymentQueueService.enqueue(request);
return PaymentResult.queued(request.getOrderId());
}
}

7. Intercepting Outbound HTTP Requests with Micronaut Client Filters

Filters on the HTTP client let you add authentication headers, correlation IDs, logging, and metrics without changing every client interface. In production systems, service-to-service authentication is almost always implemented through filters rather than passing tokens manually in each client method — it keeps the interfaces clean and the policy in one place.

Adding a Bearer Token to Every Outbound Request

@Filter(serviceId = "inventory-service") scopes the filter to a single named client. To apply the same filter to multiple named services, pass an array of service IDs. If you want to target clients by URL pattern rather than by name, use the patterns attribute instead — useful when the services share a URL structure but are not all registered as named services. @Filter("/**") is the catch-all that applies to every outbound request across all clients.

// Single service
@Filter(serviceId = "inventory-service")

// Multiple named services
@Filter(serviceId = {"inventory-service", "product-service"})

// All requests matching a URL pattern
@Filter(patterns = "/api/**")

// All outbound requests across all clients
@Filter("/**")


import io.micronaut.http.filter.HttpClientFilter;

@Filter(serviceId = "inventory-service")
public class ServiceAuthFilter implements HttpClientFilter {

private final TokenProvider tokenProvider;

public ServiceAuthFilter(TokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}

@Override
public Publisher> doFilter(
MutableHttpRequest request,
ClientFilterChain chain) {

return Mono.fromCallable(tokenProvider::getServiceToken)
.flatMap(token -> Mono.from(
chain.proceed(request.bearerAuth(token))));
}
}

Propagating Correlation IDs Across Service Calls

@Filter("/**")
public class CorrelationIdFilter implements HttpClientFilter {

@Override
public Publisher> doFilter(
MutableHttpRequest request,
ClientFilterChain chain) {

String correlationId = MDC.get("correlationId");

if (correlationId != null) {
request.header("X-Correlation-Id", correlationId);
}

return chain.proceed(request);
}
}

Logging All Outbound HTTP Requests

@Filter("/**")
@Order(Ordered.HIGHEST_PRECEDENCE)
public class ClientLoggingFilter implements HttpClientFilter {

private static final Logger log = LoggerFactory.getLogger(ClientLoggingFilter.class);

@Override
public Publisher> doFilter(
MutableHttpRequest request,
ClientFilterChain chain) {

long start = System.currentTimeMillis();

return Mono.from(chain.proceed(request))
.doOnNext(response -> log.info(
"Outbound: {} {} → {} ({}ms)",
request.getMethod(),
request.getUri(),
response.getStatus().getCode(),
System.currentTimeMillis() - start))
.doOnError(e -> log.error(
"Outbound failed: {} {} → {}",
request.getMethod(),
request.getUri(),
e.getMessage()));
}
}

8. Micronaut HttpClient: Dynamic URLs, Custom Requests, and Multipart Uploads

The declarative @Client covers the majority of service-to-service calls, but there are situations where you need more direct control: the target URL is only known at runtime, you need to construct requests dynamically based on runtime conditions, or you are working with multipart form data. For these cases, Micronaut provides HttpClient — the lower-level API that the declarative client is built on top of.

The two key methods to know are retrieve() and exchange(). retrieve() deserialises the response body directly into your target type and throws HttpClientResponseException for any non-2xx status — use it when you only care about the body and want errors to propagate as exceptions. exchange() returns the full HttpResponse<T>, giving you access to status codes, headers, and the body — use it when you need to inspect the response beyond just its body, or when you want to handle non-2xx responses without catching exceptions.

Both are called via .toBlocking(), which wraps the underlying non-blocking Netty call in a synchronous, thread-blocking operation. This is the same pattern the declarative client uses internally.

Dynamic vs fixed base URL

The approach differs depending on whether the base URL is known at startup or only at call time.

When you need a truly dynamic base URL — for example, a URL passed in at runtime from a database or configuration service — create the HttpClient instance from the URL at call time and close it afterwards with try-with-resources:

@Singleton
public class DynamicServiceClient {

public Product getProduct(String baseUrl, Long id) {
// Client is created per-call and closed immediately after — suitable for
// truly dynamic URLs that are not known until runtime
try (HttpClient client = HttpClient.create(URI.create(baseUrl))) {
return client.toBlocking()
.retrieve(
HttpRequest.GET("/products/" + id),
Product.class);
}
}
}

For a fixed base URL, inject a managed HttpClient instance the same way you would any other bean. Micronaut manages its lifecycle, connection pool, and configuration, so you get the same per-client YAML configuration support as the declarative client:

@Singleton
public class ProductServiceClient {

private final HttpClient httpClient;

public ProductServiceClient(
@Client("http://product-service") HttpClient httpClient) {
this.httpClient = httpClient;
}

// retrieve() — deserialises the body, throws on non-2xx
public Product createProduct(CreateProductRequest request) {
HttpRequest req =
HttpRequest.POST("/products", request)
.header("X-Custom-Header", "value")
.contentType(MediaType.APPLICATION_JSON);

return httpClient.toBlocking().retrieve(req, Product.class);
}

// exchange() — returns the full HttpResponse with status, headers, and body
public HttpResponse createProductWithResponse(
CreateProductRequest request) {

return httpClient.toBlocking()
.exchange(
HttpRequest.POST("/products", request),
Product.class);
}
}

Multipart File Uploads

Micronaut handles multipart uploads through MultipartBody, which works with the declarative client using @Body MultipartBody. MultipartBody.builder() constructs the payload part by part — each addPart() call maps to one form field, with binary parts requiring an explicit media type.

@Client("http://upload-service")
public interface FileUploadClient {

@Post(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA)
UploadResult upload(@Body MultipartBody body);
}


MultipartBody body = MultipartBody.builder()
.addPart("file", "product-image.jpg",
MediaType.IMAGE_JPEG_TYPE, fileBytes) // binary part with media type
.addPart("productId", "42") // plain text part
.build();

UploadResult result = fileUploadClient.upload(body);

9. Testing Micronaut HTTP Clients

Testing client integrations requires a fake HTTP server that receives requests and returns controlled responses. WireMock is the standard tool for this and works the same way it does in Spring projects.

Test Dependencies

testImplementation("org.wiremock:wiremock-standalone:3.3.1")
testImplementation("io.micronaut.test:micronaut-test-junit5")

Testing Declarative Clients with WireMock

WireMock starts on a random port and the client is pointed at it by setting the service URL as a system property before the application context starts. Each test resets WireMock state in @BeforeEach so stubs from one test cannot bleed into another.

@MicronautTest
class InventoryClientTest {

static WireMockServer wireMock;

@BeforeAll
static void startWireMock() {
wireMock = new WireMockServer(
WireMockConfiguration.options().dynamicPort());
wireMock.start();
System.setProperty("micronaut.http.services.inventory-service.url",
wireMock.baseUrl());
}

@AfterAll
static void stopWireMock() {
wireMock.stop();
}

@BeforeEach
void resetWireMock() {
wireMock.resetAll();
}

@Inject
InventoryClient inventoryClient;

// Verifies the client deserialises the response body correctly and hits the right URL.
// wireMock.verify() confirms the request was actually made — not just that no exception was thrown.
@Test
void getInventoryReturnsStockLevel() {
wireMock.stubFor(get(urlEqualTo("/inventory/42"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{
"productId": 42,
"available": 100,
"reserved": 5
}
""")));

InventoryStatus status = inventoryClient.getInventory(42L);

assertThat(status.getProductId()).isEqualTo(42L);
assertThat(status.getAvailable()).isEqualTo(100);

wireMock.verify(getRequestedFor(urlEqualTo("/inventory/42")));
}

// Confirms that a 404 from the downstream service maps to Optional.empty()
// rather than throwing an exception, as described in the error handling section.
@Test
void getInventoryHandles404WithOptional() {
wireMock.stubFor(get(urlEqualTo("/inventory/99"))
.willReturn(aResponse().withStatus(404)));

Optional result = inventoryClient.findById(99L);

assertThat(result).isEmpty();
}

// Uses WireMock JSONPath matchers to assert the client serialises the request body correctly.
// The stub only matches if both productId and quantity are present with the expected values.
@Test
void reserveInventorySendsCorrectBody() {
wireMock.stubFor(post(urlEqualTo("/inventory/reserve"))
.withRequestBody(matchingJsonPath("$.productId", equalTo("42")))
.withRequestBody(matchingJsonPath("$.quantity", equalTo("3")))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{
"reservationId": "RES-001",
"status": "CONFIRMED"
}
""")));

ReservationResult result = inventoryClient.reserve(
new ReservationRequest(42L, 3));

assertThat(result.getReservationId()).isEqualTo("RES-001");
}

// Confirms the default error handling behaviour — any 5xx response throws
// HttpClientResponseException rather than returning a result or silently failing.
@Test
void throwsHttpClientResponseExceptionOn5xx() {
wireMock.stubFor(get(urlEqualTo("/inventory/1"))
.willReturn(aResponse().withStatus(503)
.withBody("{\"message\": \"Service unavailable\"}")));

assertThrows(HttpClientResponseException.class,
() -> inventoryClient.getInventory(1L));
}
}

Testing @Retryable Retry Behaviour

WireMock's scenario API lets you simulate failure sequences:

// WireMock scenarios model stateful behaviour — each stub fires once and advances
// the scenario to the next state, simulating two failures followed by a recovery.
// wireMock.verify(3, ...) confirms all three attempts actually reached the server.
@Test
void retriesOnFailureAndEventuallySucceeds() {
wireMock.stubFor(get(urlEqualTo("/inventory/42"))
.inScenario("retry-scenario")
.whenScenarioStateIs("Started")
.willReturn(aResponse().withStatus(503))
.willSetStateTo("first-failure"));

wireMock.stubFor(get(urlEqualTo("/inventory/42"))
.inScenario("retry-scenario")
.whenScenarioStateIs("first-failure")
.willReturn(aResponse().withStatus(503))
.willSetStateTo("second-failure"));

wireMock.stubFor(get(urlEqualTo("/inventory/42"))
.inScenario("retry-scenario")
.whenScenarioStateIs("second-failure")
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{ "productId": 42, "available": 10 }
""")));

InventoryStatus status = inventoryService.getInventory(42L);

assertThat(status.getAvailable()).isEqualTo(10);
wireMock.verify(3, getRequestedFor(urlEqualTo("/inventory/42")));
}

// Confirms that once all retry attempts are exhausted the exception propagates to the caller.
// wireMock.verify(3, ...) ensures @Retryable made all three attempts before giving up.
@Test
void exhaustedRetriesThrowException() {
wireMock.stubFor(get(urlEqualTo("/inventory/99"))
.willReturn(aResponse().withStatus(503)));

assertThrows(HttpClientResponseException.class,
() -> inventoryService.getInventory(99L));

wireMock.verify(3, getRequestedFor(urlEqualTo("/inventory/99")));
}

Testing @CircuitBreaker Behaviour

The key assertion in circuit breaker tests is that once the circuit opens, subsequent calls fail with CircuitOpenException without hitting the network at all. wireMock.verify(3, ...) after four call attempts proves that the fourth never reached WireMock.

@Test
void circuitOpensAfterConsecutiveFailures() {
wireMock.stubFor(post(urlEqualTo("/payments"))
.willReturn(aResponse().withStatus(503)));

PaymentRequest request = new PaymentRequest(1L, new BigDecimal("49.99"));

// First calls fail and hit WireMock, tripping the circuit
assertThrows(Exception.class, () -> paymentService.processPayment(request));
assertThrows(Exception.class, () -> paymentService.processPayment(request));
assertThrows(Exception.class, () -> paymentService.processPayment(request));

// Circuit is now OPEN — subsequent calls fail immediately without hitting the network
assertThrows(CircuitOpenException.class,
() -> paymentService.processPayment(request));

wireMock.verify(3, postRequestedFor(urlEqualTo("/payments")));
}

Mocking the HTTP Client in Unit Tests

When testing service logic rather than the HTTP layer, mock the client interface directly. This is identical to how you would mock a Feign client in Spring:

@MicronautTest
class OrderServiceTest {

@MockBean(InventoryClient.class)
InventoryClient mockInventoryClient() {
return Mockito.mock(InventoryClient.class);
}

@MockBean(OrderRepository.class)
OrderRepository mockOrderRepository() {
return Mockito.mock(OrderRepository.class);
}

@Inject
InventoryClient inventoryClient;

@Inject
OrderService orderService;

// Tests the service orchestration logic in isolation — no real HTTP calls are made.
// argThat() verifies the client was called with a correctly constructed request object,
// not just that it was called at all.
@Test
void placeOrderReservesInventoryAndSavesOrder() {
when(inventoryClient.getInventory(42L))
.thenReturn(new InventoryStatus(42L, 100, 0));
when(inventoryClient.reserve(any()))
.thenReturn(new ReservationResult("RES-001", "CONFIRMED"));
when(orderRepository.save(any()))
.thenReturn(new Order(1L, "CUST-1", "RES-001"));

Order order = orderService.placeOrder(
new CreateOrderRequest("CUST-1", 42L, 2));

assertThat(order.getId()).isEqualTo(1L);
verify(inventoryClient).getInventory(42L);
verify(inventoryClient).reserve(
argThat(req -> req.getProductId().equals(42L)
&& req.getQuantity() == 2));
}

// Verifies the guard condition — if available stock is less than requested quantity,
// the service throws before ever calling reserve() or save(), confirmed by verifyNoMoreInteractions().
@Test
void placeOrderThrowsWhenInventoryInsufficient() {
when(inventoryClient.getInventory(42L))
.thenReturn(new InventoryStatus(42L, 1, 0));

assertThrows(InsufficientInventoryException.class,
() -> orderService.placeOrder(
new CreateOrderRequest("CUST-1", 42L, 5)));

verifyNoMoreInteractions(orderRepository);
}
}

Testing HTTP Client Filters

Filter tests use WireMock to inspect the headers on the outbound request rather than the response. The MDC is set before the call and cleared in a finally block to avoid state leaking between tests.

@Test
void correlationIdIsPropagatedToDownstreamCall() {
MDC.put("correlationId", "test-correlation-123");

try {
inventoryClient.getInventory(1L);
} finally {
MDC.clear();
}

wireMock.verify(getRequestedFor(urlEqualTo("/inventory/1"))
.withHeader("X-Correlation-Id", equalTo("test-correlation-123")));
}

// The negative case — confirms the filter does not add the header when no
// correlation ID is present in the MDC, rather than sending an empty or null value.
@Test
void missingCorrelationIdDoesNotAddHeader() {
MDC.clear();

inventoryClient.getInventory(1L);

wireMock.verify(getRequestedFor(urlEqualTo("/inventory/1"))
.withoutHeader("X-Correlation-Id"));
}

10. Spring to Micronaut Migration Reference

For developers switching from Spring, here is how the key concepts map across.

Declarative client: OpenFeign @FeignClient → Micronaut @Client. The base URL goes in the annotation value, or use id to reference a named service from application.yml.

Binding annotations: @PathVariable → path variables are implicit from {name} in the route. @RequestParam → @QueryValue. @RequestBody → @Body. @RequestHeader → @Header.

Response handling: ResponseEntity<T> → HttpResponse<T>. Returning Optional<T> gives you a clean 404-handling pattern that Spring Feign requires a custom error handler to replicate.

Resilience: Spring Retry @Retryable maps almost directly to Micronaut's @Retryable. Resilience4j @CircuitBreaker maps to Micronaut's built-in @CircuitBreaker with a @Fallback bean. There is no separate library to wire in — both are part of micronaut-retry.

Interceptors: Feign's RequestInterceptor maps to Micronaut's HttpClientFilter. The filter scoping is more explicit — you can target a specific service with @Filter(serviceId = "...") or apply globally with @Filter("/**").

Testing: WireMock and @MockBean with Mockito work the same way in both ecosystems.

N

About N Sharma

Lead Architect at StackAndSystem

N Sharma is a technologist with over 28 years of experience in software engineering, system architecture, and technology consulting. He holds a Bachelor’s degree in Engineering, a DBF, and an MBA. His work focuses on research-driven technology education—explaining software architecture, system design, and development practices through structured tutorials designed to help engineers build reliable, scalable systems.

Disclaimer

This article is for educational purposes only. Assistance from AI-powered generative tools was taken to format and improve language flow. While we strive for accuracy, this content may contain errors or omissions and should be independently verified.

Micronaut HTTP Clients Explained for Spring Boot Developers