feat(velocity): felis-api 调用改走有界线程池、定时任务防重叠,超时可配置,幂等 GET 带抖动重试一次

This commit is contained in:
Lemon-miaow committed 2026-09-26 00:03:19 +08:00
1 parent 001f060027
commit fcf5c305ea
16 files changed
+872 -37

No files matched your search

@@ -0,0 +1,191 @@
package best.lolicon.felis.link;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
/**
* FelisApiClientRetryTest pins which failures FelisApiClient repeats, against a stub
* felis-api that counts the requests each path receives: a GET is tried once more
* after a connection that dropped mid-reply or a 502/503/504, never a third time; a POST is
* never repeated (the first attempt may have landed); a GET that ran past the
* request timeout is cut off at that timeout and not repeated.
*
* <p>Run: {@code javac -d <out> shared/src/main/java/best/lolicon/felis/link/*.java
* shared/test/best/lolicon/felis/link/FelisApiClientRetryTest.java && java -cp <out>
* best.lolicon.felis.link.FelisApiClientRetryTest}.
*/
public final class FelisApiClientRetryTest {
private static final String READY = "{\"name\":\"alpha\",\"phase\":\"Running\",\"ready\":true}";
private static final Map<String, AtomicInteger> hits = new ConcurrentHashMap<>();
private static int checks;
public static void main(String[] args) throws Exception {
HttpServer stub = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
// The slow handler sleeps; the others must not queue behind it.
ExecutorService pool = Executors.newCachedThreadPool();
stub.setExecutor(pool);
stub.createContext("/", FelisApiClientRetryTest::handle);
stub.start();
try {
String base = "http://127.0.0.1:" + stub.getAddress().getPort();
FelisApiClient api = new FelisApiClient(new LinkConfig(
base, "test-token", Duration.ofSeconds(2), Duration.ofMillis(400)));
getRetriesOnceAfter503(api);
getRetriesOnceAfterACutReply(api);
getGivesUpAfterTheSecondTry(api);
getIsNotRetriedOnAClientError(api);
postIsNeverRetried(api);
timedOutGetIsCutOffAndNotRetried(api);
} finally {
stub.stop(0);
pool.shutdownNow();
}
System.out.println("FelisApiClientRetryTest OK (" + checks + " checks)");
}
// Each server name is one scripted behaviour; the n-th request to it picks the reply.
private static void handle(HttpExchange ex) throws IOException {
String path = ex.getRequestURI().getRawPath();
ex.getRequestBody().readAllBytes();
int n = hits.computeIfAbsent(ex.getRequestMethod() + " " + path, k -> new AtomicInteger()).incrementAndGet();
switch (path) {
case "/api/v1/internal/servers/flaky/status":
reply(ex, n == 1 ? 503 : 200, n == 1 ? error("unavailable") : READY);
return;
case "/api/v1/internal/servers/cut/status":
if (n == 1) {
// Headers promise 1000 bytes, the connection drops after 10: felis-api
// dying mid-reply. (A drop before any response byte is not a usable
// probe: the JDK client resends such a GET on its own.)
ex.getResponseHeaders().set("Content-Type", "application/json");
ex.sendResponseHeaders(200, 1000);
ex.getResponseBody().write("{\"name\":\"a".getBytes(StandardCharsets.UTF_8));
ex.getResponseBody().flush();
ex.close();
return;
}
reply(ex, 200, READY);
return;
case "/api/v1/internal/servers/down/status":
reply(ex, 502, error("bad_gateway"));
return;
case "/api/v1/internal/servers/gone/status":
reply(ex, 404, error("not_found"));
return;
case "/api/v1/internal/servers/busy/wake":
reply(ex, 503, error("at_capacity"));
return;
case "/api/v1/internal/servers/slow/status":
try {
Thread.sleep(1500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
reply(ex, 200, READY);
return;
default:
reply(ex, 500, error("unexpected"));
}
}
private static void getRetriesOnceAfter503(FelisApiClient api) throws LinkException {
ServerView v = api.serverStatus("flaky");
assertEq("flaky phase", "Running", v.phase());
assertEq("flaky requests", 2, count("GET /api/v1/internal/servers/flaky/status"));
}
private static void getRetriesOnceAfterACutReply(FelisApiClient api) throws LinkException {
ServerView v = api.serverStatus("cut");
assertEq("cut phase", "Running", v.phase());
assertEq("cut requests", 2, count("GET /api/v1/internal/servers/cut/status"));
}
private static void getGivesUpAfterTheSecondTry(FelisApiClient api) {
LinkException e = expectFailure("down", () -> api.serverStatus("down"));
assertEq("down status", 502, e.statusCode());
assertEq("down code", "bad_gateway", e.errorCode());
assertEq("down requests", 2, count("GET /api/v1/internal/servers/down/status"));
}
private static void getIsNotRetriedOnAClientError(FelisApiClient api) {
LinkException e = expectFailure("gone", () -> api.serverStatus("gone"));
assertEq("gone status", 404, e.statusCode());
assertEq("gone requests", 1, count("GET /api/v1/internal/servers/gone/status"));
}
private static void postIsNeverRetried(FelisApiClient api) {
UUID id = UUID.fromString("00000000-0000-0000-0000-00000000000a");
LinkException e = expectFailure("busy", () -> api.wake("busy", id));
assertEq("busy status", 503, e.statusCode());
assertEq("busy code", "at_capacity", e.errorCode());
assertEq("busy requests", 1, count("POST /api/v1/internal/servers/busy/wake"));
}
private static void timedOutGetIsCutOffAndNotRetried(FelisApiClient api) {
long start = System.nanoTime();
LinkException e = expectFailure("slow", () -> api.serverStatus("slow"));
long tookMillis = (System.nanoTime() - start) / 1_000_000;
assertEq("slow status", 0, e.statusCode());
assertEq("slow code", "transport_error", e.errorCode());
// 400 ms request timeout against a 1500 ms handler: well under the handler's
// time, which a missing or ignored request timeout would have to wait out.
if (tookMillis >= 1200) {
throw new AssertionError("slow: took " + tookMillis + " ms, want the 400 ms request timeout");
}
checks++;
assertEq("slow requests", 1, count("GET /api/v1/internal/servers/slow/status"));
}
// ---- harness ----
interface Call {
void run() throws LinkException;
}
private static LinkException expectFailure(String what, Call call) {
try {
call.run();
} catch (LinkException e) {
return e;
}
throw new AssertionError(what + ": call succeeded, want a LinkException");
}
private static int count(String key) {
AtomicInteger n = hits.get(key);
return n == null ? 0 : n.get();
}
private static String error(String code) {
return "{\"error\":{\"code\":\"" + code + "\",\"message\":\"stub\"}}";
}
private static void reply(HttpExchange ex, int status, String body) throws IOException {
byte[] b = body.getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().set("Content-Type", "application/json");
ex.sendResponseHeaders(status, b.length);
try (OutputStream os = ex.getResponseBody()) {
os.write(b);
}
}
private static void assertEq(String what, Object want, Object got) {
if (!want.equals(got)) {
throw new AssertionError(what + ": got " + got + ", want " + want);
}
checks++;
}
}
@@ -0,0 +1,166 @@
package best.lolicon.felis.link;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Stream;
/**
* LinkConfigLoaderTest checks how felis-link.properties and the environment combine:
* the environment wins, both required values must come from somewhere, a first run
* leaves a template and still refuses to start, and the call timeouts default to
* 10 s, take the operator's value, and refuse anything that is not a whole number
* of seconds from 1 to 120.
*
* <p>Run: {@code javac -d <out> shared/src/main/java/best/lolicon/felis/link/*.java
* shared/test/best/lolicon/felis/link/LinkConfigLoaderTest.java && java -cp <out>
* best.lolicon.felis.link.LinkConfigLoaderTest}.
*/
public final class LinkConfigLoaderTest {
private static int checks;
private static Path dir;
public static void main(String[] args) throws Exception {
dir = Files.createTempDirectory("felis-link-test");
try {
fileAloneIsEnough();
environmentWinsOverTheFile();
missingTokenIsRefused();
firstRunWritesATemplateAndRefuses();
timeoutsComeFromFileOrEnvironment();
badTimeoutsAreRefused();
} finally {
try (Stream<Path> walk = Files.walk(dir)) {
walk.sorted(Comparator.reverseOrder()).forEach(p -> p.toFile().delete());
}
}
System.out.println("LinkConfigLoaderTest OK (" + checks + " checks)");
}
private static void fileAloneIsEnough() throws IOException {
Path f = write("plain.properties",
"api-base-url=http://10.43.0.10:8081/\nservice-token=file-token\n");
LinkConfig c = LinkConfigLoader.load(f, env());
assertEq("file url (trailing slash stripped)", "http://10.43.0.10:8081", c.apiBaseUrl());
assertEq("file token", "file-token", c.serviceToken());
assertEq("default connect timeout", Duration.ofSeconds(10), c.connectTimeout());
assertEq("default request timeout", Duration.ofSeconds(10), c.requestTimeout());
}
private static void environmentWinsOverTheFile() throws IOException {
Path f = write("both.properties",
"api-base-url=http://file:8081\nservice-token=file-token\n");
LinkConfig c = LinkConfigLoader.load(f, env(
"FELIS_API_BASE_URL", "http://env:8081",
"FELIS_SERVICE_TOKEN", "env-token"));
assertEq("env url", "http://env:8081", c.apiBaseUrl());
assertEq("env token", "env-token", c.serviceToken());
// A blank variable is unset, not an override to empty.
LinkConfig blank = LinkConfigLoader.load(f, env("FELIS_SERVICE_TOKEN", " "));
assertEq("blank env token falls back", "file-token", blank.serviceToken());
}
private static void missingTokenIsRefused() throws IOException {
Path f = write("no-token.properties", "api-base-url=http://10.43.0.10:8081\n");
IOException e = expectRefused("no token", f, env());
assertContains("no token message", e.getMessage(), "service-token");
}
private static void firstRunWritesATemplateAndRefuses() throws IOException {
Path f = dir.resolve("fresh/felis-link.properties");
expectRefused("first run", f, env());
String template = new String(Files.readAllBytes(f), StandardCharsets.UTF_8);
assertContains("template url key", template, "\napi-base-url=\n");
assertContains("template token key", template, "\nservice-token=\n");
assertContains("template connect timeout", template, "\n#connect-timeout-seconds=10\n");
assertContains("template request timeout", template, "\n#request-timeout-seconds=10\n");
// The template's commented timeouts leave the defaults in force once filled in.
Files.write(f, (template.replace("\napi-base-url=\n", "\napi-base-url=http://x:8081\n")
.replace("\nservice-token=\n", "\nservice-token=t\n")).getBytes(StandardCharsets.UTF_8));
LinkConfig c = LinkConfigLoader.load(f, env());
assertEq("filled template connect timeout", Duration.ofSeconds(10), c.connectTimeout());
assertEq("filled template request timeout", Duration.ofSeconds(10), c.requestTimeout());
}
private static void timeoutsComeFromFileOrEnvironment() throws IOException {
Path f = write("timeouts.properties", "api-base-url=http://x:8081\nservice-token=t\n"
+ "connect-timeout-seconds=3\nrequest-timeout-seconds= 25 \n");
LinkConfig c = LinkConfigLoader.load(f, env());
assertEq("file connect timeout", Duration.ofSeconds(3), c.connectTimeout());
assertEq("file request timeout", Duration.ofSeconds(25), c.requestTimeout());
LinkConfig e = LinkConfigLoader.load(f, env(
"FELIS_API_CONNECT_TIMEOUT_SECONDS", "1",
"FELIS_API_REQUEST_TIMEOUT_SECONDS", "120"));
assertEq("env connect timeout", Duration.ofSeconds(1), e.connectTimeout());
assertEq("env request timeout", Duration.ofSeconds(120), e.requestTimeout());
}
private static void badTimeoutsAreRefused() throws IOException {
String base = "api-base-url=http://x:8081\nservice-token=t\n";
String[][] bad = {
{"connect-timeout-seconds", "0"},
{"connect-timeout-seconds", "-5"},
{"connect-timeout-seconds", "ten"},
{"request-timeout-seconds", "121"},
{"request-timeout-seconds", "2.5"},
{"request-timeout-seconds", "10s"},
};
for (String[] kv : bad) {
Path f = write("bad.properties", base + kv[0] + "=" + kv[1] + "\n");
IOException e = expectRefused(kv[0] + "=" + kv[1], f, env());
assertContains(kv[0] + "=" + kv[1] + " names the key", e.getMessage(), kv[0]);
assertContains(kv[0] + "=" + kv[1] + " quotes the value", e.getMessage(), "\"" + kv[1] + "\"");
}
Path f = write("env-bad.properties", base);
IOException e = expectRefused("env request timeout 0", f, env("FELIS_API_REQUEST_TIMEOUT_SECONDS", "0"));
assertContains("env bad names the variable", e.getMessage(), "FELIS_API_REQUEST_TIMEOUT_SECONDS");
}
// ---- harness ----
private static java.util.function.UnaryOperator<String> env(String... kv) {
Map<String, String> m = new HashMap<>();
for (int i = 0; i < kv.length; i += 2) {
m.put(kv[i], kv[i + 1]);
}
return m::get;
}
private static Path write(String name, String body) throws IOException {
Path f = dir.resolve(name);
Files.write(f, body.getBytes(StandardCharsets.UTF_8));
return f;
}
private static IOException expectRefused(String what, Path f, java.util.function.UnaryOperator<String> env) {
try {
LinkConfigLoader.load(f, env);
} catch (IOException e) {
checks++;
return e;
}
throw new AssertionError(what + ": loaded, want an IOException");
}
private static void assertContains(String what, String got, String want) {
if (got == null || !got.contains(want)) {
throw new AssertionError(what + ": " + got + " does not contain " + want);
}
checks++;
}
private static void assertEq(String what, Object want, Object got) {
if (!want.equals(got)) {
throw new AssertionError(what + ": got " + got + ", want " + want);
}
checks++;
}
}