Skip to content

Repository files navigation

Spring Mido Client

Maven Central License

한국어 | 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.

Why mido-client?

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

Features

  • Multi-channel support — define unlimited external API channels, each with primary / secondary dual endpoint
  • Automatic client caching — one RestClient instance per channel/endpoint, thread-safe via ConcurrentHashMap
  • 4-level built-in loggingoff / 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 ClientHttpRequestInterceptor in 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-size skip threshold; response auto-decompression with decompression-bomb defense cap (max-decompressed-size)
  • Per-channel content type — pick json (default) or xml per channel; the request Content-Type header is set automatically
  • Fail-fast configuration validation@Validated Bean Validation rejects malformed YAML at startup with a BindValidationException indicating 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: true property

Requirements

Requirement Version
Java 25
Spring Boot 3.5.x (built & tested with 3.5.16)
Gradle 8.14.4

Java 25 is required because ChannelContext is built on the final java.lang.ScopedValue API (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.

Quick Start

1. Add Dependency

via JitPack (GitHub)

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.

via Maven Central (published release)

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>

2. Configure application.yml

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

3. Use in Your Service

@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 clears ChannelContext around each call, including on exception.

Configuration Reference

Channel (mido-client.channels.<name>)

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

Endpoint (primary / secondary)

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)

Global

Property Type Default Description
mido-client.enabled Boolean false Enable/disable the entire library

Configuration Validation

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:

  • url is blank or doesn't match ^https?://.+
  • read-timeout-seconds or connect-timeout-seconds is zero or negative
  • gzip.min-size is negative
  • gzip.max-decompressed-size is zero or negative
  • headers[].name or headers[].value is blank
  • A channel is missing its required primary endpoint
  • type is explicitly set to null (must be json or xml; 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:

  • charset names 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.

Advanced Usage

Custom Interceptors

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 reflectively

Bean 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:
  - paymentMetricsInterceptor

The 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);
    }
}

Resolution order

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 @Component you wrote was a different object than the one mido-client used — but it is a behavior change. List the class name of a non-bean class to keep the old behavior.

Bean lookup is deferred to the first request

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.

Resilience (Rate Limiter / Circuit Breaker / Retry)

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 one

Tips:

  • 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 final registries above keep the example dependency-free. If you add the resilience4j-spring-boot3 starter, declare the interceptor as a bean instead, inject CircuitBreakerRegistry / 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.

Channel Content Type (JSON / XML)

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.com

Behavior:

  • type: json (default) — Content-Type: application/json is attached to every request; POJO bodies are serialized via Jackson.
  • type: xmlContent-Type: application/xml is attached to every request. A pre-serialized XML String body always works. POJO ↔ XML depends on your classpath: mido-client keeps RestClient's default converter list (only the String converter is replaced, to apply the channel charset), so adding jackson-dataformat-xml to your application enables MappingJackson2XmlHttpMessageConverter. It is not a mido-client dependency.

Gzip Compression

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 cap

Behavior:

  • request: true — bodies ≥ min-size bytes are gzipped before sending; Content-Encoding: gzip is added and Content-Length is updated to the compressed length. An empty body is never compressed, even with min-size: 0, so a bodyless GET does not pick up a gzip header.
  • response: trueAccept-Encoding: gzip is sent; if the server replies with Content-Encoding: gzip, the body is transparently decompressed before reaching your message converters.
  • max-decompressed-size defends against decompression bombs — if the decompressed response exceeds the cap, an IOException is 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

⚠️ Custom interceptors run outermost, so they see the request body before compression. An interceptor that signs or hashes the body (HMAC, checksum, content-digest header) computes over the plain bytes while the server receives the gzipped ones — the check fails on the server side. On a channel with gzip.request: true, either compute the signature over the compressed body yourself inside the interceptor, or leave request compression off for that channel.

HTTP Transport

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".

⚠️ Responses are buffered in memory, not streamed. Every transport is wrapped in 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.

ChannelContext & MDC

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>

Declarative binding: @ChannelName + @ChannelAction

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'

⚠️ Proxy-based, with the usual limits. The advice runs only on external calls through the Spring proxy. It does not apply to a call from another method of the same bean (self-invocation), to 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.

Which one should I use?

@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.

Logging

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 log

To 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>

License

This project is licensed under the Apache License 2.0 — see the LICENSE file for details.

Contributing

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/your-feature)
  3. Commit your changes
  4. Push to the branch
  5. Open a Pull Request

About

Custom RestClient Auto-configuration (YAML/Properties)

Topics

Resources

Stars

6 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages