Learning Paths
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.

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:
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
Inject and use the client like any other bean:
Route Binding Annotations on Client Interfaces
All the same binding annotations from server-side controllers work on the client side.
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:
Externalising the Base URL with Configuration Properties
Rather than hardcoding a URL in @Client, you can reference a configuration property:
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>:
3. Configuring Micronaut HTTP Clients
Configuring Timeouts per HTTP Client in Micronaut
Reference the configured service by its ID in @Client:
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
4. HTTP Client Error Handling
Default behaviour: exceptions for non-2xx responses
By default, the declarative client throws HttpClientResponseException for any non-2xx response:
Handling 404 Responses with Optional<T>
A cleaner pattern for endpoints that may legitimately return 404:
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.
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.
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.
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.
Configuring @Retryable with application.yml
Externalise retry settings so they can be tuned per environment:
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.
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.
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.
Propagating Correlation IDs Across Service Calls
Logging All Outbound HTTP Requests
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:
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:
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.
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
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.
Testing @Retryable Retry Behaviour
WireMock's scenario API lets you simulate failure sequences:
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.
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:
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.
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.
About N Sharma
Lead Architect at StackAndSystemN 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.
