Unverified Commit fcf5c305 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

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

parent 001f0600
Loading
Loading
Loading
Loading
+2 −0
Changes for deploy/limbo/README.md: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -58,6 +58,8 @@ Configuration (deployment inputs, never compiled in; env wins over a
| `FELIS_LOBBY_SERVER`        | Velocity server name to transfer to  | `lobby` |
| `FELIS_LOGIN_TIMEOUT_SECONDS` | login window (clamped 30–3600)     | `600` |
| `FELIS_HEALTH_PORT`         | readiness port                        | `8080` |
| `FELIS_API_CONNECT_TIMEOUT_SECONDS` | felis-api connect timeout (1–120) | `10` |
| `FELIS_API_REQUEST_TIMEOUT_SECONDS` | felis-api call timeout (1–120)    | `10` |

If the API config **or** the root domain is absent the login flow stays **OFF** and
the plugin runs readiness-only (the same "load un-crippled" fail-safe the other
+18 −1
Changes for plugins/README.md: 18 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -120,7 +120,15 @@ so there is nothing to shade and each jar is self-contained.
- `LinkConfigLoader` — reads `FELIS_API_BASE_URL` / `FELIS_SERVICE_TOKEN` (env
  wins) or a `felis-link.properties` file written as a commented template on
  first run. **The API URL and service token are deployment inputs and are never
  compiled in.**
  compiled in.** Two optional keys bound each call, in whole seconds from 1 to 120
  (default 10): `connect-timeout-seconds` / `FELIS_API_CONNECT_TIMEOUT_SECONDS`
  for opening the connection, `request-timeout-seconds` /
  `FELIS_API_REQUEST_TIMEOUT_SECONDS` for the whole call. Anything else fails the
  load with the key named.
- `FelisApiClient` — the routing client. A GET that failed on a dropped
  connection or a 502/503/504 is tried once more after a 100–400 ms jittered
  pause; a GET that timed out, and every POST (wake, claim, join-event,
  approvals), is never repeated.

Threading: the command runs on the server thread; the HTTP call is dispatched to
a daemon single-thread executor and the reply is hopped back onto the server
@@ -175,6 +183,15 @@ Velocity-only config keys (read from the same `felis-link.properties` / env as
| `login-server` | `FELIS_LOGIN_SERVER` | The system auth gate every fresh connection must pass. Defaults to `login`. |
| `lobby-server` | `FELIS_LOBBY_SERVER` | The distinct post-auth holding server used while a backend wakes. Defaults to `lobby`; it must not equal `login-server`. |

Load bounds on the proxy: felis-api calls run on a pool of 8 threads with 64
waiting slots. Past that a call is refused at once — the player reads "busy", a
lobby frame gets a `busy` error, and a dropped join-event is logged at warn —
rather than piling up threads while felis-api is slow. The registration refresh
(15 s) and the waiting-queue poll (2 s) skip a run that falls due while the last
one is still going. Acting commands (`/link`, `/felis claim`, migrate, op
approve) share a per-player budget of 5 then one per 5 s; felis:control frames
from the lobby are metered per player and menu status answers are cached.

## Lobby menu (§12)

The `paper/` module is the lobby's player-facing face for §27 scenario 10
+3 −0
Changes for plugins/paper/src/main/java/best/lolicon/felis/paper/FelisPaperPlugin.java: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -384,6 +384,9 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
                case "invalid_server_name":
                    return zh ? "这台服务器已不存在。"
                              : "That server no longer exists.";
                case "busy":
                    return zh ? "Felis 现在很忙,请过一会儿再试。"
                              : "Felis is busy right now — try again in a moment.";
                case "transport_error":
                case "interrupted":
                    return zh ? "Felis 暂时不可用,请稍后再试。"
+55 −7
Changes for plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java: 55 added lines, 7 removed lines.
Original line number Diff line number Diff line
@@ -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,21 +323,35 @@ 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 {
+2 −2
Changes for plugins/shared/src/main/java/best/lolicon/felis/link/LinkClient.java: 2 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -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")
Loading