Handle Errors in Reactive Microservices with Spring WebFlux and Reactor
Reactive error handling is based on error signals rather than exceptions escaping from an asynchronous method call. In Project Reactor, a Mono or Flux emits values and then completes, or it terminates with an error. Once an error terminates a sequence, an error-handling operator can transform that signal, replace the failed sequence, retry the operation, record diagnostic information, or return an HTTP response.
This tutorial uses Kotlin, Spring WebFlux functional handlers, and Reactor. It shows how to handle decoding failures with onErrorResume, publish domain failures with Mono.error, map downstream WebClient responses, apply timeouts and controlled retries, and keep client-facing errors separate from internal diagnostics.
The original examples use APIs and Reactor Kotlin extensions from an earlier Spring generation. Import names and convenience functions may differ in a current project, but the underlying error-signal behavior remains applicable.
How Reactor Error Signals Flow Through a Microservice
An exception thrown while Reactor is executing an operator is converted into an onError signal. The signal travels downstream until an error-handling operator processes it. If no operator handles it, the subscriber or WebFlux infrastructure receives the failure.
- An HTTP request enters a WebFlux handler or controller.
- The handler creates a reactive chain without blocking the request thread.
- Request decoding, validation, business logic, or a downstream service may produce an error signal.
- A local operator can recover from or translate a failure when the required business context is available.
- A centralized WebFlux error handler can convert otherwise unhandled failures into a consistent HTTP response.
- Internal logs and traces retain diagnostic details while the client receives a safe error body.
Reactive methods should return the publisher that contains the error-handling operators. Wrapping only the publisher assembly in a conventional try/catch does not catch failures emitted later, after subscription.
Handle WebFlux Handler Errors with onErrorResume
When you create handlers, request decoding or service processing may fail. The onErrorResume operator receives a matching error and switches to a fallback publisher. In a functional WebFlux handler, that fallback can be a Mono<ServerResponse>.
package com.microservices.chapter4
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.BodyInserters.fromObject
import org.springframework.web.reactive.function.server.ServerRequest
import org.springframework.web.reactive.function.server.ServerResponse.*
import org.springframework.web.reactive.function.server.bodyToMono
import reactor.core.publisher.onErrorResume
import java.net.URI
@Component
class CustomerHandler(val customerService: CustomerService) {
fun get(serverRequest: ServerRequest) =
customerService.getCustomer(serverRequest.pathVariable("id").toInt())
.flatMap { ok().body(fromObject(it)) }
.switchIfEmpty(status(HttpStatus.NOT_FOUND).build())
fun search(serverRequest: ServerRequest) =
ok().body(customerService.searchCustomers(serverRequest.queryParam("nameFilter")
.orElse("")), Customer::class.java)
fun create(serverRequest: ServerRequest) =
customerService.createCustomer(serverRequest.bodyToMono()).flatMap {
created(URI.create("/functional/customer/${it.id}")).build()
}.onErrorResume(Exception::class) {
badRequest().body(fromObject("error"))
}
}
Here, onErrorResume replaces a failed publisher with a response publisher that returns 400 Bad Request. Catching Exception is useful for demonstrating the operator, but production code should normally match specific exception types. Otherwise, programming defects, unavailable dependencies, and invalid client input can all be reported incorrectly as the same 400 response.
Test the WebFlux decoding failure with curl
The following request contains valid customer fields followed by invalid JSON text. It therefore demonstrates a request-body decoding failure:
curl -X POST \
http://localhost:8080/functional/customer/ \
-H 'content-type: application/json' \
-d '{
"id": 18,
"name": "New Customer",
"telephone": {
"countryCode": "+44",
"telephoneNumber": "7123456789"
}
}
bad json'
The handler returns a 400 Bad Request response containing:
error
Return a structured error response from the reactive handler
A structured response is easier for API clients to parse than plain text. The following ErrorResponse class provides an error label and message:
package com.microservices.chapter4
data class ErrorResponse(val error: String, val message: String)
The handler can then construct an ErrorResponse inside onErrorResume:
package com.microservices.chapter4
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.BodyInserters.fromObject
import org.springframework.web.reactive.function.server.ServerRequest
import org.springframework.web.reactive.function.server.ServerResponse.*
import org.springframework.web.reactive.function.server.bodyToMono
import reactor.core.publisher.onErrorResume
import java.net.URI
@Component
class CustomerHandler(val customerService: CustomerService) {
fun get(serverRequest: ServerRequest) =
customerService.getCustomer(serverRequest.pathVariable("id").toInt())
.flatMap { ok().body(fromObject(it)) }
.switchIfEmpty(status(HttpStatus.NOT_FOUND).build())
fun search(serverRequest: ServerRequest) =
ok().body(customerService.searchCustomers(serverRequest.queryParam("nameFilter")
.orElse("")), Customer::class.java)
fun create(serverRequest: ServerRequest) =
customerService.createCustomer(serverRequest.bodyToMono()).flatMap {
created(URI.create("/functional/customer/${it.id}")).build()
}.onErrorResume(Exception::class) {
badRequest().body(fromObject(ErrorResponse("error creating customer",
it.message ?: "error")))
}
}
Repeating the malformed request produces a response similar to this:
{
"error": "error creating customer",
"message": "JSON decoding error: Unexpected character ('b' (code 98)): expected a valid value (number, String, array, object, 'true', 'false' or 'null'); nested exception is com.fasterxml.jackson.core.JsonParseException: Unexpected character ('b' (code 98)): expected a valid value (number, String, array, object, 'true', 'false' or 'null')\n at [Source: UNKNOWN; line: 9, column: 2]"
}
This detailed parser message is useful during local development, but an external API should not normally expose exception class names, stack details, source locations, SQL text, hostnames, or downstream response bodies. Log the full exception internally and return a stable error code with a concise client-safe message.
Publish Domain Errors with Mono.error
A reactive service can publish a domain failure when a business rule cannot be satisfied. For example, attempting to create an existing customer can produce a specific CustomerExistException rather than returning an empty publisher or throwing an unrelated generic exception.
First, define the domain exception:
package com.microservices.chapter4
class CustomerExistException(override val message: String) : Exception(message)
Next, modify the createCustomer method so that it emits the exception when the supplied identifier is already present:
package com.microservices.chapter4
import com.microservices.chapter4.Customer.Telephone
import org.springframework.stereotype.Component
import reactor.core.publisher.Mono
import reactor.core.publisher.toFlux
import reactor.core.publisher.toMono
import java.util.concurrent.ConcurrentHashMap
@Component
class CustomerServiceImpl : CustomerService {
companion object {
val initialCustomers = arrayOf(Customer(1, "Kotlin"),
Customer(2, "Spring"),
Customer(3, "Microservice", Telephone("+44", "7123456789")))
}
val customers = ConcurrentHashMap<Int, Customer>(initialCustomers.associateBy(Customer::id))
override fun getCustomer(id: Int) = customers[id]?.toMono() ?: Mono.empty()
override fun searchCustomers(nameFilter: String) = customers.filter {
it.value.name.contains(nameFilter, true)
}.map(Map.Entry<Int, Customer>::value).toFlux()
override fun createCustomer(customerMono: Mono<Customer>) =
customerMono.flatMap {
if (customers[it.id] == null) {
customers[it.id] = it
it.toMono()
} else {
Mono.error(CustomerExistException("Customer ${it.id} already
exist"))
}
}
}
If the customer does not exist, the service stores it and emits the created customer. If the identifier already exists, Mono.error creates a failed publisher containing CustomerExistException. The handler’s error operator then converts that signal into an HTTP response.
Test the duplicate-customer domain error
Send this request twice using the same customer identifier:
curl -X POST \
http://localhost:8080/functional/customer/ \
-H 'content-type: application/json' \
-d '{
"id": 18,
"name": "New Customer",
"telephone": {
"countryCode": "+44",
"telephoneNumber": "7123456789"
}
}
'
The first request creates the customer. The second request reaches the duplicate check and returns a 400 Bad Request response:
{
"error": "error creating customer",
"message": "Customer 18 already exists"
}
For an HTTP API, a duplicate resource may be better represented by 409 Conflict than 400 Bad Request. The appropriate status depends on the API contract. Mapping a specific domain exception separately makes that decision explicit.
Choose the Correct Reactor Error-Handling Operator
| Reactor operator | Use in a reactive microservice | Result |
|---|---|---|
onErrorResume | Run an alternate reactive path based on the exception. | Switches to a fallback publisher. |
onErrorReturn | Return a fixed fallback value when doing so is semantically valid. | Replaces the error with one value. |
onErrorMap | Translate a low-level failure into a domain or application exception. | Preserves failure while changing its type or context. |
doOnError | Record a metric or log without recovering. | Performs a side effect and lets the error continue. |
retryWhen | Retry a transient, idempotent operation under a bounded policy. | Resubscribes to the source. |
timeout | Stop waiting beyond the service’s latency budget. | Emits a timeout error or switches to a configured fallback. |
onErrorComplete | Convert a narrowly defined error into completion when absence is valid. | Suppresses the matching error and completes. |
doOnError observes an error but does not handle it. Likewise, onErrorMap translates the failure but keeps the sequence in an error state. Use onErrorResume when recovery requires another asynchronous operation or a reactive HTTP response.
Map only known exceptions in a WebFlux handler
Typed recovery keeps validation, domain conflicts, and unexpected failures from being collapsed into one response. This simplified Kotlin example handles a duplicate customer separately and leaves unrelated exceptions for centralized handling:
fun create(request: ServerRequest): Mono<ServerResponse> =
request.bodyToMono<Customer>()
.flatMap(customerService::createCustomer)
.flatMap { customer ->
ServerResponse.created(URI.create("/customers/${customer.id}"))
.bodyValue(customer)
}
.onErrorResume(CustomerExistException::class.java) { error ->
ServerResponse.status(HttpStatus.CONFLICT)
.bodyValue(
ApiError(
code = "CUSTOMER_ALREADY_EXISTS",
message = error.message ?: "Customer already exists"
)
)
}
A malformed request body can be mapped separately to 400 Bad Request. An unavailable downstream service normally requires a 5xx response or a documented fallback. An unknown programming error should remain visible to monitoring and should not be mislabeled as a client mistake.
Handle WebClient Errors from Downstream Microservices
A reactive microservice must interpret both transport failures and HTTP error responses from downstream services. With WebClient, retrieve() treats 4xx and 5xx responses as errors by default. The onStatus method can read a controlled portion of the response and convert it into an application-specific exception.
public Mono<Inventory> getInventory(String productId) {
return webClient.get()
.uri("/inventory/{productId}", productId)
.retrieve()
.onStatus(
status -> status.value() == 404,
response -> Mono.error(
new InventoryNotFoundException(productId)
)
)
.onStatus(
HttpStatusCode::is5xxServerError,
response -> Mono.error(
new InventoryServiceException("Inventory service failed")
)
)
.bodyToMono(Inventory.class);
}
Do not automatically forward every downstream status, header, or error body to the original caller. Translate the failure according to the current service’s API contract. This prevents internal service names and implementation details from becoming part of a public interface.
Distinguish downstream HTTP failures from connection failures
- Downstream 4xx response: Determine whether it represents client input, missing downstream data, authorization, or an internal contract mismatch.
- Downstream 5xx response: Treat it as a dependency failure unless a documented fallback can produce a correct response.
- Connection refusal or DNS failure: Record the dependency and operation, then apply the service’s availability policy.
- Timeout: Stop waiting within the caller’s latency budget and return a controlled failure or valid fallback.
- Malformed downstream response: Treat it as an integration failure rather than blaming the original client.
Apply Reactive Timeouts, Retries, and Fallbacks Safely
Retries are appropriate only for failures that are likely to be transient. They also need a strict attempt limit and backoff. Retrying validation errors, authorization failures, duplicate requests, or deterministic business-rule failures adds load without changing the result.
public Mono<Inventory> loadInventory(String productId) {
return inventoryClient.getInventory(productId)
.timeout(Duration.ofSeconds(2))
.retryWhen(
Retry.backoff(2, Duration.ofMillis(200))
.filter(this::isTransientDependencyFailure)
)
.onErrorMap(
TimeoutException.class,
error -> new InventoryServiceException(
"Inventory lookup timed out",
error
)
);
}
The example permits an initial attempt plus at most two retries for failures accepted by isTransientDependencyFailure. A retry resubscribes to the source, so the underlying operation must be safe to repeat or protected with an idempotency mechanism.
- Keep the total timeout within the upstream request’s remaining latency budget.
- Add randomized backoff where many instances could retry simultaneously.
- Do not retry an unbounded number of times.
- Use circuit breaking or concurrency limits when repeated dependency failures could exhaust resources.
- Return cached or default data only when the fallback is correct for the business operation.
- Track retry attempts and final failures separately in metrics.
Use Local and Global WebFlux Error Handling Together
Local error handling belongs close to the operation when the handler has enough context to choose a valid fallback or translate a known domain condition. Global handling provides a consistent final response for failures that reach the HTTP boundary.
| Error-handling location | Suitable responsibilities |
|---|---|
| Repository or client adapter | Translate driver, protocol, or downstream-client failures into meaningful application exceptions. |
| Domain service | Publish domain-rule failures and decide whether a domain fallback is valid. |
| Handler or controller | Map known application outcomes to the endpoint’s HTTP contract. |
| Global WebFlux handler | Apply the common error format, correlation identifier, content type, and safe fallback for unhandled errors. |
A global handler should not turn every exception into 200 OK or 400 Bad Request. Preserve meaningful status categories, use stable machine-readable error codes, and avoid exposing internal exception messages.
Design a Consistent Reactive Microservice Error Response
A client-facing error body should be stable enough for callers to process without depending on Java or Kotlin exception names. A practical response can contain:
- code: A stable application error code such as
CUSTOMER_ALREADY_EXISTS. - message: A concise explanation safe to show to the caller.
- status: The HTTP status code when including it avoids ambiguity.
- path: The request path, if appropriate for the API.
- correlationId: An identifier that connects the client report with logs and traces.
- fieldErrors: Structured validation failures for individual input fields.
{
"code": "CUSTOMER_ALREADY_EXISTS",
"message": "A customer with this ID already exists.",
"status": 409,
"path": "/functional/customer/",
"correlationId": "8d7a01c9f3b24c52"
}
Do not use a correlation identifier as an authentication secret. It is an operational reference that should be validated and sanitized before being copied from an incoming request.
Log Reactive Errors Without Losing Diagnostic Context
Use doOnError for logging or metrics when the error must continue downstream. Avoid logging the same stack trace in the client adapter, service, handler, and global handler. Repeated logging increases noise and can make one failure appear to be several incidents.
- Record the service operation, error category, downstream dependency, duration, and correlation or trace identifier.
- Use Reactor Context or the tracing integration supported by the application instead of relying only on thread-local state.
- Exclude passwords, tokens, cookies, personal data, and full request bodies from routine error logs.
- Separate expected client or domain failures from unexpected system failures in metrics.
- Record retry attempts, timeouts, circuit-breaker state changes, and fallback use.
- Keep the full internal exception available to operators while returning a sanitized message to clients.
Test Reactive Error Paths with StepVerifier
Reactive error behavior should be tested by subscribing to the publisher. Reactor Test’s StepVerifier can assert emitted values, completion, and error types without adding block() to production code.
@Test
void duplicateCustomerPublishesDomainError() {
Mono<Customer> result = customerService.createCustomer(
Mono.just(new Customer(18, "New Customer"))
);
StepVerifier.create(result)
.expectErrorMatches(error ->
error instanceof CustomerExistException &&
error.getMessage().contains("18")
)
.verify();
}
Endpoint tests should also verify the HTTP status, content type, stable error code, validation details, and absence of internal stack information. For downstream calls, test connection failures, timeouts, malformed responses, retry exhaustion, and any configured fallback.
Reactive Microservice Error-Handling Mistakes to Avoid
- Catching every exception as a bad request: This hides server defects and dependency outages behind an incorrect 400 response.
- Calling
subscribe()inside a service: This detaches work from the request chain and makes error propagation and cancellation harder to control. - Using
block()in a WebFlux request path: Blocking can consume event-loop capacity and undermine the reactive execution model. - Using
onErrorReturnfor every failure: A default value can make unavailable or corrupt data look valid. - Retrying all errors: Deterministic failures remain deterministic and repeated calls can amplify an outage.
- Returning raw exception messages: Internal details can leak and the public response becomes coupled to implementation text.
- Swallowing errors with an empty publisher: Completion and failure have different meanings and should not be interchanged without a defined business rule.
- Placing recovery too early: A broad operator high in the chain can obscure which operation failed and apply the wrong fallback.
Reactive Microservices Error-Handling QA Checklist
- Confirm that malformed JSON, field validation, missing resources, domain conflicts, downstream failures, and unexpected exceptions produce distinct documented outcomes.
- Verify that each
onErrorResumematches specific errors or has a justified boundary-level fallback. - Check that
doOnErroris not incorrectly described or used as a recovery operator. - Verify that retry policies are bounded, apply only to transient failures, and do not repeat unsafe writes without idempotency protection.
- Confirm that timeouts fit within the caller’s end-to-end latency budget.
- Check that WebClient errors are translated to the current service’s API contract rather than forwarded unchanged.
- Verify that client responses exclude stack traces, database details, credentials, internal hostnames, and raw downstream bodies.
- Confirm that logs and traces retain a correlation identifier and enough context to identify the failing dependency and operation.
- Test error publishers with StepVerifier and HTTP mappings with WebFlux endpoint tests.
- Verify that fallbacks return semantically valid data and are observable through metrics.
Reactive Microservices Error Handling FAQs
How should errors be handled in reactive microservices?
Represent failures as reactive error signals, translate low-level failures into meaningful application exceptions, and map them to a stable API response at the correct boundary. Use typed onErrorResume for contextual recovery, onErrorMap for translation, and a global WebFlux handler for consistent treatment of unhandled errors.
What is the difference between onErrorResume and onErrorReturn?
onErrorResume selects another publisher and can perform asynchronous recovery based on the exception. onErrorReturn replaces a matching error with one fixed value. Use either only when the replacement is a correct result for the failed operation.
How should WebClient exceptions be handled in Spring WebFlux?
Classify HTTP error responses with onStatus or exchangeToMono, translate them into application-specific exceptions, and handle transport failures such as timeouts separately. Avoid returning raw downstream response bodies or internal service details to the original caller.
When should a reactive microservice retry a failed request?
Retry only transient failures when the operation is safe to repeat or protected by idempotency. Use a small attempt limit, backoff, a total timeout, and metrics. Do not retry validation failures, authorization failures, domain conflicts, or other deterministic errors.
Why does try-catch not handle every Reactor error?
A try/catch around publisher construction catches only exceptions thrown while assembling that publisher. Many reactive failures occur later, after subscription, and travel as onError signals. Those failures must be processed with Reactor error operators or by the final subscriber and WebFlux error infrastructure.
TutorialKart.com