feat(velocity): felis-api 调用改走有界线程池、定时任务防重叠,超时可配置,幂等 GET 带抖动重试一次
This commit is contained in:
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++;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user