diff --git a/README.md b/README.md index ebd2e47..95cf498 100644 --- a/README.md +++ b/README.md @@ -308,7 +308,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. `QUERY /api/v1/player-finance` accepts exactly `{playerUuid, profileId}` with either compact or dashed UUIDs and returns compact lowercase UUIDs. The public route uses the existing player rate limit and shared response cache. It first verifies through Hypixel `/v2/skyblock/profile` that the player belongs to the requested profile, then reads `/v2/skyblock/museum` with the server-side `MARKETGUARD_HYPIXEL_API_KEY`. Only the requested member is normalized. The response contains bank, purse, Hypixel's authoritative museum `value` and `appraisal`, donated position IDs, decoded special exhibit IDs, counts, and `knownTotal` only when bank, purse, and museum value are all available. Raw Coop members and Museum NBT are never returned. Privacy-disabled or temporarily unavailable Museum data produces `status: "partial"` with `unavailableFields` while preserving available bank and purse values. A missing or rejected server key returns HTTP 419. Costs, revenue, profit, and ROI are not available from these upstream fields and are intentionally not estimated or included. @@ -320,7 +320,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/v1/player-finance` request: diff --git a/app/marketguard_api/client.py b/app/marketguard_api/client.py index b55bbde..0aeb04a 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, @@ -341,6 +382,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/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)}. " diff --git a/tests/test_marketguard_api.py b/tests/test_marketguard_api.py index 026e8c3..c1f6462 100644 --- a/tests/test_marketguard_api.py +++ b/tests/test_marketguard_api.py @@ -19,7 +19,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 @@ -33,6 +40,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) @@ -1144,7 +1171,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, hypixel_api_key="test-key") + 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(