PaymentTradeResilience.java
package com.ecommerce.payment.infrastructure.resilience;
import com.ecommerce.payment.application.exception.PaymentError;
import com.ecommerce.payment.application.exception.PaymentException;
import com.ecommerce.payment.infrastructure.config.PaymentClientProperties;
import com.ecommerce.payment.infrastructure.config.TradePaymentContextResilienceProperties;
import com.ecommerce.platform.common.transaction.SynchronousBoundaryGuard;
import io.github.resilience4j.bulkhead.Bulkhead;
import io.github.resilience4j.bulkhead.BulkheadConfig;
import io.github.resilience4j.bulkhead.BulkheadFullException;
import io.github.resilience4j.bulkhead.BulkheadRegistry;
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.retry.RetryConfig;
import io.github.resilience4j.retry.RetryRegistry;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.Objects;
import java.util.function.Supplier;
@Component
public final class PaymentTradeResilience {
public static final String INSTANCE_NAME = "paymentTradePaymentContext";
private final CircuitBreaker circuitBreaker;
private final Retry retry;
private final Bulkhead bulkhead;
private final Counter bulkheadRejected;
private final Counter circuitRejected;
public PaymentTradeResilience(
CircuitBreakerRegistry circuitBreakerRegistry,
RetryRegistry retryRegistry,
BulkheadRegistry bulkheadRegistry,
MeterRegistry meterRegistry,
PaymentClientProperties clientProperties,
TradePaymentContextResilienceProperties properties) {
Objects.requireNonNull(circuitBreakerRegistry, "circuitBreakerRegistry");
Objects.requireNonNull(retryRegistry, "retryRegistry");
Objects.requireNonNull(bulkheadRegistry, "bulkheadRegistry");
Objects.requireNonNull(meterRegistry, "meterRegistry");
validateBudget(clientProperties, properties);
RetryConfig retryConfig = RetryConfig.custom()
.maxAttempts(properties.maxAttempts())
.waitDuration(properties.retryWait())
.retryOnException(PaymentTradeResilience::isRetryable)
.build();
CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom()
.slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
.slidingWindowSize(properties.slidingWindowSize())
.minimumNumberOfCalls(properties.minimumNumberOfCalls())
.failureRateThreshold(properties.failureRateThreshold())
.waitDurationInOpenState(properties.openStateWait())
.permittedNumberOfCallsInHalfOpenState(properties.halfOpenCalls())
.recordException(PaymentTradeResilience::isRecordable)
.ignoreException(PaymentTradeResilience::isIgnored)
.build();
BulkheadConfig bulkheadConfig = BulkheadConfig.custom()
.maxConcurrentCalls(properties.maxConcurrentCalls())
.maxWaitDuration(properties.bulkheadMaxWait())
.build();
retry = retryRegistry.retry(INSTANCE_NAME, retryConfig);
circuitBreaker = circuitBreakerRegistry.circuitBreaker(INSTANCE_NAME, circuitBreakerConfig);
bulkhead = bulkheadRegistry.bulkhead(INSTANCE_NAME, bulkheadConfig);
bulkheadRejected = rejectionCounter(meterRegistry, "bulkhead");
circuitRejected = rejectionCounter(meterRegistry, "circuit");
}
public <T> T execute(Supplier<T> remoteCall) {
Objects.requireNonNull(remoteCall, "remoteCall");
SynchronousBoundaryGuard.requireOutsideTransaction(INSTANCE_NAME);
Supplier<T> guarded = Bulkhead.decorateSupplier(bulkhead, remoteCall);
guarded = Retry.decorateSupplier(retry, guarded);
guarded = CircuitBreaker.decorateSupplier(circuitBreaker, guarded);
try {
return guarded.get();
} catch (PaymentException exception) {
throw exception;
} catch (BulkheadFullException exception) {
bulkheadRejected.increment();
throw new PaymentException(PaymentError.REMOTE_DEPENDENCY_UNAVAILABLE, exception);
} catch (CallNotPermittedException exception) {
circuitRejected.increment();
throw new PaymentException(PaymentError.REMOTE_DEPENDENCY_UNAVAILABLE, exception);
} catch (RuntimeException exception) {
throw new PaymentException(PaymentError.REMOTE_DEPENDENCY_UNAVAILABLE, exception);
}
}
private static boolean isRetryable(Throwable throwable) {
return throwable instanceof RemoteDependencyFailure failure && failure.retryable();
}
private static boolean isRecordable(Throwable throwable) {
return throwable instanceof RemoteDependencyFailure failure && failure.recordable();
}
private static boolean isIgnored(Throwable throwable) {
return throwable instanceof BulkheadFullException
|| throwable instanceof RemoteDependencyFailure failure && !failure.recordable();
}
private static void validateBudget(
PaymentClientProperties clientProperties,
TradePaymentContextResilienceProperties properties) {
Duration maximumAttemptTime = clientProperties.connectTimeout().plus(clientProperties.readTimeout());
Duration maximumRetryWait = properties.retryWait().multipliedBy(properties.maxAttempts() - 1L);
Duration configuredWorstCase = maximumAttemptTime
.multipliedBy(properties.maxAttempts())
.plus(maximumRetryWait)
.plus(properties.bulkheadMaxWait().multipliedBy(properties.maxAttempts()));
if (configuredWorstCase.compareTo(properties.totalBudget()) > 0) {
throw new IllegalArgumentException(
"Payment to Trade timeout/retry policy can exceed its total budget: worstCase="
+ configuredWorstCase + ", totalBudget=" + properties.totalBudget());
}
}
private static Counter rejectionCounter(MeterRegistry meterRegistry, String guard) {
return Counter.builder("ecommerce.http.client.resilience.rejections")
.description("Synchronous dependency calls rejected before starting remote I/O")
.tag("service", "payment-service")
.tag("dependency", "trade-service")
.tag("operation", "payment_context")
.tag("guard", guard)
.register(meterRegistry);
}
}