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

@@ -6,11 +6,14 @@ import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
import java.util.regex.Pattern;
/**
@@ -43,6 +46,11 @@ import java.util.regex.Pattern;
public final class FelisApiClient {
// Mirrors internal/naming.serverNameRE plus its no-leading/trailing-dash rule.
private static final Pattern SERVER_NAME = Pattern.compile("^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$");
// Answers a second GET can outlive: felis-api restarting behind its Service, or a
// gateway with no ready endpoint.
private static final Set<Integer> RETRY_STATUSES = Set.of(502, 503, 504);
private static final int RETRY_PAUSE_MIN_MILLIS = 100;
private static final int RETRY_PAUSE_JITTER_MILLIS = 300;
private final LinkConfig config;
private final HttpClient http;
@@ -50,7 +58,7 @@ public final class FelisApiClient {
public FelisApiClient(LinkConfig config) {
this.config = Objects.requireNonNull(config, "config");
this.http = HttpClient.newBuilder()
.connectTimeout(config.timeout())
.connectTimeout(config.connectTimeout())
.build();
}
@@ -261,8 +269,34 @@ public final class FelisApiClient {
return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
}
// getObject tries a GET a second time, after a jittered 100-400 ms pause, when the
// first attempt failed in a way a retry can fix: the connection was refused or
// reset (felis-api restarting, its pod moving) or the answer was 502/503/504. GETs
// only read, so repeating one is safe; POSTs (wake, claim, join-event, approvals)
// are never repeated, because the first attempt may have landed. A GET that timed
// out is not repeated either: felis-api is then slow rather than gone, and a second
// full wait would hold the caller twice as long while adding load to an API that
// is already behind. The jitter keeps a proxy's queued callers from retrying in
// one burst.
private Map<?, ?> getObject(String path, int expect) throws LinkException {
HttpRequest req = base(path).GET().build();
try {
HttpResponse<String> res = exchange(req);
if (!RETRY_STATUSES.contains(res.statusCode())) {
return expectObject(res, expect);
}
} catch (HttpTimeoutException e) {
throw transportError(e);
} catch (IOException e) {
// refused or reset: retried below
} catch (InterruptedException e) {
throw interrupted(e);
}
try {
Thread.sleep(RETRY_PAUSE_MIN_MILLIS + ThreadLocalRandom.current().nextInt(RETRY_PAUSE_JITTER_MILLIS + 1));
} catch (InterruptedException e) {
throw interrupted(e);
}
return expectObject(send(req), expect);
}
@@ -289,23 +323,37 @@ public final class FelisApiClient {
}
return HttpRequest.newBuilder()
.uri(uri)
.timeout(config.timeout())
.timeout(config.requestTimeout())
.header("Authorization", "Bearer " + config.serviceToken())
.header("Accept", "application/json");
}
private HttpResponse<String> send(HttpRequest req) throws LinkException {
try {
return http.send(req, HttpResponse.BodyHandlers.ofString());
return exchange(req);
} catch (IOException e) {
throw new LinkException(0, "transport_error",
"could not reach felis-api: " + e.getMessage(), e);
throw transportError(e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LinkException(0, "interrupted", "felis-api request interrupted", e);
throw interrupted(e);
}
}
private HttpResponse<String> exchange(HttpRequest req) throws IOException, InterruptedException {
return http.send(req, HttpResponse.BodyHandlers.ofString());
}
// A refused connection arrives as a ConnectException with no message; the class
// name keeps the log line from reading "could not reach felis-api: null".
private static LinkException transportError(IOException e) {
String why = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
return new LinkException(0, "transport_error", "could not reach felis-api: " + why, e);
}
private static LinkException interrupted(InterruptedException e) {
Thread.currentThread().interrupt();
return new LinkException(0, "interrupted", "felis-api request interrupted", e);
}
private Map<?, ?> expectObject(HttpResponse<String> res, int expect) throws LinkException {
int status = res.statusCode();
if (status != expect) {
@@ -40,7 +40,7 @@ public final class LinkClient {
public LinkClient(LinkConfig config) {
this.config = Objects.requireNonNull(config, "config");
this.http = HttpClient.newBuilder()
.connectTimeout(config.timeout())
.connectTimeout(config.connectTimeout())
.build();
}
@@ -50,7 +50,7 @@ public final class LinkClient {
String body = "{\"mc_uuid\":\"" + mcUuid + "\"}";
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(config.apiBaseUrl() + PATH))
.timeout(config.timeout())
.timeout(config.requestTimeout())
.header("Authorization", "Bearer " + config.serviceToken())
.header("Content-Type", "application/json")
.header("Accept", "application/json")
@@ -10,29 +10,39 @@ import java.util.Objects;
* and are <em>never</em> compiled in. Keeping them out of source is what lets the
* tree stay domain- and credential-free; each platform's config loader is
* responsible for sourcing them.
*
* <p>Two timeouts bound each call: {@code connectTimeout} for opening the TCP
* connection and {@code requestTimeout} for the whole exchange once it is open. A
* felis-api that is gone fails fast on the first; one that is slow is cut off by the
* second, so a blocked call cannot hold a plugin thread indefinitely.
*/
public final class LinkConfig {
private final String apiBaseUrl;
private final String serviceToken;
private final Duration timeout;
private final Duration connectTimeout;
private final Duration requestTimeout;
public LinkConfig(String apiBaseUrl, String serviceToken, Duration timeout) {
public LinkConfig(String apiBaseUrl, String serviceToken, Duration connectTimeout, Duration requestTimeout) {
this.apiBaseUrl = stripTrailingSlash(Objects.requireNonNull(apiBaseUrl, "apiBaseUrl"));
this.serviceToken = Objects.requireNonNull(serviceToken, "serviceToken");
this.timeout = Objects.requireNonNull(timeout, "timeout");
this.connectTimeout = Objects.requireNonNull(connectTimeout, "connectTimeout");
this.requestTimeout = Objects.requireNonNull(requestTimeout, "requestTimeout");
if (this.apiBaseUrl.isEmpty()) {
throw new IllegalArgumentException("apiBaseUrl is empty");
}
if (this.serviceToken.isEmpty()) {
throw new IllegalArgumentException("serviceToken is empty");
}
if (this.timeout.isZero() || this.timeout.isNegative()) {
throw new IllegalArgumentException("timeout must be positive");
if (this.connectTimeout.isZero() || this.connectTimeout.isNegative()) {
throw new IllegalArgumentException("connectTimeout must be positive");
}
if (this.requestTimeout.isZero() || this.requestTimeout.isNegative()) {
throw new IllegalArgumentException("requestTimeout must be positive");
}
}
public LinkConfig(String apiBaseUrl, String serviceToken) {
this(apiBaseUrl, serviceToken, Duration.ofSeconds(10));
this(apiBaseUrl, serviceToken, Duration.ofSeconds(10), Duration.ofSeconds(10));
}
public String apiBaseUrl() {
@@ -43,8 +53,12 @@ public final class LinkConfig {
return serviceToken;
}
public Duration timeout() {
return timeout;
public Duration connectTimeout() {
return connectTimeout;
}
public Duration requestTimeout() {
return requestTimeout;
}
private static String stripTrailingSlash(String u) {
@@ -8,6 +8,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Properties;
import java.util.function.UnaryOperator;
/**
* LinkConfigLoader resolves a {@link LinkConfig} the same way on every platform:
@@ -18,12 +19,25 @@ import java.util.Properties;
* the source tree stays domain- and credential-free. On first run it writes a
* commented template and then reports the values as missing, so an operator gets
* a file to fill in rather than a silent half-configured plugin.
*
* <p>The two call timeouts ({@code connect-timeout-seconds},
* {@code request-timeout-seconds}, or {@code FELIS_API_CONNECT_TIMEOUT_SECONDS} /
* {@code FELIS_API_REQUEST_TIMEOUT_SECONDS}) are optional and default to
* {@value #DEFAULT_TIMEOUT_SECONDS} s. A value that is not a whole number of seconds
* from 1 to {@value #MAX_TIMEOUT_SECONDS} fails the load: a typo quietly falling back
* to the default would hide the setting the operator meant to change.
*/
public final class LinkConfigLoader {
public static final String ENV_URL = "FELIS_API_BASE_URL";
public static final String ENV_TOKEN = "FELIS_SERVICE_TOKEN";
private static final String KEY_URL = "api-base-url";
public static final String ENV_CONNECT_TIMEOUT = "FELIS_API_CONNECT_TIMEOUT_SECONDS";
public static final String ENV_REQUEST_TIMEOUT = "FELIS_API_REQUEST_TIMEOUT_SECONDS";
static final int DEFAULT_TIMEOUT_SECONDS = 10;
static final int MAX_TIMEOUT_SECONDS = 120;
private static final String KEY_TOKEN = "service-token";
private static final String KEY_CONNECT_TIMEOUT = "connect-timeout-seconds";
private static final String KEY_REQUEST_TIMEOUT = "request-timeout-seconds";
private LinkConfigLoader() {
}
@@ -33,9 +47,15 @@ public final class LinkConfigLoader {
* if the file does not yet exist.
*
* @throws IOException if the file cannot be read/created, or if neither the
* environment nor the file supplies both required values.
* environment nor the file supplies both required values, or if a
* timeout is not a whole number of seconds in range.
*/
public static LinkConfig load(Path propertiesFile) throws IOException {
return load(propertiesFile, System::getenv);
}
// load with the environment passed in, so the precedence rules are testable.
static LinkConfig load(Path propertiesFile, UnaryOperator<String> env) throws IOException {
Properties props = new Properties();
if (Files.exists(propertiesFile)) {
try (InputStream in = Files.newInputStream(propertiesFile)) {
@@ -45,14 +65,36 @@ public final class LinkConfigLoader {
writeTemplate(propertiesFile);
}
String url = firstNonBlank(System.getenv(ENV_URL), props.getProperty(KEY_URL));
String token = firstNonBlank(System.getenv(ENV_TOKEN), props.getProperty(KEY_TOKEN));
String url = firstNonBlank(env.apply(ENV_URL), props.getProperty(KEY_URL));
String token = firstNonBlank(env.apply(ENV_TOKEN), props.getProperty(KEY_TOKEN));
if (isBlank(url) || isBlank(token)) {
throw new IOException("set " + ENV_URL + "/" + ENV_TOKEN
+ " or fill in " + propertiesFile + " (" + KEY_URL + ", " + KEY_TOKEN + ")");
}
return new LinkConfig(url, token, Duration.ofSeconds(10));
Duration connect = seconds(KEY_CONNECT_TIMEOUT, ENV_CONNECT_TIMEOUT,
firstNonBlank(env.apply(ENV_CONNECT_TIMEOUT), props.getProperty(KEY_CONNECT_TIMEOUT)));
Duration request = seconds(KEY_REQUEST_TIMEOUT, ENV_REQUEST_TIMEOUT,
firstNonBlank(env.apply(ENV_REQUEST_TIMEOUT), props.getProperty(KEY_REQUEST_TIMEOUT)));
return new LinkConfig(url, token, connect, request);
}
private static Duration seconds(String key, String envName, String raw) throws IOException {
if (isBlank(raw)) {
return Duration.ofSeconds(DEFAULT_TIMEOUT_SECONDS);
}
String v = raw.trim();
int n;
try {
n = Integer.parseInt(v);
} catch (NumberFormatException e) {
n = 0;
}
if (n < 1 || n > MAX_TIMEOUT_SECONDS) {
throw new IOException(key + " (" + envName + ") must be a whole number of seconds from 1 to "
+ MAX_TIMEOUT_SECONDS + ", got \"" + v + "\"");
}
return Duration.ofSeconds(n);
}
private static void writeTemplate(Path file) throws IOException {
@@ -72,7 +114,14 @@ public final class LinkConfigLoader {
+ KEY_URL + "=\n"
+ "#\n"
+ "# " + KEY_TOKEN + ": the internal service token (keep this secret).\n"
+ KEY_TOKEN + "=\n";
+ KEY_TOKEN + "=\n"
+ "#\n"
+ "# Optional call timeouts in whole seconds (1-" + MAX_TIMEOUT_SECONDS + ", default "
+ DEFAULT_TIMEOUT_SECONDS + "): " + KEY_CONNECT_TIMEOUT + " bounds opening the\n"
+ "# connection, " + KEY_REQUEST_TIMEOUT + " the whole call once it is open\n"
+ "# (" + ENV_CONNECT_TIMEOUT + ", " + ENV_REQUEST_TIMEOUT + ").\n"
+ "#" + KEY_CONNECT_TIMEOUT + "=" + DEFAULT_TIMEOUT_SECONDS + "\n"
+ "#" + KEY_REQUEST_TIMEOUT + "=" + DEFAULT_TIMEOUT_SECONDS + "\n";
try (OutputStream out = Files.newOutputStream(file)) {
out.write(template.getBytes(StandardCharsets.UTF_8));
}
@@ -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++;
}
}