Files
baiyue-firstleg/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFeignConfiguration.java
T
2026-09-29 18:47:37 +08:00

65 lines
3.6 KiB
Java

package com.baiyue.common.web;
import feign.Request;
import feign.Capability;
import feign.Retryer;
import feign.codec.Decoder;
import feign.codec.DecodeException;
import com.baiyue.common.core.api.ApiResponse;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.lang.reflect.ParameterizedType;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import java.util.concurrent.TimeUnit;
/** Explicitly selected by reliable event clients; never registered as a global configuration. */
public class EventDeliveryFeignConfiguration {
@Bean
public DeliveryTransportCapability eventDeliveryCapability(
@Value("${baiyue.event-delivery.connect-timeout-ms:3000}") int connectTimeout,
@Value("${baiyue.event-delivery.read-timeout-ms:15000}") int readTimeout,
@Value("${baiyue.event-delivery.lease-seconds:60}") long leaseSeconds) {
if (connectTimeout < 1 || readTimeout < 1 || leaseSeconds < 1
|| leaseSeconds > Integer.MAX_VALUE || (long) connectTimeout + readTimeout >= leaseSeconds * 1000) {
throw new IllegalArgumentException("Event delivery timeouts must fit inside the lease");
}
return new DeliveryTransportCapability(new Request.Options(connectTimeout, TimeUnit.MILLISECONDS, readTimeout, TimeUnit.MILLISECONDS, false));
}
/** Applied at Feign build time after global/client properties, without changing other clients. */
public static final class DeliveryTransportCapability implements Capability {
private static final ObjectMapper RESPONSE_JSON = new ObjectMapper()
.enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION)
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
private final Request.Options options;
public DeliveryTransportCapability(Request.Options options) { this.options = options; }
@Override public Request.Options enrich(Request.Options ignored) { return options; }
@Override public Retryer enrich(Retryer ignored) {
// The persisted Outbox owns the retry budget.
return Retryer.NEVER_RETRY;
}
@Override public Decoder enrich(Decoder decoder) {
return (response, type) -> {
if (type instanceof ParameterizedType parameterized && parameterized.getRawType() == ApiResponse.class) {
// Jackson normally coerces a numeric 200 into String. Inspect the wire value first.
if (response.body() == null) throw new DecodeException(response.status(), "Missing event response", response.request());
byte[] bytes;
try (var stream = response.body().asInputStream()) { bytes = stream.readNBytes(65537); }
if (bytes.length > 65536) throw new DecodeException(response.status(), "Event response exceeds limit", response.request());
try {
var root = RESPONSE_JSON.readTree(bytes);
if (root == null || !root.isObject() || !root.path("code").isTextual()) {
throw new IllegalArgumentException();
}
} catch (Exception invalid) {
throw new DecodeException(response.status(), "Invalid event response contract", response.request());
}
return decoder.decode(response.toBuilder().body(bytes).build(), type);
}
return decoder.decode(response, type);
};
}
}
}