Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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:
Expand Down
42 changes: 42 additions & 0 deletions app/marketguard_api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Propagate limiter exhaustion from player lookups

Once /api/v1/players reaches 300 authenticated calls in the rolling window, this acquisition raises HypixelRateLimitError, but PlayerService.get_players() catches its HypixelUpstreamError superclass at player_service.py:70-72 and converts the affected lookups into status: "unavailable". Consequently the route's dedicated rate-limit handler at routes.py:322-329 is never reached: callers receive HTTP 200 without Retry-After, and the unavailable result is cached as a successful response. Re-raise HypixelRateLimitError before the generic upstream catch so exhaustion is surfaced as the documented temporary failure.

AGENTS.md reference: AGENTS.md:L74-L80

Useful? React with 👍 / 👎.

client = self._get_client()
try:
response = await client.get(path, params=params, headers={"API-Key": api_key})
Expand Down
29 changes: 29 additions & 0 deletions scripts/compose_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,18 +227,47 @@ 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()]


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)}. "
Expand Down
31 changes: 29 additions & 2 deletions tests/test_marketguard_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand Down