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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,18 @@ Below is the current list of TTL values configured for the API cache. The latest

## 🐍 Architecture

The codebase follows strict DDD layering — dependencies only flow inward. `domain` has no knowledge of FastAPI, Valkey, or Postgres; it only sees `typing.Protocol` ports, which `adapters` implement.

```mermaid
flowchart TD
api["api — routers, DI, response models"] --> domain
api --> adapters
api --> infrastructure
adapters["adapters — Blizzard client, Valkey cache, Postgres storage, taskiq"] --> domain
adapters --> infrastructure
domain["domain — parsers, services (SWR), ports"] -.->|logger only| infrastructure["infrastructure — logger, singletons, error helpers"]
```

### Request flow (Stale-While-Revalidate)

Every cached response is stored in Valkey as an **SWR envelope** containing the payload and three timestamps: `stored_at`, `staleness_threshold`, and `stale_while_revalidate`. Nginx/OpenResty inspects the envelope on every request:
Expand Down
9 changes: 5 additions & 4 deletions app/domain/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Set of custom exceptions used in the API"""

from http import HTTPStatus
from typing import Any


class RateLimitedError(Exception):
Expand All @@ -22,10 +23,10 @@ class OverfastError(Exception):
"""Generic OverFast API Exception"""

status_code = HTTPStatus.INTERNAL_SERVER_ERROR.value
message = "OverFast API Error"
message: str | dict[str, Any] = "OverFast API Error"

def __str__(self):
return self.message
return str(self.message)


class ParserBlizzardError(OverfastError):
Expand All @@ -34,9 +35,9 @@ class ParserBlizzardError(OverfastError):
"""

status_code = HTTPStatus.INTERNAL_SERVER_ERROR.value
message = "Parser Blizzard Error"
message: str | dict[str, Any] = "Parser Blizzard Error"

def __init__(self, status_code: int, message: str):
def __init__(self, status_code: int, message: str | dict[str, Any]):
super().__init__()
self.status_code = status_code
self.message = message
Expand Down
5 changes: 2 additions & 3 deletions app/domain/parsers/hero.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
"""Stateless parser functions for single hero details"""

import re
from http import HTTPStatus
from typing import TYPE_CHECKING

from fastapi import status

from app.config import settings
from app.domain.parsers.utils import (
parse_html_root,
Expand Down Expand Up @@ -58,7 +57,7 @@ def parse_hero_html(html: str, locale: Locale = Locale.ENGLISH_US) -> dict:
abilities_section = root_tag.css_first("div.abilities-container")
if not abilities_section:
raise ParserBlizzardError( # noqa: TRY301
status_code=status.HTTP_404_NOT_FOUND,
status_code=HTTPStatus.NOT_FOUND.value,
message="Hero not found or not released yet",
)

Expand Down
5 changes: 2 additions & 3 deletions app/domain/parsers/hero_stats_summary.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
"""Stateless parser functions for hero stats summary (pickrate/winrate from Blizzard API)"""

from http import HTTPStatus
from typing import TYPE_CHECKING

from fastapi import status

from app.config import settings
from app.domain.enums import PlayerGamemode, PlayerPlatform, PlayerRegion
from app.domain.exceptions import (
Expand Down Expand Up @@ -111,7 +110,7 @@ def parse_hero_stats_json(
# Validate map matches gamemode (outside try so this is never caught above).
if map_filter != selected_map:
raise ParserBlizzardError(
status_code=status.HTTP_400_BAD_REQUEST,
status_code=HTTPStatus.BAD_REQUEST.value,
message=f"Selected map '{map_filter}' is not compatible with '{gamemode}' gamemode.",
)

Expand Down
4 changes: 3 additions & 1 deletion app/domain/parsers/player_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ def get_computed_stat_value(input_str: str) -> str | float | int:
def get_division_from_icon(rank_url: str) -> CompetitiveDivision:
division_name = (
rank_url.rsplit("/", maxsplit=1)[-1] # filename or inline-SVG symbol id
.split(".", maxsplit=1)[0] # drop content hash / ".png": "Rank_DiamondTier" / "goldtier"
.split(".", maxsplit=1)[
0
] # drop content hash / ".png": "Rank_DiamondTier" / "goldtier"
.split("-", maxsplit=1)[0]
.rsplit("_", maxsplit=1)[-1] # drop "Rank_" prefix: "DiamondTier" / "goldtier"
)
Expand Down
5 changes: 2 additions & 3 deletions app/domain/parsers/player_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@
- Career stats (detailed statistics per hero)
"""

from http import HTTPStatus
from typing import TYPE_CHECKING

from fastapi import status

from app.config import settings
from app.domain.exceptions import ParserBlizzardError, ParserParsingError
from app.domain.parsers.utils import (
Expand Down Expand Up @@ -138,7 +137,7 @@ def parse_player_profile_html(
# Check if player exists
if not root_tag.css_first("blz-section.Profile-masthead"):
raise ParserBlizzardError(
status_code=status.HTTP_404_NOT_FOUND,
status_code=HTTPStatus.NOT_FOUND.value,
message="Player not found",
)

Expand Down
25 changes: 6 additions & 19 deletions app/domain/services/hero_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,10 @@
import json
from typing import TYPE_CHECKING, Any

from fastapi import HTTPException

from app.config import settings
from app.domain.enums import Locale, PlayerGamemode, SubRole
from app.domain.exceptions import (
InvalidGamemodeFilterError,
ParserBlizzardError,
ParserInternalError,
ParserParsingError,
)
Expand Down Expand Up @@ -115,18 +112,12 @@ def _hero_detail_config(
"""Build a StaticFetchConfig for a single hero detail."""

async def _fetch() -> str:
try:
hero_html = await fetch_hero_html(
self.blizzard_client, hero_key, locale
)
# Validate hero exists before making the second Blizzard request.
# parse_hero_html raises ParserBlizzardError (404) for unknown heroes.
parse_hero_html(hero_html, locale)
heroes_html = await fetch_heroes_html(self.blizzard_client, locale)
except ParserBlizzardError as exc:
raise HTTPException(
status_code=exc.status_code, detail=exc.message
) from exc
hero_html = await fetch_hero_html(self.blizzard_client, hero_key, locale)
# Validate hero exists before making the second Blizzard request.
# parse_hero_html raises ParserBlizzardError (404) for unknown heroes,
# which propagates to the API layer's registered OverfastError handler.
parse_hero_html(hero_html, locale)
heroes_html = await fetch_heroes_html(self.blizzard_client, locale)
return json.dumps(
{"hero_html": hero_html, "heroes_html": heroes_html},
separators=(",", ":"),
Expand Down Expand Up @@ -297,10 +288,6 @@ async def _get_hero_stats(
competitive_division=competitive_division,
order_by=order_by,
)
except ParserBlizzardError as exc:
raise HTTPException(
status_code=exc.status_code, detail=exc.message
) from exc
except ParserParsingError as exc:
blizzard_url = f"{settings.blizzard_host}{settings.hero_stats_path}"
raise ParserInternalError(blizzard_url, exc) from exc
Expand Down
32 changes: 13 additions & 19 deletions app/domain/services/player_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
if TYPE_CHECKING:
from collections.abc import Callable

from fastapi import HTTPException

from app.config import settings
from app.domain.enums import HeroKeyCareerFilter, PlayerGamemode, PlayerPlatform
from app.domain.exceptions import (
Expand Down Expand Up @@ -590,7 +588,7 @@ def _calculate_retry_after(self, check_count: int) -> int:
async def _mark_player_unknown(
self,
blizzard_id: str,
exception: HTTPException,
exception: ParserBlizzardError,
battletag: str | None = None,
) -> None:
if not settings.unknown_players_cache_enabled:
Expand All @@ -607,7 +605,7 @@ async def _mark_player_unknown(
blizzard_id, check_count, retry_after, battletag=battletag
)

exception.detail = { # ty: ignore[invalid-assignment]
exception.message = {
"error": "Player not found",
"retry_after": retry_after,
"next_check_at": next_check_at,
Expand All @@ -627,41 +625,37 @@ async def _handle_player_exceptions(
player_id: str,
identity: PlayerIdentity,
) -> Never:
"""Translate all player exceptions to HTTPException and always raise."""
"""Translate known parser exceptions to a client-facing error and always raise.

Raised errors are ``ParserBlizzardError``/``ParserInternalError`` — the API
layer's registered ``OverfastError`` handler turns them into HTTP responses.
"""
effective_id = identity.blizzard_id or player_id
battletag_input = identity.battletag_input
player_summary = identity.player_summary

if isinstance(error, ParserBlizzardError):
exc = HTTPException(status_code=error.status_code, detail=error.message)
if error.status_code == HTTPStatus.NOT_FOUND.value:
await self._mark_player_unknown(
effective_id, exc, battletag=battletag_input
effective_id, error, battletag=battletag_input
)
raise exc from error
raise error

if isinstance(error, ParserParsingError):
if "Could not find main content in HTML" in str(error):
exc = HTTPException(
not_found = ParserBlizzardError(
status_code=HTTPStatus.NOT_FOUND.value,
detail="Player not found",
message="Player not found",
)
await self._mark_player_unknown(
effective_id, exc, battletag=battletag_input
effective_id, not_found, battletag=battletag_input
)
raise exc from error
raise not_found from error

blizzard_url = (
f"{settings.blizzard_host}{settings.career_path}/"
f"{player_summary.get('url', effective_id) if player_summary else effective_id}/"
)
raise ParserInternalError(blizzard_url, error) from error

if isinstance(error, HTTPException):
if error.status_code == HTTPStatus.NOT_FOUND.value:
await self._mark_player_unknown(
effective_id, error, battletag=battletag_input
)
raise error

raise error
10 changes: 9 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -143,11 +143,19 @@ ignore = [
allowed-confusables = ["(", ")", ":"]

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["SLF001"] # Ignore private member access in tests
"tests/**" = ["SLF001", "TID251"] # Ignore private member access + fastapi.TestClient in tests
"app/api/models/**" = ["TC001"] # Ignore type-checking block (Pydantic models definition)
"app/api/routers/**" = ["TC001"] # Enums used in FastAPI path/query parameters need runtime import
"app/api/dependencies.py" = ["TC001"] # Port types needed at runtime for FastAPI Depends
"app/domain/services/**" = ["TC001"] # Locale/enums used in runtime method annotations
"app/api/**" = ["TID251"] # API layer owns FastAPI
"app/adapters/**" = ["TID251"] # BlizzardClient raises HTTPException for transport errors
"app/infrastructure/**" = ["TID251"] # Shared HTTPException helpers (Discord alerts, etc.)
"app/monitoring/**" = ["TID251"] # Prometheus/FastAPI router glue
"app/main.py" = ["TID251"] # App assembly

[tool.ruff.lint.flake8-tidy-imports.banned-api]
"fastapi".msg = "Domain layer must stay framework-agnostic. Raise app.domain.exceptions instead of fastapi.HTTPException, use http.HTTPStatus instead of fastapi.status."

[tool.ruff.lint.isort]
# Consider app as first-party for imports in tests
Expand Down
6 changes: 4 additions & 2 deletions tests/heroes/parsers/test_hero_stats_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,10 @@ async def test_parse_hero_stats_summary_invalid_map_error_message(
map_filter="hanaoka",
)

assert "hanaoka" in exc_info.value.message
assert "compatible" in exc_info.value.message.lower()
message = str(exc_info.value.message)

assert "hanaoka" in message
assert "compatible" in message.lower()


def test_parse_hero_stats_json_raises_invalid_gamemode_filter_error():
Expand Down
Loading
Loading