From 7a8417e14c83d7b17788b9083f140f6d2d0835d4 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 1 Aug 2026 13:17:14 +0000 Subject: [PATCH 1/4] Document optional player profile selection --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ebed6a8..e9c2ca9 100644 --- a/README.md +++ b/README.md @@ -303,7 +303,7 @@ Supply-chain checks: `/api/v2/lowestbin` returns an object with top-level `lastUpdated` plus a `products` object whose keys are item identifiers and whose values contain the current Lowest BIN `price`, seller `auctioneerUuid`, Hypixel auction `item_name`, and snapshot-based `avg7d` / `avg30d` averages over deduplicated Hypixel snapshots. `QUERY /api/v2/lowestbin` accepts a JSON body with a non-empty `products` array and returns the same response shape containing only the requested identifiers. Unknown identifiers are omitted. The QUERY method is additive and does not replace the GET endpoint; because the HTTP QUERY method is currently an IETF Internet-Draft, clients should retain GET as a compatibility fallback. -`QUERY /api/v1/players` accepts up to ten Minecraft usernames or UUIDs paired with a required SkyBlock profile UUID. It returns each player in request order with the canonical UUID, first join timestamp, requested profile, bank/purse coins, decoded armor and equipment item lists, and SkyBlock skill level plus XP. The endpoint is public but rate-limited and uses `MARKETGUARD_HYPIXEL_API_KEY` only on the server. Profile privacy settings or upstream failures can make individual fields unavailable; the response reports that through `status` and `unavailableFields` without exposing raw upstream data. If that key is missing or rejected by Hypixel, the route returns HTTP 419 with a generic error detail. The top-level response status is `ok` or `stale` when served from the shared cache; clients can also inspect `X-Data-Stale`. Cache misses for the same normalized request share one in-flight upstream lookup, the upstream work is bounded per worker, and skill definitions are cached for one hour. +`QUERY /api/v1/players` accepts up to ten Minecraft usernames or UUIDs. Each entry may include a SkyBlock profile UUID; if `profileId` is omitted, the API automatically uses the player's selected SkyBlock profile. It returns each player in request order with the canonical UUID, first join timestamp, resolved profile, bank/purse coins, decoded armor and equipment item lists, and SkyBlock skill level plus XP. The endpoint is public but rate-limited and uses `MARKETGUARD_HYPIXEL_API_KEY` only on the server. Profile privacy settings or upstream failures can make individual fields unavailable; the response reports that through `status` and `unavailableFields` without exposing raw upstream data. If that key is missing or rejected by Hypixel, the route returns HTTP 419 with a generic error detail. The top-level response status is `ok` or `stale` when served from the shared cache; clients can also inspect `X-Data-Stale`. Cache misses for the same normalized request share one in-flight upstream lookup, the upstream work is bounded per worker, and skill definitions are cached for one hour. The protected Admin Analytics Metrics page includes the player route's cache-hit rate, average response time, active upstream loads, coalesced requests, and upstream failures. The Compose deployment already supplies its internal API metrics URL. Keep `WEB_CONCURRENCY=1` while this route uses the built-in process-local per-IP limiter; Redis shares cached responses but does not make that limiter distributed. @@ -313,7 +313,7 @@ Example `QUERY /api/v1/players` request: QUERY /api/v1/players HTTP/1.1 Content-Type: application/json -{"players":[{"player":"Pankraz01","profileId":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}]} +{"players":[{"player":"Pankraz01"}]} ``` Example `QUERY /api/v2/lowestbin` request: From ecedecebc8f95924bc75ed3f1fb60a998eb73c86 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 1 Aug 2026 13:30:21 +0000 Subject: [PATCH 2/4] Fix Player API test expectations --- tests/test_marketguard_api.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_marketguard_api.py b/tests/test_marketguard_api.py index 542512f..38e97c0 100644 --- a/tests/test_marketguard_api.py +++ b/tests/test_marketguard_api.py @@ -564,9 +564,12 @@ async def _hypixel_handler(_request: httpx.Request) -> httpx.Response: return httpx.Response(403, json={"success": False, "cause": "Invalid API key"}) settings = _marketguard_settings(hypixel_api_key="invalid-hypixel-key") + marketguard_service, marketguard_bazaar_service = _noop_marketguard_services(settings) app = create_app( training_hub_settings=_training_hub_settings(tmp_path), marketguard_settings=settings, + marketguard_service=marketguard_service, + marketguard_bazaar_service=marketguard_bazaar_service, marketguard_player_service=_marketguard_player_service( settings, _hypixel_handler, @@ -659,7 +662,7 @@ async def get_players(self, queries) -> dict[str, object]: async def aclose(self) -> None: return None - settings = _marketguard_settings(players_rate_limit_per_minute=3) + settings = _marketguard_settings(hypixel_api_key="test-hypixel-key", players_rate_limit_per_minute=3) marketguard_service, marketguard_bazaar_service = _noop_marketguard_services(settings) counting_player_service = _CountingPlayerService() app = create_marketguard_app( @@ -775,7 +778,7 @@ def test_marketguard_openapi_documents_response_codes_and_examples(tmp_path: Pat assert schemas["LowestBinV2Product"]["properties"]["avg7d"]["examples"][0] == 97500000 assert schemas["LowestBinV2Product"]["properties"]["avg30d"]["examples"][0] == 96000000 players_query = schema["paths"]["/api/v1/players"]["query"] - assert set(players_query["responses"]) == {"200", "422", "429", "503"} + assert set(players_query["responses"]) == {"200", "419", "422", "429", "503"} assert players_query["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].endswith( "/PlayersQueryResponse" ) From 71ec097a9fa639fb372b352e8feef3210760dd19 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 9 Sep 2026 12:50:21 +0200 Subject: [PATCH 3/4] feat(marketguard): enforce Hypixel API key quota of 300 requests / 5 min Adds a process-wide rolling-window rate limiter in front of the Hypixel client. Requests are counted on attempt (including ones that later time out), because those may still have reached Hypixel and consumed quota. Recovered from an uncommitted working tree on the production server. Co-Authored-By: Claude Opus 5 --- app/marketguard_api/client.py | 42 +++++++++++++++++++++++++++++++++++ tests/test_marketguard_api.py | 29 +++++++++++++++++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/app/marketguard_api/client.py b/app/marketguard_api/client.py index 886b3fe..6c28698 100644 --- a/app/marketguard_api/client.py +++ b/app/marketguard_api/client.py @@ -3,6 +3,9 @@ import asyncio import logging import math +import time +from collections import deque +from collections.abc import Callable from typing import Any import httpx @@ -20,6 +23,44 @@ logger = logging.getLogger(__name__) +class _HypixelKeyRateLimiter: + """Enforce Hypixel's API-key quota with a rolling, process-wide window.""" + + def __init__( + self, + *, + max_requests: int = 300, + window_seconds: int = 300, + clock: Callable[[], float] = time.monotonic, + ) -> None: + self._max_requests = max(1, int(max_requests)) + self._window_seconds = max(1, int(window_seconds)) + self._clock = clock + self._lock = asyncio.Lock() + self._requests: deque[float] = deque() + + async def acquire(self) -> None: + now = self._clock() + async with self._lock: + cutoff = now - self._window_seconds + while self._requests and self._requests[0] <= cutoff: + self._requests.popleft() + + if len(self._requests) >= self._max_requests: + retry_after = max(1, math.ceil(self._requests[0] + self._window_seconds - now)) + raise HypixelRateLimitError( + "Configured Hypixel API key request limit reached.", + retry_after_seconds=retry_after, + ) + + # Count attempts, including requests that later time out, because they may still + # have reached Hypixel and consumed the key's quota. + self._requests.append(now) + + +_HYPIXEL_KEY_RATE_LIMITER = _HypixelKeyRateLimiter() + + class HypixelAuctionClient: def __init__( self, @@ -329,6 +370,7 @@ async def _fetch_authenticated(self, path: str, *, params: dict[str, str]) -> di if not api_key: raise HypixelUpstreamError("Hypixel player API is not configured.") + await _HYPIXEL_KEY_RATE_LIMITER.acquire() client = self._get_client() try: response = await client.get(path, params=params, headers={"API-Key": api_key}) diff --git a/tests/test_marketguard_api.py b/tests/test_marketguard_api.py index 38e97c0..aae9fe2 100644 --- a/tests/test_marketguard_api.py +++ b/tests/test_marketguard_api.py @@ -16,7 +16,14 @@ from app.main import create_app from app.marketguard_api.cache import CachedResponse, LocalResponseCache, ResponseCacheChain -from app.marketguard_api.client import HypixelAuctionClient, HypixelBazaarClient, HypixelPlayerClient, MojangNameClient +from app.marketguard_api.client import ( + HypixelAuctionClient, + HypixelBazaarClient, + HypixelPlayerClient, + MojangNameClient, + _HypixelKeyRateLimiter, +) +from app.marketguard_api.exceptions import HypixelRateLimitError from app.marketguard_api.config import MarketGuardSettings from app.marketguard_api.item_keys import resolve_auction_item from app.marketguard_api.main import create_marketguard_app @@ -28,6 +35,26 @@ from app.training_hub.config.settings import TrainingHubSettings +def test_hypixel_key_rate_limiter_enforces_rolling_five_minute_window() -> None: + now = [0.0] + limiter = _HypixelKeyRateLimiter(max_requests=2, window_seconds=300, clock=lambda: now[0]) + + async def _exercise() -> None: + await limiter.acquire() + await limiter.acquire() + try: + await limiter.acquire() + except HypixelRateLimitError as exc: + assert exc.retry_after_seconds == 300 + else: + raise AssertionError("third request inside the five-minute window must be rejected") + + now[0] = 300.0 + await limiter.acquire() + + asyncio.run(_exercise()) + + def test_lowestbin_v1_returns_not_found(tmp_path: Path) -> None: settings = _marketguard_settings() marketguard_service, marketguard_bazaar_service = _noop_marketguard_services(settings) From 5a87c594af41c58e96bbf24df4fbe28cc7e0b7af Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 9 Sep 2026 12:59:57 +0200 Subject: [PATCH 4/4] fix(deploy): scope named-volume lookup to this Compose project resolve_named_volumes() matched any host volume ending in the logical suffix, so a second Compose project owning e.g. proxy_caddy_data made the lookup ambiguous and aborted every deployment. COMPOSE_PROJECT_NAME was named in the error text but never actually read. Now the project name is resolved the way Compose resolves it (env var, env file, compose `name:`, then directory name) and a candidate matching `_` wins. Unrelated stacks on the same host no longer block a deploy, and no volume has to be deleted to unblock one. Co-Authored-By: Claude Opus 5 --- scripts/compose_ops.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/scripts/compose_ops.py b/scripts/compose_ops.py index 26ded99..9750f02 100644 --- a/scripts/compose_ops.py +++ b/scripts/compose_ops.py @@ -227,6 +227,28 @@ def status(path: str) -> int: return mode if mode in {"local", "external"} else "unknown" +def compose_project_name(context: ComposeContext) -> str: + """Best-effort name of the Compose project this deployment belongs to. + + Mirrors how Docker Compose itself picks the project name, so volume + lookups can be scoped to this stack on hosts that run several projects. + """ + from_env = os.getenv("COMPOSE_PROJECT_NAME", "").strip() + if from_env: + return from_env + if context.env_file.is_file(): + for line in context.env_file.read_text(encoding="utf-8").splitlines(): + key, _, value = line.partition("=") + if key.strip() == "COMPOSE_PROJECT_NAME" and value.strip(): + return value.strip().strip('"').strip("'") + if context.compose_file.is_file(): + for line in context.compose_file.read_text(encoding="utf-8").splitlines(): + match = re.match(r"^name:\s*(\S+)\s*$", line) + if match: + return match.group(1).strip('"').strip("'") + return re.sub(r"[^a-z0-9_-]", "", context.repo_root.name.lower()) + + def list_docker_volumes(*, cwd: Path) -> list[str]: result = run_command(["docker", "volume", "ls", "--format", "{{.Name}}"], cwd=cwd, capture_output=True) return [line.strip() for line in (result.stdout or "").splitlines() if line.strip()] @@ -234,11 +256,18 @@ def list_docker_volumes(*, cwd: Path) -> list[str]: def resolve_named_volumes(context: ComposeContext) -> dict[str, str]: available = list_docker_volumes(cwd=context.repo_root) + project = compose_project_name(context) resolved: dict[str, str] = {} for logical_name in _LOGICAL_VOLUME_NAMES: exact_matches = [name for name in available if name == logical_name] suffix_matches = [name for name in available if name.endswith(f"_{logical_name}")] candidates = sorted(set(exact_matches or suffix_matches)) + if len(candidates) > 1 and project: + # Other Compose projects on the same host may own a volume with the + # same logical suffix (e.g. a second Caddy stack). Prefer our own. + scoped = f"{project}_{logical_name}" + if scoped in candidates: + candidates = [scoped] if len(candidates) > 1: raise RuntimeError( f"Ambiguous Docker volumes for {logical_name}: {', '.join(candidates)}. "