한국어 | English
YAML-driven multi-channel RestClient management for Spring Boot 3.5+ / Java 25
mido-client eliminates boilerplate RestClient configuration by letting you define multiple external API channels —
each with its own URL, auth, timeout, logging, and interceptors — entirely in application.yml. No @Bean methods, no
factory classes, no repeated setup code.
| RestClient (vanilla) | OpenFeign | mido-client | |
|---|---|---|---|
| Configuration style | Java @Bean |
Java interface + annotations | YAML only |
| Multi-channel setup | Manual per bean | Manual per interface | Built-in |
| Dual endpoint per service | Manual | Not supported | Built-in |
| Request/response logging | Manual interceptor | Plugin required | Built-in (4 levels) |
| Client instance caching | Manual | Managed by framework | Built-in |
Built on Spring RestClient |
Yes | No (uses Feign) | Yes |
- Multi-channel support — define unlimited external API channels, each with
primary/secondarydual endpoint - Automatic client caching — one
RestClientinstance per channel/endpoint, thread-safe viaConcurrentHashMap - 4-level built-in logging —
off/console/file/all(console + file simultaneously), includes body, URL, response time - Per-endpoint authentication — Bearer, Basic, API Key
- Smart charset detection — Content-Type header → UTF-8 validation → channel default fallback
- Custom interceptors — register any
ClientHttpRequestInterceptorin YAML by Spring bean name (dependency injection works) or by class name - Per-channel connection isolation — every channel/endpoint gets its own
java.net.http.HttpClient, so one saturated channel cannot starve the others; HTTP/2 included - Per-channel gzip — opt-in request compression with
min-sizeskip threshold; response auto-decompression with decompression-bomb defense cap (max-decompressed-size) - Per-channel content type — pick
json(default) orxmlper channel; the requestContent-Typeheader is set automatically - Fail-fast configuration validation —
@ValidatedBean Validation rejects malformed YAML at startup with aBindValidationExceptionindicating the offending field - ChannelContext with MDC — scoped (
ScopedValue) channel action tracking, integrated with SLF4J MDC for distributed log tracing; bind it declaratively with@ChannelName+@ChannelAction(optional, needs an AOP runtime) - Zero-code Auto-Configuration — activated with a single
mido-client.enabled: trueproperty
| Requirement | Version |
|---|---|
| Java | 25 |
| Spring Boot | 3.5.x (built & tested with 3.5.16) |
| Gradle | 8.14.4 |
Java 25 is required because
ChannelContextis built on the finaljava.lang.ScopedValueAPI (JEP 506, Java 25). Consumers need a Spring Framework 6.2 release whose bundled ASM can read Java 25 bytecode (recent 6.2.x patches; Spring Boot 3.2.x cannot). This library is built and tested against Spring Boot 3.5.16.
Gradle
repositories {
maven { url 'https://jitpack.io' }
}
dependencies {
implementation 'com.github.skaca8:mido-client:3.3.0'
}Maven
<repositories>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
<dependency>
<groupId>com.github.skaca8</groupId>
<artifactId>mido-client</artifactId>
<version>3.3.0</version>
</dependency>To use a specific release, replace the version above with a tag or a commit hash.
Gradle
implementation 'io.github.skaca8:mido-client:3.3.0'Maven
<dependency>
<groupId>io.github.skaca8</groupId>
<artifactId>mido-client</artifactId>
<version>3.3.0</version>
</dependency>mido-client:
enabled: true
channels:
payment:
title: "Payment Service"
charset: UTF-8
type: json # json (default) | xml
primary:
url: https://api.payment.com
read-timeout-seconds: 30
connect-timeout-seconds: 5
authorization:
type: bearer
token: ${PAYMENT_QUERY_TOKEN}
log: console
secondary: # optional: secondary endpoint for the same service
url: https://process.payment.com
read-timeout-seconds: 60
authorization:
type: bearer
token: ${PAYMENT_PROCESS_TOKEN}
log: all
auth:
primary:
url: https://auth.example.com
authorization:
type: bearer
token: ${AUTH_TOKEN}
headers:
- name: X-API-Version
value: v1@Service
public class PaymentService extends BaseExternalApi {
private final RestClient queryClient;
private final RestClient processClient;
public PaymentService(MidoClientFactory midoClientFactory) {
this.queryClient = midoClientFactory.getOrCreateClient("payment");
this.processClient = midoClientFactory.getOrCreateClient("payment", EndpointType.SECONDARY);
}
@Override
protected String getChannelName() {
return "payment";
}
public PaymentStatus getPaymentStatus(String paymentId) {
return withDefaultChannelAction("getPaymentStatus", () ->
queryClient.get()
.uri("/payments/{id}/status", paymentId)
.retrieve()
.body(PaymentStatus.class)
);
}
public PaymentResult processPayment(PaymentRequest request) {
return withDefaultChannelAction("processPayment", () ->
processClient.post()
.uri("/payments/process")
.body(request)
.retrieve()
.body(PaymentResult.class)
);
}
}
BaseExternalApi.withDefaultChannelAction()automatically sets and clearsChannelContextaround each call, including on exception.
| Property | Type | Default | Description |
|---|---|---|---|
title |
String | - | Channel description (optional) |
charset |
String | UTF-8 |
Default character encoding for response body |
type |
ContentType | json |
Request Content-Type for the channel — json / xml |
| Property | Type | Default | Description |
|---|---|---|---|
url |
String | - | Required. Base URL of the endpoint |
title |
String | - | Endpoint description (optional) |
read-timeout-seconds |
Long | 60 |
Read timeout |
connect-timeout-seconds |
Long | 3 |
Connection timeout |
log |
LogLevel | console |
off / console / file / all |
log-body |
Boolean | true |
Include request/response bodies in the log lines. Set false on endpoints carrying PII, card, or token data |
log-max-body-bytes |
Integer | 8192 |
Max body bytes per log line; the rest becomes ...(truncated N bytes). 0 = no limit |
authorization.type |
TokenType | - | bearer / basic / api_key |
authorization.token |
String | - | Authentication token value |
headers |
List | - | Static headers to attach to every request |
interceptors |
List<String> | - | Spring bean names or fully-qualified class names of ClientHttpRequestInterceptor, in execution order |
gzip.request |
Boolean | false |
Compress outgoing request body (Content-Encoding: gzip) |
gzip.response |
Boolean | false |
Force Accept-Encoding: gzip and auto-decompress response |
gzip.min-size |
Integer | 1024 |
Skip request compression when body is smaller than this (bytes) |
gzip.max-decompressed-size |
Integer | 10485760 |
Throw IOException if decompressed response exceeds this (bytes — decompression-bomb defense) |
| Property | Type | Default | Description |
|---|---|---|---|
mido-client.enabled |
Boolean | false |
Enable/disable the entire library |
mido-client validates @ConfigurationProperties at application startup. Misconfiguration causes the context to fail to start with a BindValidationException that indicates the offending field and the rejected value. Examples that fail validation:
urlis blank or doesn't match^https?://.+read-timeout-secondsorconnect-timeout-secondsis zero or negativegzip.min-sizeis negativegzip.max-decompressed-sizeis zero or negativeheaders[].nameorheaders[].valueis blank- A channel is missing its required
primaryendpoint typeis explicitly set tonull(must bejsonorxml; unknown values are rejected separately by Spring's enum binder at startup)
Beyond bean validation, the MidoClientFactory bean checks the following at startup, so a typo does not wait for the first request to surface:
charsetnames an unknown charset →Invalid charset '<name>' for channel: <channel>- an
interceptors[]entry is neither a registered bean name nor a loadable class name → the message names the channel and endpoint - the entry names a bean whose type does not implement
ClientHttpRequestInterceptor - the entry names a class that does not implement
ClientHttpRequestInterceptor, or has no public no-arg constructor and no matching bean to use instead
Nothing is instantiated during this check. A class name is loaded and inspected; a bean name is checked through containsBean / getType, so a constructor with side effects does not run twice.
Implement ClientHttpRequestInterceptor and list it under the endpoint. Each entry is a Spring bean name or a fully-qualified class name, and YAML order is execution order.
mido-client:
channels:
payment:
primary:
url: https://api.payment.com
interceptors:
- paymentMetricsInterceptor # bean name → taken from the container
- com.example.RequestIdInterceptor # class name → instantiated reflectivelyBean names give you dependency injection. Declare the interceptor as a bean and inject whatever it needs:
@Component
public class PaymentMetricsInterceptor implements ClientHttpRequestInterceptor {
private final MeterRegistry meterRegistry;
public PaymentMetricsInterceptor(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
Timer.Sample sample = Timer.start(meterRegistry);
try {
return execution.execute(request, body);
} finally {
sample.stop(meterRegistry.timer("payment.latency"));
}
}
}interceptors:
- paymentMetricsInterceptorThe static field and ApplicationContextHolder workarounds earlier versions documented are no longer needed.
Class names still work exactly as before. A stateless interceptor needs no bean:
public class RequestIdInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
request.getHeaders().add("X-Request-Id", UUID.randomUUID().toString());
return execution.execute(request, body);
}
}| Entry | Resolved as |
|---|---|
| A registered bean name | that bean |
| A loadable class name with exactly one bean of that type | that bean |
| A loadable class name with no bean of that type | a reflective instance from the public no-arg constructor |
| A loadable class name with two or more beans of that type | a reflective instance, plus a warning naming the candidates — reference one by bean name instead |
| Neither | IllegalStateException at startup |
Upgrading from 3.2.x: if you list a class name and that class is also registered as a bean, you now get the bean instead of a separate reflective instance. That is usually the fix you wanted — previously the
@Componentyou wrote was a different object than the onemido-clientused — but it is a behavior change. List the class name of a non-bean class to keep the old behavior.
Interceptor beans are not fetched while the RestClient is built. They are resolved on the first request through that client and then cached.
This is a design constraint, not an optimization. getOrCreateClient is normally called from a consumer's constructor:
@Component
@ChannelName("payment")
public class PaymentAdapter {
private final RestClient client;
public PaymentAdapter(MidoClientFactory factory) {
this.client = factory.getOrCreateClient("payment"); // still inside bean creation
}
}Pulling an interceptor bean at that moment would force it — and everything it depends on — into existence mid-construction, turning ordinary wiring into a circular reference. Deferring means the lookup happens after the context has finished refreshing, so an interceptor may safely depend on anything, including a bean that itself uses mido-client.
Two consequences worth knowing:
- An interceptor bean is not created just because a channel names it. It appears on the first request through that channel (or earlier, if something else in your application already needed it).
- A misconfiguration that only a full lookup would reveal — a bean that exists but cannot be created — surfaces on that first request, not at startup. Startup still catches a missing bean, a missing class, and a bean of the wrong type.
Fail-fast behavior: if an entry is neither a registered bean name nor a loadable class name, if a named bean's type does not implement ClientHttpRequestInterceptor, or if a class name neither implements the interface nor has a usable no-arg constructor, the context fails to start with a message naming the channel, the endpoint, and the offending entry.
mido-client intentionally does not bundle a resilience layer — bring your own (Resilience4j, Sentinel, Failsafe, Spring Retry, …) via the interceptors: config. Below is a copy-paste-ready recipe with Resilience4j.
1. Add Resilience4j to your application's dependencies (NOT to mido-client itself):
implementation 'io.github.resilience4j:resilience4j-circuitbreaker:2.2.0'
implementation 'io.github.resilience4j:resilience4j-ratelimiter:2.2.0'
implementation 'io.github.resilience4j:resilience4j-retry:2.2.0'2. Write a single interceptor that wraps the call with Resilience4j decorators:
package com.yourapp.interceptor;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.decorators.Decorators;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiterConfig;
import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.retry.RetryConfig;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import java.io.IOException;
import java.time.Duration;
public class PaymentResilienceInterceptor implements ClientHttpRequestInterceptor {
private static final RateLimiter RATE_LIMITER = RateLimiter.of("payment",
RateLimiterConfig.custom()
.limitForPeriod(100)
.limitRefreshPeriod(Duration.ofSeconds(1))
.timeoutDuration(Duration.ofMillis(500))
.build());
private static final CircuitBreaker CIRCUIT_BREAKER = CircuitBreaker.of("payment",
CircuitBreakerConfig.custom()
.failureRateThreshold(50)
.waitDurationInOpenState(Duration.ofSeconds(30))
.slidingWindowSize(20)
.build());
private static final Retry RETRY = Retry.of("payment",
RetryConfig.custom()
.maxAttempts(3)
.waitDuration(Duration.ofMillis(200))
.build());
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
try {
return Decorators.ofCallable(() -> execution.execute(request, body))
.withCircuitBreaker(CIRCUIT_BREAKER)
.withRateLimiter(RATE_LIMITER)
.withRetry(RETRY)
.decorate()
.call();
} catch (IOException | RuntimeException e) {
throw e;
} catch (Exception e) {
throw new IOException(e);
}
}
}3. Register on the channel via YAML:
mido-client:
channels:
payment:
primary:
url: https://api.payment.com
interceptors:
- "com.yourapp.interceptor.PaymentResilienceInterceptor" # or the bean name, if you declare it as oneTips:
- Custom interceptors are registered before
mido-client's logging interceptor, so retry attempts and rate-limit waits show up as separate log entries — useful for debugging cascading failures. - Prefer one interceptor class per channel — the decorators' state (open/closed window, retry counters) is keyed by the registry name, so sharing across channels with different SLAs causes cross-talk.
- The
static finalregistries above keep the example dependency-free. If you add theresilience4j-spring-boot3starter, declare the interceptor as a bean instead, injectCircuitBreakerRegistry/RateLimiterRegistry, and reference it by bean name — that gives you YAML-driven tuning without recompiling. - If you only need one of the three (e.g. rate limiting), drop the unused decorators — chaining only what you need keeps stack traces shallow and behavior predictable.
Each channel sends requests with a single default Content-Type. Pick it once per channel via type; if omitted, json is used.
mido-client:
channels:
legacySoap:
type: xml # outgoing Content-Type: application/xml
primary:
url: https://soap.example.com
modernRest:
# type omitted → defaults to json
primary:
url: https://api.example.comBehavior:
type: json(default) —Content-Type: application/jsonis attached to every request; POJO bodies are serialized via Jackson.type: xml—Content-Type: application/xmlis attached to every request. A pre-serialized XMLStringbody always works. POJO ↔ XML depends on your classpath:mido-clientkeepsRestClient's default converter list (only theStringconverter is replaced, to apply the channelcharset), so addingjackson-dataformat-xmlto your application enablesMappingJackson2XmlHttpMessageConverter. It is not amido-clientdependency.
Per-channel opt-in HTTP body compression. Each direction is independently toggleable.
mido-client:
channels:
payment:
primary:
url: https://api.payment.com
gzip:
request: true # compress outgoing body
response: true # request gzipped response and auto-decompress
min-size: 1024 # skip compression for small bodies
max-decompressed-size: 10485760 # 10 MB safety capBehavior:
request: true— bodies ≥min-sizebytes are gzipped before sending;Content-Encoding: gzipis added andContent-Lengthis updated to the compressed length. An empty body is never compressed, even withmin-size: 0, so a bodylessGETdoes not pick up a gzip header.response: true—Accept-Encoding: gzipis sent; if the server replies withContent-Encoding: gzip, the body is transparently decompressed before reaching your message converters.max-decompressed-sizedefends against decompression bombs — if the decompressed response exceeds the cap, anIOExceptionis thrown immediately and memory stays bounded to roughly buffer + cap.
Interceptors are ordered so that logging always sees plain-text bodies while the network carries compressed bytes. The full chain is:
your custom interceptors → mido logging → mido gzip → transport
gzip.request: true, either compute the signature over the compressed body yourself inside the interceptor, or leave request compression off for that channel.
Every channel is backed by JdkClientHttpRequestFactory over java.net.http.HttpClient. There is nothing to configure — the point is that each channel/endpoint gets its own HttpClient, and therefore its own connection pool. A slow or saturated channel cannot starve the others, which is the isolation this library exists to provide.
| Property | Value |
|---|---|
| Request factory | JdkClientHttpRequestFactory |
| Connection pool | One per channel/endpoint |
| HTTP/2 | Yes (negotiated, with HTTP/1.1 fallback) |
| Redirects | Followed (Redirect.NORMAL, refuses HTTPS→HTTP downgrade) |
| Proxy | http.proxyHost / https.proxyHost system properties honored — mido-client never calls HttpClient.Builder.proxy(), and a builder that does not is documented to use ProxySelector.getDefault() |
read-timeout-seconds is a whole-exchange deadline, not a socket idle timeout. It covers everything from request send to the response body being fully consumed, and expires as HttpTimeoutException. Spring implements it with its own TimeoutHandler rather than HttpRequest.Builder#timeout (a workaround for JDK-8258397); the timer is cancelled when the response body stream is closed. So size the value against total expected call time, not per-packet gaps — a response that trickles in slowly but steadily will still be cut off. mido-client buffers the whole response for logging anyway, so this is not a streaming-download client.
connect-timeout-seconds maps to HttpClient.Builder.connectTimeout and surfaces separately as HttpConnectTimeoutException, which is why FailureType can tell "never reached the server" from "may already have been sent".
BufferingClientHttpRequestFactory so the logging interceptor can re-read the body, which means the full response is held as a byte[] regardless of your log / log-body settings. log-max-body-bytes bounds what reaches the log, not what reaches the heap. Do not point a channel at an endpoint that returns responses large enough to matter against your heap — this is not a file-download client.
Lifecycle: every client is shut down when the Spring context closes (MidoClientFactory implements DisposableBean). HttpClient.shutdown() is used rather than close() so that a request still in flight cannot stall shutdown — in-flight exchanges finish and the selector/pool threads then exit.
BaseExternalApi.withDefaultChannelAction() binds ChannelContext automatically. ChannelContext is
backed by a ScopedValue (Java 25): the action is bound only for the dynamic extent of the call and
is auto-unbound on both return and exception — there is no manual set/clear. For direct usage:
// void form
ChannelContext.runWithChannelAction("payment.processPayment", () -> {
// your REST call — channelAction appears in all logs via MDC
});
// value-returning form
String status = ChannelContext.callWithChannelAction("payment.processPayment", () ->
restClient.get().uri("/status").retrieve().body(String.class));The action key channelAction is available in log patterns:
<!-- logback.xml -->
<pattern>%d [%X{channelAction}] %-5level %msg%n</pattern>Wrapping every call in a lambda gets repetitive, and forgetting one is invisible — the log line just says channelAction: unknown. The annotations remove the repetition:
@Service
@ChannelName("payment") // class = channel (the external system)
public class PaymentAdapter {
private final RestClient client;
public PaymentAdapter(MidoClientFactory factory) {
this.client = factory.getOrCreateClient("payment");
}
@ChannelAction // -> "payment.getStatus"
public PaymentStatus getStatus(String id) {
return client.get().uri("/payments/{id}/status", id).retrieve().body(PaymentStatus.class);
}
@ChannelAction("processPayment") // -> "payment.processPayment"
public PaymentResult process(PaymentRequest request) {
return client.post().uri("/payments/process").body(request).retrieve().body(PaymentResult.class);
}
}The two axes are split on purpose: the channel is a property of the class (which external system), the action a property of the method (what call). One annotation carrying both would let the channel vary per method and break the "one class = one channel" invariant. A class that genuinely talks to two channels should be split in two — there is no method-level channel override.
Requires an AOP runtime you provide. mido-client declares aspectjweaver as compileOnly, so add spring-boot-starter-aop (Boot 3) to your application to activate the aspect. Without it the annotations are inert and nothing else changes.
implementation 'org.springframework.boot:spring-boot-starter-aop'private / static / final methods, or to objects that are not Spring beans. In those cases the action is simply not bound and the log shows unknown — the annotation being present is not proof that it took effect.
So mido-client checks for it at startup instead of leaving you to notice channelAction: unknown in production. Once all singletons exist, every bean carrying @ChannelAction is inspected:
| Finding | Result |
|---|---|
@ChannelAction on a class without @ChannelName |
startup failure |
@ChannelAction on a private or static method |
startup failure — never matched by the @annotation pointcut |
@ChannelAction on a final method |
startup failure — CGLIB cannot override it |
| An annotated method called from an unannotated method of the same class | warning, naming caller and callee |
@ChannelAction used with no AspectJ runtime on the classpath |
warning — the annotations do nothing |
A class annotated @ChannelName in your application's packages that is not a Spring bean |
warning — its annotations are never advised |
The first three are fatal because the advice provably cannot apply; leaving them as warnings would just be a slower version of the same silent failure.
Self-invocation is only a warning. It is found by reading the class bytes (via the ASM already inside spring-core — no new dependency) and looking for calls to an annotated method from an unannotated one in the same class. The bytecode cannot prove the receiver is this rather than another instance of the same type, so failing startup on it would occasionally be wrong. A call from a method that is itself annotated is not reported: the context is already bound, so bypassing the proxy changes nothing. Any failure inside the scan (unreadable class file, ASM mismatch) degrades to "no findings" and never breaks startup.
WARN @ChannelAction on PaymentAdapter#getStatus is bypassed when called from PaymentAdapter#refresh —
a self-invocation does not go through the Spring proxy, so channelAction will be 'unknown' for
that path. Call it through an injected reference, or bind explicitly with
ChannelContext.callWithChannelAction(...).
The non-bean check scans your application's own auto-configuration packages for the class-level @ChannelName. Abstract classes and interfaces are skipped: @ChannelName is @Inherited, so an abstract base declaring the channel is a legitimate pattern and can never be a bean. It is a warning rather than a failure because instantiating such a class deliberately and binding the context by hand is valid — just not what the annotation does.
What no check can see: reflective invocations, and objects wrapped in a proxy the library did not create. Both bypass the pointcut without leaving anything detectable at startup. The aspect keeps its own runtime guard for the missing-@ChannelName case to cover those paths.
Nesting is safe: ChannelContext saves and restores the previous MDC value, so an annotated method called inside another bound action correctly exposes the outer action again once it returns.
The aspect runs at Ordered.HIGHEST_PRECEDENCE + 100, outside @Transactional, so the action stays bound through transaction commit. Define your own ChannelActionAspect bean to replace it.
@ChannelAction |
BaseExternalApi / ChannelContext |
|
|---|---|---|
| Boilerplate | None | A lambda per call |
Needs spring-boot-starter-aop |
Yes | No |
Works on self-invocation, private/final, non-beans |
No | Yes |
| Missing binding is visible in code | No, but caught at startup | Yes |
Both are supported and interoperate; BaseExternalApi is not deprecated. Use the annotations for straightforward adapter beans, and the explicit form where proxying does not reach or where you want the binding visible at the call site.
log picks the destination, not the severity:
| Level | Console | File (MidoClientFileLog) |
|---|---|---|
off |
- | - |
console |
Yes | - |
file |
- | Yes |
all |
Yes | Yes |
Severity follows the outcome, so alerting can key on the level instead of parsing log text:
| Outcome | Level |
|---|---|
| Request line, 2xx / 3xx response | info |
| 4xx response | warn |
| 5xx response | error |
| Transport failure (no response) | error |
Each log entry includes: channel action, HTTP method, URL, request/response body, response time, HTTP status.
If log: file or log: all is configured but the host application never declared a MidoClientFileLog logger, a warning is emitted at startup — otherwise those lines would silently fall through to the root logger. The check runs on Logback only; on other SLF4J bindings it is skipped rather than guessed at.
A call that fails before a response arrives (connect / read timeout, DNS, TLS) is logged as a separate [mido-client failure] line at error level, carrying the elapsed time, the failure classification, and the exception type/message. The stack trace is not repeated there — the exception propagates to the caller unchanged.
[mido-client failure] channelAction: payment.pay, method: POST, url: https://api.payment.com/pay,
elapsedMs: 3011, failureType: timeout, delivery: UNKNOWN, exception: java.net.SocketTimeoutException: Read timed out
failureType / delivery come from FailureType.classify(Throwable), which you can call yourself instead of walking the cause chain. mido-client never wraps or replaces the exception Spring and the JDK throw, so existing handlers and Resilience4j exception predicates keep working:
catch (RestClientException e) {
log.warn("payment call failed: {}", FailureType.classify(e));
throw toDomainFailure(e);
}failureType |
delivery |
Meaning |
|---|---|---|
dns |
NOT_DELIVERED |
Host name did not resolve |
connect |
NOT_DELIVERED |
Refused, unreachable, or connect timeout |
tls |
UNKNOWN |
Handshake, certificate, or mid-stream protocol failure |
timeout |
UNKNOWN |
Timed out; may or may not have been processed |
client-error / server-error |
DELIVERED |
Server answered 4xx / 5xx |
unknown |
UNKNOWN |
Nothing matched |
delivery is an observation, not a retry policy. "Did the request reach the server" and "is re-running this operation safe" are different questions, and only the first one is answerable from an exception. Three things worth keeping separate:
| Question | Answered by | |
|---|---|---|
| Delivery | Did the request reach the server? | FailureType |
| Retry safety | Can this operation be re-run? | The operation's own semantics |
| Idempotency | What makes re-running harmless? | An idempotency key, server-side |
A non-idempotent call — a payment authorization, say — needs an idempotency key regardless of what this classifier returns, because UNKNOWN is a frequent and unavoidable answer. read-timeout-seconds is a whole-exchange deadline, so timeout routinely means "the server processed it but the response was too slow". tls is UNKNOWN for the same reason: SSLException also covers failures raised while reading the response, after the request was delivered.
Treating NOT_DELIVERED as "safe to retry" is the mistake this API most invites. It holds only for operations that were already safe to re-run.
NOT_DELIVERED is also narrower than it sounds once redirects are involved. The transport follows them, and for 307/308 it re-sends the original method and body, so a dns or connect failure can happen on a later hop after an earlier host was already reached. What the label asserts is "not delivered to the host that failed" — every host reached before it answered with a redirect instead of performing the operation. That is the normal case, but it assumes the server behaves; the classifier cannot see the redirect chain.
Where it does pay off is declarative retry configuration, since Predicate<Throwable> needs no dependency beyond the JDK:
RetryConfig.custom()
// only retry what provably never reached the server
.retryOnException(e -> FailureType.classify(e).getDelivery() == FailureType.Delivery.NOT_DELIVERED)
.build();Widen that to != DELIVERED only for operations that are idempotent, whether by nature or by key.
A connect timeout arrives as HttpConnectTimeoutException and is classified as connect (not delivered); a response timeout arrives as HttpTimeoutException and stays timeout (delivery unknown), because the request may already have been sent.
To keep bodies out of the logs on an endpoint carrying PII, card, or token data, set log-body: false. The body is then not read at all (this is omission, not masking) and the line shows body: (omitted); status, elapsed time, and channel action are still logged.
mido-client:
channels:
payment:
primary:
url: https://api.payment.com
log: console
log-body: false # card numbers and tokens never reach the logTo enable file logging, add a logger named MidoClientFileLog in your logback.xml:
<appender name="MIDO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/mido-client.log</file>
<!-- rolling policy -->
</appender>
<logger name="MidoClientFileLog" level="INFO" additivity="false">
<appender-ref ref="MIDO_FILE"/>
</logger>This project is licensed under the Apache License 2.0 — see the LICENSE file for details.
- Fork the repository
- Create your feature branch (
git checkout -b feature/your-feature) - Commit your changes
- Push to the branch
- Open a Pull Request