fix(lingxing): allow bounded configurable business request pacing

This commit is contained in:
2026-10-13 11:46:57 +08:00
parent 6e9466d0b4
commit 39f914b3b5
4 changed files with 109 additions and 5 deletions
@@ -0,0 +1,60 @@
package com.baiyue.integration.config;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.CollectionMode;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
class LingxingEgressPropertiesTest {
private LingxingEgressProperties properties(CollectionMode mode, Duration interval, Duration cooldown) {
return new LingxingEgressProperties(mode, "test", null, null, null, null, interval, cooldown);
}
@Test void businessAcceptsPositiveMillisecondIntervalsIncludingProbeSteps() {
for (long millis : new long[]{1, 1500, 2000, 3000, 5000, 300000}) {
assertThat(properties(CollectionMode.BUSINESS, Duration.ofMillis(millis), null).minStartInterval())
.isEqualTo(Duration.ofMillis(millis));
}
}
@Test void businessRejectsSubMillisecondNonPositiveAndExcessiveIntervals() {
for (Duration interval : List.of(Duration.ofMillis(-1), Duration.ZERO,
Duration.ofNanos(999999), Duration.ofMinutes(5).plusMillis(1))) {
assertThatThrownBy(() -> properties(CollectionMode.BUSINESS, interval, null))
.isInstanceOf(IllegalArgumentException.class);
}
}
@Test void validationRetainsFiveSecondMinimumAndAllModesDefaultToFiveSeconds() {
for (CollectionMode mode : CollectionMode.values()) {
assertThat(properties(mode, null, null).minStartInterval()).isEqualTo(Duration.ofSeconds(5));
if (mode == CollectionMode.VALIDATION) {
assertThatThrownBy(() -> properties(mode, Duration.ofMillis(4999), null))
.isInstanceOf(IllegalArgumentException.class);
assertThat(properties(mode, Duration.ofSeconds(5), null).minStartInterval())
.isEqualTo(Duration.ofSeconds(5));
}
}
assertThat(properties(null, null, null).collectionMode()).isEqualTo(CollectionMode.OFF);
}
@Test void switchingBusinessOffCanRetainItsThreeSecondInterval() {
var business = properties(CollectionMode.BUSINESS, Duration.ofSeconds(3), null);
assertThat(properties(CollectionMode.OFF, business.minStartInterval(), null).minStartInterval())
.isEqualTo(Duration.ofSeconds(3));
assertThatThrownBy(() -> properties(CollectionMode.OFF, Duration.ZERO, null))
.isInstanceOf(IllegalArgumentException.class);
}
@Test void businessIntervalChangeDoesNotRelaxCooldownBounds() {
for (Duration cooldown : List.of(Duration.ofMillis(4999), Duration.ofDays(1).plusMillis(1))) {
assertThatThrownBy(() -> properties(CollectionMode.BUSINESS, Duration.ofMillis(1), cooldown))
.isInstanceOf(IllegalArgumentException.class);
}
for (Duration cooldown : List.of(Duration.ofSeconds(5), Duration.ofDays(1))) {
assertThat(properties(CollectionMode.BUSINESS, Duration.ofMillis(1), cooldown).cooldownFallback())
.isEqualTo(cooldown);
}
}
}
@@ -13,6 +13,40 @@ import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertThrows;
class LingxingEgressGateTest {
@Test void debugTimingRecordsActualCallAndCompletionWithoutContextOrPayload() {
var logger = (ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory.getLogger(LingxingEgressGate.class);
var previous = logger.getLevel();
var appender = new ch.qos.logback.core.read.ListAppender<ch.qos.logback.classic.spi.ILoggingEvent>();
appender.start(); logger.addAppender(appender); logger.setLevel(ch.qos.logback.classic.Level.DEBUG);
try {
var gate = gate();
Object body = new Object();
assertThat(gate.<Object>execute(context("private-attempt"), Operation.OUTBOUND_DETAIL, HASH, handle -> {
handle.startHttp(); clock.advanceMillis(123); return body;
})).isSameAs(body);
assertThat(appender.list).hasSize(1);
assertThat(appender.list.get(0).getFormattedMessage())
.contains("operation=OUTBOUND_DETAIL", "start=" + NOW, "end=" + NOW.plusMillis(123),
"upstreamStatus=200", "errorCode=null", "completionConfirmed=true")
.doesNotContain("demo-account", "private-attempt", "demo-grant", HASH);
ledger.failComplete = true;
var failure = assertThrows(LingxingWmsException.class, () -> gate.execute(context("private-failure"),
Operation.OUTBOUND_DETAIL, HASH, handle -> {
handle.startHttp(); clock.advanceMillis(50); throw rateLimit();
}));
assertThat(failure.code()).isEqualTo("LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED");
assertThat(appender.list).hasSize(2);
assertThat(appender.list.get(1).getFormattedMessage())
.contains("upstreamStatus=429", "errorCode=RATE_LIMIT", "completionConfirmed=false")
.doesNotContain("private-failure", "synthetic rate limit", "demo-account", HASH);
logger.setLevel(ch.qos.logback.classic.Level.INFO);
ledger.failComplete = false;
gate().execute(context("debug-disabled"), Operation.WAREHOUSES, HASH, handle -> body);
assertThat(appender.list).hasSize(2);
} finally {
logger.detachAppender(appender); appender.stop(); logger.setLevel(previous);
}
}
static final Instant NOW = Instant.parse("2026-10-12T00:00:00Z");
static final String HASH = "a".repeat(64);
private final MutableClock clock = new MutableClock(NOW);
@@ -159,7 +193,7 @@ class LingxingEgressGateTest {
} finally { release.countDown(); executor.shutdownNow(); }
}
@Test void intervalCannotBeConfiguredBelowFiveSecondsAndPropertiesHideGrant() {
assertThatThrownBy(() -> new LingxingEgressProperties(null, null, null, null, null, null, Duration.ofMillis(4999), null)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new LingxingEgressProperties(CollectionMode.VALIDATION, null, null, null, null, null, Duration.ofMillis(4999), null)).isInstanceOf(IllegalArgumentException.class);
assertThat(props(CollectionMode.VALIDATION, "test").toString()).doesNotContain("demo-grant", "demo-account", "127.0.0.1");
}
@Test void knownBudgetDenialDoesNotPermanentlyPauseOrConsumeHttp() {