From 3781275e59814ac6d9f29c04f6e850e5127df40f Mon Sep 17 00:00:00 2001
From: matevip
Priority: {@code Retry-After} (delta-seconds or HTTP-date) → + * Anthropic RFC-3339 reset instants → OpenAI-style duration resets. For + * multi-bucket reset headers the earliest future instant wins — + * optimistic, because a premature retry just re-records the hint, while + * over-waiting silently costs the user the whole window.
+ */ + static long extractRetryAfterMs(Throwable error) { + for (Throwable cur = error; cur != null; cur = cur.getCause()) { + HttpHeaders headers = null; + if (cur instanceof WebClientResponseException wre) { + headers = wre.getHeaders(); + } else if (cur instanceof RestClientResponseException rre) { + headers = rre.getResponseHeaders(); + } + if (headers == null) continue; + long ms = parseRetryWindowMs(headers); + if (ms > 0) return ms; + } + return 0; + } + + private static long parseRetryWindowMs(HttpHeaders headers) { + String retryAfter = headers.getFirst("retry-after"); + if (retryAfter != null && !retryAfter.isBlank()) { + String v = retryAfter.trim(); + if (v.chars().allMatch(Character::isDigit)) { + return clampHint(Long.parseLong(v) * 1000); + } + try { + long epochMs = ZonedDateTime.parse(v, DateTimeFormatter.RFC_1123_DATE_TIME) + .toInstant().toEpochMilli(); + return clampHint(epochMs - System.currentTimeMillis()); + } catch (DateTimeParseException ignored) { + // fall through to the reset headers + } + } + long best = 0; + for (String name : ANTHROPIC_RESET_HEADERS) { + String v = headers.getFirst(name); + if (v == null || v.isBlank()) continue; + try { + long delta = Instant.parse(v.trim()).toEpochMilli() - System.currentTimeMillis(); + if (delta > 0 && (best == 0 || delta < best)) best = delta; + } catch (DateTimeParseException ignored) { + } + } + if (best > 0) return clampHint(best); + for (String name : OPENAI_RESET_HEADERS) { + long ms = parseGoDurationMs(headers.getFirst(name)); + if (ms > 0 && (best == 0 || ms < best)) best = ms; + } + return best > 0 ? clampHint(best) : 0; + } + + private static long parseGoDurationMs(String value) { + if (value == null || value.isBlank()) return 0; + java.util.regex.Matcher m = GO_DURATION.matcher(value.trim()); + if (!m.matches()) return 0; + long ms = 0; + if (m.group(1) != null) ms += Long.parseLong(m.group(1)) * 3_600_000L; + if (m.group(2) != null) ms += Long.parseLong(m.group(2)) * 60_000L; + if (m.group(3) != null) ms += (long) (Double.parseDouble(m.group(3)) * 1000); + if (m.group(4) != null) ms += Long.parseLong(m.group(4)); + return ms; + } + + private static long clampHint(long ms) { + if (ms <= 0) return 0; + return Math.max(MIN_HINT_MS, Math.min(ms, MAX_HINT_MS)); + } + /** 提取完整异常链信息用于关键字匹配 */ private static String extractFullErrorChain(Throwable error) { StringBuilder sb = new StringBuilder(); @@ -640,6 +790,17 @@ public class NodeStreamingChatHelper { int failoverCount = 0; int llmCallCount = 0; long callStartMs = System.currentTimeMillis(); + // True once the generic routing below has already recorded a health + // failure for this incident — stops the post-loop fallback record from + // double-counting it. + boolean healthRecorded = false; + // Carries the ErrorType behind each null-return retry so the next + // attempt's backoff can be type-aware (see doStreamCall). + AtomicReferenceEach constant carries four policy attributes so the retry loop, the + * fallback-chain router, the pool eviction hook, and the health tracker + * all read one source of truth instead of maintaining parallel + * per-type branch chains:
+ *Two types additionally have side-effectful recovery steps that + * cannot be expressed as attributes and keep explicit branches in the + * loop: {@link #PROMPT_TOO_LONG} (report server-stated window, return to + * node for compaction) and {@link #THINKING_BLOCK_ERROR} (strip stale + * thinking blocks from the prompt, then retry once).
+ */ public enum ErrorType { - /** 无错误 */ - NONE, - /** 速率限制 (429) */ - RATE_LIMIT, - /** 服务端错误 (5xx, timeout) */ - SERVER_ERROR, - /** Prompt 过长 (context length exceeded) */ - PROMPT_TOO_LONG, - /** 认证错误 */ - AUTH_ERROR, - /** 客户端错误 (400 Bad Request, 不支持的格式等) — 不应重试 */ - CLIENT_ERROR, - /** Thinking 块错误(旧消息中的 thinking block 不可修改)— 可剥离后单次重试 */ - THINKING_BLOCK_ERROR, + // retryBudget failsOver evicts countsHealth + /** No error. */ + NONE (0, false, false, false), + /** + * The caller's own key is throttled (HTTP 429). Small retry budget — + * staying on a rate-limited provider wastes time — then fail over. + */ + RATE_LIMIT (MAX_RETRIES_RATE_LIMIT, true, false, true), + /** + * The provider's serving capacity is saturated (HTTP 529, + * "engine_overloaded", "model is overloaded"). The caller's key is + * healthy, so this neither dents provider health (a busy provider is + * not a broken one) nor rotates away eagerly — it waits on the long + * backoff table, then falls over. + */ + OVERLOADED (MAX_RETRIES_OVERLOADED, true, false, false), + /** Transient server / network failure (5xx, timeout, TLS/socket flap). */ + SERVER_ERROR (MAX_RETRIES, true, false, true), + /** + * Context window exceeded. Never retried here — returned to the node, + * which owns structured compaction and its own retry. + */ + PROMPT_TOO_LONG (0, false, false, false), + /** Auth / infrastructure failure (bad key, cert, DNS). Will not self-heal. */ + AUTH_ERROR (0, true, true, true), + /** + * 400-class request-shape error. Fails identically on every provider, + * so neither retried nor failed over — surfaced to the caller. + */ + CLIENT_ERROR (0, false, false, false), + /** + * Stale thinking blocks rejected by the provider. Retried once after + * stripping thinking from the prompt (explicit branch — needs the + * prompt mutation the generic path cannot do). + */ + THINKING_BLOCK_ERROR (1, false, false, false), /** * RFC-009: LLM returned no content, no thinking, and no tool calls. - * Treated as a soft failure — skip same-model retries and hand off to - * the fallback chain directly. Typical cause: upstream rate-limit - * rejection that comes back as HTTP 200 with empty body. + * Typical cause: upstream soft failure surfaced as HTTP 200 with an + * empty body. Retried in the outer loop (it is a result, not an + * exception), then falls over. */ - EMPTY_RESPONSE, + EMPTY_RESPONSE (MAX_RETRIES_EMPTY_RESPONSE, true, false, true), /** * RFC-009 P3.2: payment / billing failure (HTTP 402, "insufficient_quota", * "credit balance is too low", etc.). Distinct from {@link #AUTH_ERROR} * because the right response is to switch provider (a different - * provider may have credits) rather than just terminate. Skips same-model - * retries and falls through to the fallback chain. + * provider may have credits) rather than just terminate. */ - BILLING, + BILLING (0, true, true, true), /** * RFC-009 P3.2: requested model id not recognized by the provider * (HTTP 404, "Model not exist", "model_not_found", DashScope's - * "url error"). Same handling as {@link #BILLING} — heads straight - * to the fallback chain instead of looping retries against a model - * that does not exist. + * "url error"). Model-scoped: heads to the fallback chain but never + * evicts the provider or dents its health — sibling models still work. */ - MODEL_NOT_FOUND, - /** 其他未知错误 */ - UNKNOWN + MODEL_NOT_FOUND (0, true, false, false), + /** + * Unclassifiable. Retried defensively with a conservative budget — + * a transient mis-missed by the keyword patterns is cheaper to retry + * than a lost turn; the wall-clock budget bounds the fatal case. + */ + UNKNOWN (MAX_RETRIES_UNKNOWN, true, false, true); + + private final int retryBudget; + private final boolean failsOver; + private final boolean evictsProvider; + private final boolean countsHealth; + + ErrorType(int retryBudget, boolean failsOver, boolean evictsProvider, boolean countsHealth) { + this.retryBudget = retryBudget; + this.failsOver = failsOver; + this.evictsProvider = evictsProvider; + this.countsHealth = countsHealth; + } + + /** Same-model retry attempts before this type is exhausted (0 = never retried). */ + public int retryBudget() { return retryBudget; } + + /** Whether an exhausted failure hands off to the fallback chain. */ + public boolean failsOver() { return failsOver; } + + /** Whether this failure HARD-removes the provider from the available pool. */ + public boolean evictsProvider() { return evictsProvider; } + + /** Whether this failure counts toward the provider health cooldown tracker. */ + public boolean countsHealth() { return countsHealth; } } /** diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java index 74d2077d..455aa82b 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java @@ -1523,8 +1523,7 @@ public class ChatStreamTracker { /** * RunState 最长无活动时间。从 wall-clock {@code MAX_LIFETIME_MS=30min} - * 切换到 inactivity-based 后默认 30 min — 与 hermes-agent 的 - * {@code gateway_timeout=1800s} 同口径:只要 agent 还在持续产事件 + * 切换到 inactivity-based 后默认 30 min(1800s 空闲超时):只要 agent 还在持续产事件 * (tool call / content delta / phase transition / progress_update), * 就一直活下去,墙钟跑 1 小时 2 小时都可以。只有真正"完全静默 ≥ N 分钟" * 才视为卡死并强制清理。 diff --git a/mateclaw-server/src/main/java/vip/mate/llm/failover/AvailableProviderPool.java b/mateclaw-server/src/main/java/vip/mate/llm/failover/AvailableProviderPool.java index 0d7c4ae9..c6fe6864 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/failover/AvailableProviderPool.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/failover/AvailableProviderPool.java @@ -1,6 +1,7 @@ package vip.mate.llm.failover; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.time.Instant; @@ -17,17 +18,28 @@ import java.util.concurrent.ConcurrentHashMap; *Two state transitions:
*TTL readmission: AUTH_ERROR and BILLING removals are not + * permanent. Users top up balances, aggregator quotas refresh, and providers + * have transient 401 flaps — none of which the process can observe. Each + * removal carries a {@code readmitAtMs} deadline (from + * {@link ProviderHealthProperties}); once it passes, the next + * {@link #contains} check lazily readmits the provider. No pre-readmission + * probe: the first real call is the probe, and a still-broken provider is + * simply re-evicted (self-correcting). INIT_PROBE and MANUAL removals never + * auto-readmit — the former means the configuration itself is broken, the + * latter is explicit operator intent.
+ * *State is process-local; a restart re-runs the init probe. That's * intentional — full distributed coordination is out of scope for v1 * (single-node and desktop deployments are the primary targets).
@@ -46,6 +58,18 @@ public class AvailableProviderPool { */ private final MapWhen {@code cooldownOverrideMs > 0} the provider has stated exactly + * when capacity returns, so the cooldown starts immediately — + * waiting for {@link ProviderHealthProperties#getFailureThreshold} more + * consecutive failures would burn extra calls against a window the + * provider already announced. The override is clamped to + * {@link #MAX_COOLDOWN_OVERRIDE_MS} and never shortens an active + * cooldown.
+ */ + public void recordFailure(String providerId, long cooldownOverrideMs) { if (!props.isEnabled() || providerId == null) return; AtomicLong counter = consecutiveFailures.computeIfAbsent(providerId, k -> new AtomicLong()); long failures = counter.incrementAndGet(); + if (cooldownOverrideMs > 0) { + long clamped = Math.min(cooldownOverrideMs, MAX_COOLDOWN_OVERRIDE_MS); + long cooldownEnd = System.currentTimeMillis() + clamped; + cooldownUntilMs.merge(providerId, cooldownEnd, Math::max); + log.warn("[ProviderHealth] provider={} entering cooldown for {}s (provider-stated retry window)", + providerId, clamped / 1000); + return; + } if (failures >= props.getFailureThreshold()) { long cooldownEnd = System.currentTimeMillis() + props.getCooldownMs(); cooldownUntilMs.put(providerId, cooldownEnd); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorClassificationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorClassificationTest.java index 45c536c5..3fe7b558 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorClassificationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorClassificationTest.java @@ -213,9 +213,13 @@ class ErrorClassificationTest { } @Test - @DisplayName("Gateway-rewritten 400 'model is overloaded' → SERVER_ERROR") - void gatewayOverloadedIsServerError() throws Exception { - assertEquals(NodeStreamingChatHelper.ErrorType.SERVER_ERROR, + @DisplayName("Gateway-rewritten 400 'model is overloaded' → OVERLOADED") + void gatewayOverloadedIsOverloaded() throws Exception { + // Previously SERVER_ERROR. The overload semantic now has its own + // type with patient same-provider backoff — still retryable, still + // fails over after its budget, but no longer dents provider health + // (a busy upstream is not a broken provider). + assertEquals(NodeStreamingChatHelper.ErrorType.OVERLOADED, classify(new RuntimeException("400 Bad Request: model is overloaded, please try again"))); } @@ -286,4 +290,45 @@ class ErrorClassificationTest { classify(new RuntimeException( "PKIX path building failed: unable to find valid certification path to requested target"))); } + + // ===== OVERLOADED (provider capacity saturation) ===== + + @Test + @DisplayName("'engine_overloaded' (even alongside 429) → OVERLOADED, not RATE_LIMIT") + void engineOverloadedIsOverloaded() throws Exception { + // Providers reuse the 429 status for capacity saturation. The more + // specific overload semantic must win over the bare 429 pattern — + // an overloaded provider gets patient backoff, not fast failover. + assertEquals(NodeStreamingChatHelper.ErrorType.OVERLOADED, + classify(new RuntimeException("429 Too Many Requests: engine_overloaded"))); + } + + @Test + @DisplayName("Anthropic 529 'overloaded_error' → OVERLOADED") + void anthropic529IsOverloaded() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.OVERLOADED, + classify(new RuntimeException( + "529 {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"Overloaded\"}}"))); + } + + @Test + @DisplayName("'The model is overloaded' → OVERLOADED, not SERVER_ERROR") + void modelOverloadedIsOverloaded() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.OVERLOADED, + classify(new RuntimeException("The model is overloaded. Please try again later."))); + } + + @Test + @DisplayName("SiliconFlow group-saturation message → OVERLOADED") + void siliconflowSaturationIsOverloaded() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.OVERLOADED, + classify(new RuntimeException("50603: 当前分组上游负载已饱和,请稍后再试"))); + } + + @Test + @DisplayName("Plain 429 without overload wording stays RATE_LIMIT") + void plain429StaysRateLimit() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.RATE_LIMIT, + classify(new RuntimeException("429 Too Many Requests"))); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorTypePolicyTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorTypePolicyTest.java new file mode 100644 index 00000000..5437d3f0 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorTypePolicyTest.java @@ -0,0 +1,79 @@ +package vip.mate.agent.graph; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.agent.graph.NodeStreamingChatHelper.ErrorType; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Table-driven assertions over every {@link ErrorType}'s + * recovery-policy attributes. The retry loop, fallback router, pool eviction + * and health tracker all consume these attributes, so a mis-configured new + * constant would silently change failover behavior — this test makes any + * change to the policy table an explicit, reviewed diff. + */ +class ErrorTypePolicyTest { + + /** One row per constant: {type, retryBudget, failsOver, evictsProvider, countsHealth}. */ + private static final Object[][] POLICY_TABLE = { + {ErrorType.NONE, 0, false, false, false}, + {ErrorType.RATE_LIMIT, 2, true, false, true }, + {ErrorType.OVERLOADED, 5, true, false, false}, + {ErrorType.SERVER_ERROR, 10, true, false, true }, + {ErrorType.PROMPT_TOO_LONG, 0, false, false, false}, + {ErrorType.AUTH_ERROR, 0, true, true, true }, + {ErrorType.CLIENT_ERROR, 0, false, false, false}, + {ErrorType.THINKING_BLOCK_ERROR, 1, false, false, false}, + {ErrorType.EMPTY_RESPONSE, 3, true, false, true }, + {ErrorType.BILLING, 0, true, true, true }, + {ErrorType.MODEL_NOT_FOUND, 0, true, false, false}, + {ErrorType.UNKNOWN, 5, true, false, true }, + }; + + @Test + @DisplayName("Every ErrorType constant appears in the policy table exactly once") + void tableCoversEveryConstant() { + assertEquals(ErrorType.values().length, POLICY_TABLE.length, + "A new ErrorType constant was added without a policy-table row — " + + "add it here so its recovery policy is an explicit, reviewed decision"); + } + + @Test + @DisplayName("Policy attributes match the table") + void attributesMatchTable() { + for (Object[] row : POLICY_TABLE) { + ErrorType t = (ErrorType) row[0]; + assertEquals((int) row[1], t.retryBudget(), t + ".retryBudget"); + assertEquals(row[2], t.failsOver(), t + ".failsOver"); + assertEquals(row[3], t.evictsProvider(), t + ".evictsProvider"); + assertEquals(row[4], t.countsHealth(), t + ".countsHealth"); + } + } + + @Test + @DisplayName("Evicting types always count toward provider health") + void evictingImpliesHealth() { + // A HARD-evicting failure is by definition a provider-level failure; + // an evicting type that skips health tracking would TTL-readmit into + // a tracker that never saw the incident. + for (ErrorType t : ErrorType.values()) { + if (t.evictsProvider()) { + assertTrue(t.countsHealth(), t + " evicts the provider but does not count health"); + } + } + } + + @Test + @DisplayName("OVERLOADED never dents provider health or pool membership") + void overloadedIsHealthNeutral() { + // A saturated provider is busy, not broken: eviction or cooldown would + // take a healthy provider out of the chain exactly when every other + // conversation needs it most. + assertFalse(ErrorType.OVERLOADED.evictsProvider()); + assertFalse(ErrorType.OVERLOADED.countsHealth()); + assertTrue(ErrorType.OVERLOADED.failsOver()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/RetryAfterExtractionTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/RetryAfterExtractionTest.java new file mode 100644 index 00000000..96ec4396 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/RetryAfterExtractionTest.java @@ -0,0 +1,98 @@ +package vip.mate.agent.graph; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Parsing of provider-stated retry windows out of 429/529 + * response headers, straight from the exception chain (both WebFlux and + * RestClient exception shapes carry their response headers). + */ +class RetryAfterExtractionTest { + + private static Throwable ex429(HttpHeaders headers) { + return WebClientResponseException.create( + HttpStatus.TOO_MANY_REQUESTS.value(), "Too Many Requests", + headers, new byte[0], StandardCharsets.UTF_8); + } + + @Test + @DisplayName("Retry-After: 7 (delta-seconds) → 7000ms") + void deltaSeconds() { + HttpHeaders h = new HttpHeaders(); + h.add("Retry-After", "7"); + assertEquals(7_000, NodeStreamingChatHelper.extractRetryAfterMs(ex429(h))); + } + + @Test + @DisplayName("Retry-After: HTTP-date → positive delta") + void httpDate() { + HttpHeaders h = new HttpHeaders(); + ZonedDateTime future = ZonedDateTime.now(ZoneOffset.UTC).plusSeconds(30); + h.add("Retry-After", DateTimeFormatter.RFC_1123_DATE_TIME.format(future)); + long ms = NodeStreamingChatHelper.extractRetryAfterMs(ex429(h)); + assertTrue(ms > 25_000 && ms <= 31_000, "expected ≈30s, got " + ms); + } + + @Test + @DisplayName("Anthropic RFC-3339 reset instants → earliest future delta wins") + void anthropicResetInstant() { + HttpHeaders h = new HttpHeaders(); + h.add("anthropic-ratelimit-requests-reset", Instant.now().plusSeconds(600).toString()); + h.add("anthropic-ratelimit-tokens-reset", Instant.now().plusSeconds(60).toString()); + long ms = NodeStreamingChatHelper.extractRetryAfterMs(ex429(h)); + assertTrue(ms > 55_000 && ms <= 61_000, "earliest bucket (≈60s) must win, got " + ms); + } + + @Test + @DisplayName("OpenAI duration-style x-ratelimit-reset ('6m0s') → 360000ms") + void openaiDurationStyle() { + HttpHeaders h = new HttpHeaders(); + h.add("x-ratelimit-reset-requests", "6m0s"); + assertEquals(360_000, NodeStreamingChatHelper.extractRetryAfterMs(ex429(h))); + } + + @Test + @DisplayName("Sub-second values clamp up to the 1s floor") + void subSecondClampsToFloor() { + HttpHeaders h = new HttpHeaders(); + h.add("x-ratelimit-reset-tokens", "120ms"); + assertEquals(1_000, NodeStreamingChatHelper.extractRetryAfterMs(ex429(h))); + } + + @Test + @DisplayName("Absurdly long Retry-After clamps to the 2h ceiling") + void hugeValueClampsToCeiling() { + HttpHeaders h = new HttpHeaders(); + h.add("Retry-After", String.valueOf(7 * 24 * 3600)); // one week + assertEquals(2 * 60 * 60 * 1000L, NodeStreamingChatHelper.extractRetryAfterMs(ex429(h))); + } + + @Test + @DisplayName("No usable headers → 0 (no hint)") + void noHeadersNoHint() { + assertEquals(0, NodeStreamingChatHelper.extractRetryAfterMs(ex429(new HttpHeaders()))); + assertEquals(0, NodeStreamingChatHelper.extractRetryAfterMs(new RuntimeException("429 plain"))); + } + + @Test + @DisplayName("Hint is found through a wrapping cause chain") + void foundThroughCauseChain() { + HttpHeaders h = new HttpHeaders(); + h.add("Retry-After", "5"); + RuntimeException wrapped = new RuntimeException("provider call failed", ex429(h)); + assertEquals(5_000, NodeStreamingChatHelper.extractRetryAfterMs(wrapped)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerCleanupTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerCleanupTest.java index aefc4cc2..54be6f90 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerCleanupTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerCleanupTest.java @@ -16,9 +16,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; * *Before the fix, a long-running agent that kept producing tool calls * (47-minute LLM-review smoke test, round 6) was killed at the 30-minute - * wall-clock mark mid-task. The new behaviour mirrors hermes-agent's - * {@code gateway_timeout}: only completely idle runs are evicted, the - * actively-producing ones can run as long as they need to. + * wall-clock mark mid-task. The new behaviour is an idle-timeout contract: + * only completely idle runs are evicted, the actively-producing ones can + * run as long as they need to. */ class ChatStreamTrackerCleanupTest { diff --git a/mateclaw-server/src/test/java/vip/mate/llm/failover/AvailableProviderPoolTest.java b/mateclaw-server/src/test/java/vip/mate/llm/failover/AvailableProviderPoolTest.java index f000f0ee..885d85f7 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/failover/AvailableProviderPoolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/failover/AvailableProviderPoolTest.java @@ -150,4 +150,81 @@ class AvailableProviderPoolTest { var snap = pool.snapshot(); assertNotNull(snap); } + + // ===== TTL readmission of HARD-removed providers ===== + + private static AvailableProviderPool poolWithReadmitMs(long billingMs, long authMs) { + ProviderHealthProperties props = new ProviderHealthProperties(); + props.setBillingReadmitMs(billingMs); + props.setAuthReadmitMs(authMs); + return new AvailableProviderPool(props); + } + + @Test + @DisplayName("BILLING removal readmits lazily after its TTL") + void billingReadmitsAfterTtl() throws Exception { + AvailableProviderPool p = poolWithReadmitMs(50, 0); + p.add("openai"); + p.remove("openai", RemovalSource.BILLING, "402 insufficient_quota"); + assertFalse(p.contains("openai"), "still evicted inside the TTL window"); + + Thread.sleep(80); + assertTrue(p.contains("openai"), "TTL expired — contains() must lazily readmit"); + // Readmission clears the removal reason like any add(). + assertNull(p.snapshot().get("openai")); + } + + @Test + @DisplayName("AUTH removal readmits after its own TTL") + void authReadmitsAfterTtl() throws Exception { + AvailableProviderPool p = poolWithReadmitMs(0, 50); + p.add("kimi"); + p.remove("kimi", RemovalSource.AUTH_ERROR, "401"); + assertFalse(p.contains("kimi")); + + Thread.sleep(80); + assertTrue(p.contains("kimi")); + } + + @Test + @DisplayName("INIT_PROBE and MANUAL removals never auto-readmit") + void probeAndManualNeverReadmit() throws Exception { + AvailableProviderPool p = poolWithReadmitMs(1, 1); + p.add("openai"); + p.add("ollama"); + p.remove("openai", RemovalSource.INIT_PROBE, "probe failed"); + p.remove("ollama", RemovalSource.MANUAL, "operator disabled"); + + Thread.sleep(30); + assertFalse(p.contains("openai"), "broken configuration must not silently come back"); + assertFalse(p.contains("ollama"), "explicit operator intent must not expire"); + } + + @Test + @DisplayName("TTL of 0 disables auto-readmission entirely") + void zeroTtlDisablesReadmission() throws Exception { + AvailableProviderPool p = poolWithReadmitMs(0, 0); + p.add("openai"); + p.remove("openai", RemovalSource.BILLING, "402"); + + Thread.sleep(30); + assertFalse(p.contains("openai")); + assertEquals(0, p.snapshot().get("openai").readmitAtMs()); + } + + @Test + @DisplayName("Re-removal after readmission restarts the TTL from the latest incident") + void reRemovalRestartsTtl() throws Exception { + AvailableProviderPool p = poolWithReadmitMs(50, 0); + p.add("openai"); + p.remove("openai", RemovalSource.BILLING, "402 first"); + Thread.sleep(80); + assertTrue(p.contains("openai"), "first TTL expired"); + + // Still broken — the first post-readmission call evicts again. + p.remove("openai", RemovalSource.BILLING, "402 second"); + assertFalse(p.contains("openai"), "fresh removal must start a fresh TTL window"); + Thread.sleep(80); + assertTrue(p.contains("openai"), "second TTL expires independently"); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/llm/failover/ProviderHealthTrackerTest.java b/mateclaw-server/src/test/java/vip/mate/llm/failover/ProviderHealthTrackerTest.java index 97d3b478..1de6c0e0 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/failover/ProviderHealthTrackerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/failover/ProviderHealthTrackerTest.java @@ -114,4 +114,45 @@ class ProviderHealthTrackerTest { assertTrue(snap.get("openai").cooldownRemainingMs() > 0, "cooldown remaining ms must be positive while active"); } + + // ===== Provider-stated cooldown override (Retry-After feedback) ===== + + @Test + @DisplayName("Cooldown override fires immediately, without waiting for the threshold") + void overrideBypassesThreshold() { + tracker.recordFailure("openai", 60_000); + assertTrue(tracker.isInCooldown("openai"), + "one failure with a stated retry window must start cooldown right away"); + long remaining = tracker.snapshot().get("openai").cooldownRemainingMs(); + assertTrue(remaining > 55_000 && remaining <= 60_000, + "cooldown must honor the stated window, got " + remaining); + } + + @Test + @DisplayName("Override clamps to the 2h ceiling") + void overrideClampedToCeiling() { + tracker.recordFailure("openai", 24L * 3600 * 1000); + long remaining = tracker.snapshot().get("openai").cooldownRemainingMs(); + assertTrue(remaining <= ProviderHealthTracker.MAX_COOLDOWN_OVERRIDE_MS, + "override must clamp at 2h, got " + remaining); + } + + @Test + @DisplayName("Override never shortens a longer active cooldown") + void overrideNeverShortens() { + tracker.recordFailure("openai", 3600_000); + tracker.recordFailure("openai", 5_000); + long remaining = tracker.snapshot().get("openai").cooldownRemainingMs(); + assertTrue(remaining > 5_000, + "a later, shorter window must not truncate the active cooldown, got " + remaining); + } + + @Test + @DisplayName("Success clears an override cooldown like any other") + void successClearsOverride() { + tracker.recordFailure("openai", 3600_000); + assertTrue(tracker.isInCooldown("openai")); + tracker.recordSuccess("openai"); + assertFalse(tracker.isInCooldown("openai")); + } }