How do ErrorDecoder and RequestInterceptor work in Feign, and when would you customize each?
answer
- RequestInterceptor: apply(RequestTemplate) every request
- ErrorDecoder: decode(methodKey, Response) only non-2xx
- map status → domain exception
- return RetryableException → Retryer retries
- body is single-use stream; per-client vs global scope
basics
~20 sA RequestInterceptor mutates every outgoing request (e.g. add an auth header) before it is sent. An ErrorDecoder converts a non-2xx HTTP response into an exception you choose, letting you map status codes to domain exceptions or trigger retries.
solid answer
~40 sBy default Feign throws a generic `FeignException` for any non-2xx response and sends requests as-built. Two extension points customize this: **`RequestInterceptor`** — a bean whose `apply(RequestTemplate)` runs for every request just before dispatch. Typical uses: inject `Authorization`/bearer tokens, correlation IDs, tenant headers, or shared query params. Multiple interceptors chain. **`ErrorDecoder`** — invoked only when the response status is not 2xx. Its `decode(String methodKey, Response response)` returns a `Throwable`. You map specific statuses to domain exceptions (e.g. 404→`NotFoundException`, 409→`ConflictException`), read the error body, or return a `RetryableException` (optionally with a retry-after) so Feign's `Retryer` retries. Return the fallback via `new ErrorDecoder.Default().decode(...)` for unhandled cases. Both can be registered globally (a `@Bean` in a `@Configuration`) or per-client through `@FeignClient(configuration = ...)`, where per-client beans override globals.
code
java · 32 linespublic class CatalogFeignConfig {
@Bean
public RequestInterceptor authInterceptor(TokenProvider tokens) {
return template -> template.header("Authorization", "Bearer " + tokens.current());
}
@Bean
public ErrorDecoder errorDecoder() {
return new CatalogErrorDecoder();
}
static class CatalogErrorDecoder implements ErrorDecoder {
private final ErrorDecoder fallback = new ErrorDecoder.Default();
@Override
public Exception decode(String methodKey, Response response) {
return switch (response.status()) {
case 404 -> new ResourceNotFoundException(methodKey);
case 409 -> new ConflictException(methodKey);
case 503 -> new RetryableException(
response.status(), "catalog unavailable",
response.request().httpMethod(),
(Long) null, response.request());
default -> fallback.decode(methodKey, response);
};
}
}
}
@FeignClient(name = "catalog", configuration = CatalogFeignConfig.class)
public interface CatalogClient { /* ... */ }go deeper
Know that RequestInterceptor adds headers and ErrorDecoder turns error responses into exceptions.
Implement both, map status codes to domain exceptions, and register per-client vs global.
Explain the single-use body stream, RetryableException + Retryer interaction, and delegation to the default decoder.
Design consistent cross-service auth/tracing propagation and a uniform error-to-exception strategy, accounting for thread-local context and retry/idempotency concerns.
## The request/response pipeline When you call a Feign method, roughly: the **Contract** metadata + arguments build a **`RequestTemplate`** → the **Encoder** writes the body → each **`RequestInterceptor`** mutates the template → the underlying HTTP `Client` sends it → the **`Response`** comes back → if 2xx, the **Decoder** turns the body into the return type; if not 2xx, the **`ErrorDecoder`** turns it into an exception. `RequestInterceptor` and `ErrorDecoder` are the two hooks on either side. ## RequestInterceptor ``` public interface RequestInterceptor { void apply(RequestTemplate template); } ``` Registered as beans, they run for **every outgoing request** on the clients they apply to, immediately before dispatch. `RequestTemplate` lets you `header(...)`, `query(...)`, modify the body, etc. Canonical use cases: - **Auth propagation**: read the current OAuth2/JWT token (e.g. from `SecurityContextHolder` or an `OAuth2AuthorizedClient`) and add `Authorization: Bearer ...`. - **Correlation/trace IDs**: forward `X-Request-Id` from MDC so logs stitch across services. - **Tenant/locale headers** or a shared API key. Multiple interceptors form a chain; order isn't guaranteed to be meaningful, so keep them independent. A subtle gotcha: interceptors run on the *calling* thread, so if you rely on `SecurityContextHolder` (thread-local) it must be populated on that thread — async/hystrix thread hand-off can lose it. ## ErrorDecoder ``` public interface ErrorDecoder { Exception decode(String methodKey, Response response); } ``` Called **only for non-2xx** responses. The default (`ErrorDecoder.Default`) produces a `FeignException` subclass (`FeignException.NotFound`, `.BadRequest`, etc. for common codes) and honors any `Retry-After` header by returning a `RetryableException` for 503/429-style cases. Customizing lets you: - **Map status → domain exception**: `switch (response.status()) { case 404 -> new ResourceNotFoundException(...); case 409 -> new ConflictException(...); }` so callers catch meaningful types instead of parsing `FeignException.status()`. - **Extract the error body**: read `response.body()` (an `InputStream`) once — you must fully consume/close it — to parse a structured error payload. Reading it twice or leaving it open leaks. - **Trigger retries**: return a `feign.RetryableException` and Feign's configured **`Retryer`** (default `Retryer.NEVER_RETRY` in Spring Cloud unless you provide a `Retryer` bean) will retry with backoff. You can pass a `retryAfter` `Date`/`Long`. - **Delegate**: keep a `new ErrorDecoder.Default()` and call it for statuses you don't special-case, so behavior for the rest is unchanged. ### Gotchas with ErrorDecoder - The `Response` body is a **single-use stream**; buffer it (`Util.toString(response.body().asReader(UTF_8))`) if you need it, and beware it's already consumed if a Decoder ran. - Returning a checked exception not declared on the interface method wraps it; usually return unchecked (RuntimeException) domain exceptions. - ErrorDecoder does not fire for connection failures/timeouts — those surface as `FeignException`/`RetryableException` from the client layer, not through `decode`. ## Registration scope Both are ordinary beans. Put a `@Bean` in a class picked up by component scan → **global** default for all clients. Put it in a class referenced by `@FeignClient(configuration = MyConfig.class)` and **do not** annotate that class with `@Configuration` under the scanned packages (to avoid it becoming global) → **per-client** override. Per-client beans win over globals. This scoping is a frequent source of "my interceptor applies to the wrong clients" bugs. ## When to use Use a `RequestInterceptor` for cross-cutting outbound concerns (auth, tracing) and an `ErrorDecoder` to turn transport-level status codes into your application's exception vocabulary and to control retry semantics.
- How do you make Feign actually retry after your ErrorDecoder returns a RetryableException?You must provide a `Retryer` bean. Spring Cloud OpenFeign defaults to `Retryer.NEVER_RETRY`, so even a RetryableException won't be retried unless you register e.g. `new Retryer.Default(...)` (with maxAttempts and backoff) globally or per-client. The RetryableException can carry a retry-after time that the Retryer honors.
- What is the danger of reading response.body() inside an ErrorDecoder?The body is a single-use InputStream. If you read it without buffering you consume it, and if a Decoder or logging already read it, it may be empty. Read it once into a String/bytes and close it; failing to close can leak connections in some clients.
- How do you limit a RequestInterceptor to just one client instead of all?Define it as a bean inside a configuration class referenced by that client's `@FeignClient(configuration = ...)`, and keep that class out of the @Configuration component-scan path so it isn't registered globally. Per-client configuration beans override the global context for that client only.
saying these in an interview costs you the question
- Thinking ErrorDecoder is called for all responses including 2xx
- Assuming returning a RetryableException retries without a Retryer bean (Spring default is NEVER_RETRY)
- Reading the response body stream multiple times
- Believing a global @Bean interceptor can be scoped to one client without the configuration attribute
- Expecting ErrorDecoder to handle connection timeouts (it only sees HTTP responses)