fix(密钥检测): 直连失败重试一次,超时/网络提示中文化

上游 ai.t8star.org 实测约 1/8 单请求完全不应答(主机 A 上 JDK 客户端
HTTP/1.1 与 HTTP/2 均复现),检测只有一次机会时用户会看到
「网络不可达:HttpTimeoutException: request timed out」。

- 传输层失败(超时/网络不可达)对直连最后一跳重试一次(800ms 间隔);
  代理模块不重试——jikip 按提取次数计费,重试会多扣一次
- 失败文案中文化(新增 CODE_TIMEOUT),英文异常串只进服务端日志
- 探测提问改「你好」(最简一次调用)
- 检测面板标明检测对象(配置密钥 sk-**** / 输入值(未保存)),
  检测接口超时单独放宽(客户端 60s / 后台 180s),避免重试期间前端先超时
This commit is contained in:
2026-09-14 00:28:35 +08:00
parent fd614004b0
commit 864c22ffc7
5 changed files with 175 additions and 36 deletions
@@ -137,6 +137,67 @@ class UserApiSecretCheckServiceTest {
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_SERVER_ERROR);
}
// ===== 传输层失败分类(2026-09-13:面板不再出现英文异常串,超时单列 code)=====
@Test
void transportTimeoutIsClassifiedAsTimeoutWithChineseMessage() {
// 生产形态:ResourceAccessException 包装 HttpTimeoutException
Exception failure = new org.springframework.web.client.ResourceAccessException(
"I/O error on POST request", new java.net.http.HttpTimeoutException("request timed out"));
UserApiSecretCheckService.CheckOutcome outcome = service.classifyTransportFailure(failure, 15001, false);
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_ERROR);
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_TIMEOUT);
assertThat(outcome.message()).isEqualTo(UserApiSecretCheckService.TIMEOUT_MESSAGE);
assertThat(outcome.message()).doesNotContain("HttpTimeoutException");
assertThat(outcome.latencyMs()).isEqualTo(15001);
}
@Test
void concurrentTimeoutExceptionAlsoClassifiedAsTimeout() {
UserApiSecretCheckService.CheckOutcome outcome = service.classifyTransportFailure(
new java.util.concurrent.TimeoutException("timed out"), 100, true);
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_TIMEOUT);
assertThat(outcome.viaProxy()).isTrue();
}
@Test
void transportConnectFailureIsClassifiedAsNetworkError() {
UserApiSecretCheckService.CheckOutcome outcome = service.classifyTransportFailure(
new java.net.ConnectException("Connection refused"), 300, true);
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_ERROR);
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_NETWORK_ERROR);
assertThat(outcome.message()).isEqualTo(UserApiSecretCheckService.NETWORK_ERROR_MESSAGE);
assertThat(outcome.viaProxy()).isTrue();
assertThat(outcome.message()).doesNotContain("ConnectException");
}
@Test
void transportNestedTimeoutInsideGenericIOExceptionIsTimeout() {
Exception failure = new java.io.IOException("boom", new java.util.concurrent.TimeoutException("timed out"));
UserApiSecretCheckService.CheckOutcome outcome = service.classifyTransportFailure(failure, 200, false);
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_TIMEOUT);
}
/** 只有传输层失败值得重试;HTTP 层的 401/429/5xx 重试无意义。 */
@Test
void onlyTransportFailuresAreRetryable() {
assertThat(UserApiSecretCheckService.isTransportFailure(outcome("error", UserApiSecretCheckService.CODE_TIMEOUT))).isTrue();
assertThat(UserApiSecretCheckService.isTransportFailure(outcome("error", UserApiSecretCheckService.CODE_NETWORK_ERROR))).isTrue();
assertThat(UserApiSecretCheckService.isTransportFailure(outcome("error", UserApiSecretCheckService.CODE_RATE_LIMITED))).isFalse();
assertThat(UserApiSecretCheckService.isTransportFailure(outcome("error", UserApiSecretCheckService.CODE_SERVER_ERROR))).isFalse();
assertThat(UserApiSecretCheckService.isTransportFailure(outcome("failed", UserApiSecretCheckService.CODE_INVALID_KEY))).isFalse();
assertThat(UserApiSecretCheckService.isTransportFailure(outcome("passed", UserApiSecretCheckService.CODE_OK))).isFalse();
assertThat(UserApiSecretCheckService.isTransportFailure(null)).isFalse();
}
private static UserApiSecretCheckService.CheckOutcome outcome(String status, String code) {
return new UserApiSecretCheckService.CheckOutcome(status, code, "msg", 100, false);
}
// ===== 代理配置探测(提取链接语义;2026-09-13 修复「直连自家站点假通过」)=====
@Test