Skip to content

Commit d237f2e

Browse files
authored
feat!: polishing project for v4.0.0 (#391)
* fix: started fixes before 4.0 * fix: updated README * fix: last fix before release
1 parent 18936f7 commit d237f2e

39 files changed

Lines changed: 263 additions & 209 deletions

AGENTS.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@ Four strict DDD layers; dependencies only flow inward.
4141

4242
| Layer | May depend on | Must not depend on |
4343
|---|---|---|
44-
| `domain` | stdlib only | `adapters`, `api`, `infrastructure` |
45-
| `adapters` | `domain` | `api` |
46-
| `api` | `domain`, `adapters` (via DI) ||
44+
| `domain` | stdlib, `infrastructure` (logger only) | `adapters`, `api` |
45+
| `adapters` | `domain`, `infrastructure` | `api` |
46+
| `api` | `domain`, `adapters` (via DI), `infrastructure` ||
4747
| `infrastructure` | anything ||
4848

4949
```
@@ -52,7 +52,7 @@ app/
5252
├── config.py # Pydantic BaseSettings (settings singleton)
5353
├── domain/
5454
│ ├── enums.py # All domain enums; HeroKey/MapKey built dynamically from CSV
55-
│ ├── exceptions.py # Domain exceptions (ParserParsingError, RateLimitedError, …)
55+
├── exceptions.py # Domain exceptions (ParserParsingError, ParserInternalError, RateLimitedError, …)
5656
│ ├── models/player.py # PlayerIdentity, PlayerRequest dataclasses
5757
│ ├── parsers/ # HTML parsers (stateless functions, selectolax)
5858
│ ├── ports/ # typing.Protocol interfaces (structural typing)
@@ -74,13 +74,14 @@ app/
7474
├── api/
7575
│ ├── dependencies.py # FastAPI Depends() providers + type aliases
7676
│ ├── exception_handlers.py
77-
│ ├── helpers.py # overfast_internal_error, Discord webhook
77+
├── helpers.py # SWR headers and response helpers (routes_responses, apply_swr_headers, …)
7878
│ ├── lifespan.py
7979
│ ├── responses.py # ASCIIJSONResponse (default response class)
8080
│ ├── models/ # Pydantic response models
8181
│ └── routers/
8282
├── infrastructure/
8383
│ ├── decorators.py # @rate_limited
84+
│ ├── helpers.py # overfast_internal_error, send_discord_webhook_message
8485
│ ├── logger.py # loguru logger
8586
│ └── metaclasses.py # Singleton with clear_all()
8687
└── monitoring/ # Prometheus metrics + middleware

README.md

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
[![Issues](https://img.shields.io/github/issues/TeKrop/overfast-api)](https://github.com/TeKrop/overfast-api/issues)
77
[![Documentation](https://img.shields.io/badge/documentation-yes-brightgreen.svg)](https://overfast-api.tekrop.fr)
88
[![License: MIT](https://img.shields.io/github/license/TeKrop/overfast-api)](https://github.com/TeKrop/overfast-api/blob/master/LICENSE)
9-
![Mockup OverFast API](https://files.tekrop.fr/overfast_api_logo_full_1000.png)
9+
![Mockup OverFast API](static/logo.png)
1010

1111
> OverFast API provides comprehensive data on Overwatch heroes, game modes, maps, and player statistics by scraping Blizzard pages. Built with **FastAPI** and **Selectolax**, **PostgreSQL** for persistent storage, **Stale-While-Revalidate caching** via **Valkey** and **nginx (OpenResty)**, **taskiq** background workers, and **TCP Slow Start + AIMD throttling** for Blizzard requests.
1212
@@ -16,6 +16,7 @@
1616
* [💽 Run as developer](#-run-as-developer)
1717
* [👨‍💻 Technical details](#-technical-details)
1818
* [🐍 Architecture](#-architecture)
19+
* [📊 Monitoring](#-monitoring)
1920
* [🤝 Contributing](#-contributing)
2021
* [🚀 Community projects](#-community-projects)
2122
* [🙏 Credits](#-credits)
@@ -74,7 +75,7 @@ just format # Run ruff formatter
7475
```
7576

7677
### Testing
77-
The code has been tested using unit testing, except some rare parts which are not relevant to test. There are tests on the parsers classes, the common classes, but also on the commands (run in CLI) and the API views (using FastAPI TestClient class).
78+
The code has been tested using unit testing, except some rare parts which are not relevant to test. There are tests on the parsers, the domain services, the adapters, and the API views (using FastAPI TestClient class). Tests are organized to mirror the DDD layer structure: `tests/domain/`, `tests/adapters/`, `tests/infrastructure/`, `tests/players/`, `tests/heroes/`, etc.
7879

7980
Running tests with coverage (default)
8081
```shell
@@ -83,17 +84,18 @@ just test
8384

8485
Running tests with given args (without coverage)
8586
```shell
86-
just test tests/common
87-
make test PYTEST_ARGS="tests/common"
87+
just test tests/domain/services
88+
make test PYTEST_ARGS="tests/domain/services"
8889
```
8990

9091

9192
### Pre-commit
9293
The project is using [pre-commit](https://pre-commit.com/) framework to ensure code quality before making any commit on the repository. After installing the project dependencies, you can install the pre-commit by using the `pre-commit install` command.
9394

94-
The configuration can be found in the `.pre-commit-config.yaml` file. It consists in launching 2 processes on modified files before making any commit :
95-
- `ruff` for linting and code formatting (with `ruff format`)
96-
- `sourcery` for more code quality checks and a lot of simplifications
95+
The configuration can be found in the `.pre-commit-config.yaml` file. It runs the following checks on modified files before each commit:
96+
- `ruff` for linting and auto-fixing
97+
- `ruff format` for code formatting
98+
- `ty` for type checking
9799

98100
## 👨‍💻 Technical details
99101

@@ -284,6 +286,22 @@ stateDiagram-v2
284286
AIMD --> AIMD : non-200 — reset streak
285287
```
286288

289+
## 📊 Monitoring
290+
291+
OverFast API ships an optional observability stack built on **Prometheus** and **Grafana**. When enabled, metrics are collected from two sources: **Nginx/OpenResty** (all requests, including Valkey cache hits) via a Lua module, and **FastAPI middleware** (requests that reach the app — cache misses only). Both sources normalize dynamic path segments (player IDs, hero keys) to prevent cardinality explosion, using matching Python and Lua implementations.
292+
293+
![Grafana dashboard screenshot](static/monitoring/grafana_dashboard.png)
294+
295+
Key metrics tracked include request rates and status code distribution, cache hit/miss ratios, Blizzard upstream call rates, AIMD throttle state, and background task throughput. Auto-provisioned Grafana dashboards cover API usage, API health, Blizzard calls, tasks & rate limiting, and system metrics.
296+
297+
Enable the full stack with a single command:
298+
299+
```shell
300+
just up monitoring=true
301+
```
302+
303+
For detailed metric definitions, normalization rules, dashboard descriptions, and troubleshooting, see [app/monitoring/README.md](app/monitoring/README.md).
304+
287305
## 🤝 Contributing
288306

289307
Contributions, issues and feature requests are welcome ! Do you want to update the heroes data (health, armor, shields, etc.) or the maps list ? Don't hesitate to consult the dedicated [CONTRIBUTING file](https://github.com/TeKrop/overfast-api/blob/main/CONTRIBUTING.md).

app/adapters/blizzard/throttle.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@
2323
from typing import TYPE_CHECKING
2424

2525
from app.adapters.cache.valkey_cache import ValkeyCache
26-
from app.api.helpers import send_discord_webhook_message
2726
from app.config import settings
2827
from app.domain.exceptions import RateLimitedError
28+
from app.infrastructure.helpers import send_discord_webhook_message
2929
from app.infrastructure.logger import logger
3030
from app.infrastructure.metaclasses import Singleton
3131
from app.monitoring.metrics import (

app/adapters/tasks/worker.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@
4141
get_storage,
4242
get_task_queue,
4343
)
44-
from app.api.helpers import send_discord_webhook_message
4544
from app.config import settings
4645
from app.domain.enums import HeroKey, Locale
4746
from app.domain.parsers.heroes import fetch_heroes_html, parse_heroes_html
@@ -53,6 +52,7 @@
5352
PlayerService,
5453
RoleService,
5554
)
55+
from app.infrastructure.helpers import send_discord_webhook_message
5656
from app.infrastructure.logger import logger
5757
from app.monitoring.metrics import (
5858
background_refresh_completed_total,

app/api/exception_handlers.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
from fastapi.exceptions import ResponseValidationError
88
from starlette.exceptions import HTTPException as StarletteHTTPException
99

10-
from app.api.helpers import overfast_internal_error
1110
from app.api.responses import ASCIIJSONResponse
12-
from app.domain.exceptions import OverfastError
11+
from app.domain.exceptions import OverfastError, ParserInternalError
12+
from app.infrastructure.helpers import overfast_internal_error
1313

1414
if TYPE_CHECKING:
1515
from fastapi import FastAPI, Request
@@ -33,6 +33,10 @@ async def overfast_error_handler(_: Request, exc: OverfastError):
3333
status_code=exc.status_code,
3434
)
3535

36+
@app.exception_handler(ParserInternalError)
37+
async def parser_internal_error_handler(_: Request, exc: ParserInternalError):
38+
raise overfast_internal_error(exc.blizzard_url, exc.cause) from exc
39+
3640
@app.exception_handler(ResponseValidationError)
3741
async def pydantic_validation_error_handler(
3842
request: Request, error: ResponseValidationError

app/api/helpers.py

Lines changed: 2 additions & 146 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
"""API Helpers module"""
22

3-
import traceback
4-
from datetime import UTC, datetime
53
from functools import cache
6-
from typing import TYPE_CHECKING, Any
4+
from typing import TYPE_CHECKING
75

8-
import httpx
9-
from fastapi import HTTPException, status
6+
from fastapi import status
107

118
from app.api.models.errors import (
129
BlizzardErrorMessage,
@@ -15,8 +12,6 @@
1512
RateLimitErrorMessage,
1613
)
1714
from app.config import settings
18-
from app.infrastructure.decorators import rate_limited
19-
from app.infrastructure.logger import logger
2015

2116
if TYPE_CHECKING:
2217
from fastapi import Request, Response
@@ -112,145 +107,6 @@
112107
}
113108

114109

115-
def overfast_internal_error(url: str, error: Exception) -> HTTPException:
116-
"""Returns an Internal Server Error. Also log it and eventually send
117-
a Discord notification via a webhook if configured.
118-
"""
119-
120-
# Get error details
121-
error_str = str(error)
122-
error_type = type(error).__name__
123-
124-
# Log the critical error with full traceback
125-
logger.critical(
126-
"Internal server error for URL {} : {}\n{}",
127-
url,
128-
error_str,
129-
traceback.format_exc(),
130-
)
131-
132-
# If we're using a profiler, it means we're debugging, raise the error
133-
# directly in order to have proper backtrace in logs
134-
if settings.profiler:
135-
raise error # pragma: no cover
136-
137-
# Truncate error message for Discord (keep first part which is most relevant)
138-
max_error_length = 900 # Field value limit is 1024, leave room for formatting
139-
if len(error_str) > max_error_length:
140-
# For validation errors, try to show just the summary
141-
if "validation error" in error_str.lower():
142-
lines = error_str.split("\n")
143-
error_str = "\n".join(
144-
lines[:5]
145-
) # First 5 lines usually contain the key info
146-
if len(error_str) > max_error_length:
147-
error_str = error_str[:max_error_length]
148-
else:
149-
error_str = error_str[:max_error_length]
150-
151-
# Send a message to the given channel using Discord Webhook URL
152-
send_discord_webhook_message(
153-
title="🚨 Internal Server Error",
154-
url=f"{settings.app_base_url}{url}" if not url.startswith("http") else url,
155-
fields=[
156-
{"name": "Error Type", "value": f"`{error_type}`", "inline": True},
157-
{"name": "Endpoint", "value": f"`{url}`", "inline": True},
158-
{
159-
"name": "Error Message",
160-
"value": f"```\n{error_str}\n```",
161-
"inline": False,
162-
},
163-
],
164-
color=0xE74C3C, # Red
165-
)
166-
167-
return HTTPException(
168-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
169-
detail=settings.internal_server_error_message,
170-
)
171-
172-
173-
def _truncate_text(text: str, max_length: int, suffix: str = "...") -> str:
174-
"""Truncate text to max length with suffix if needed."""
175-
if len(text) <= max_length:
176-
return text
177-
return text[: max_length - len(suffix)] + suffix
178-
179-
180-
def _truncate_embed_fields(
181-
fields: list[dict[str, Any]],
182-
) -> list[dict[str, Any]]:
183-
"""Truncate field names and values to Discord limits."""
184-
max_field_name_length = 250 # Actual limit: 256
185-
max_field_value_length = 1000 # Actual limit: 1024
186-
187-
for field in fields:
188-
name = field.get("name", "")
189-
value = field.get("value", "")
190-
191-
if isinstance(name, str) and len(name) > max_field_name_length:
192-
field["name"] = _truncate_text(name, max_field_name_length)
193-
if isinstance(value, str) and len(value) > max_field_value_length:
194-
field["value"] = _truncate_text(
195-
value, max_field_value_length, "\n*(truncated)*"
196-
)
197-
198-
return fields
199-
200-
201-
@rate_limited(max_calls=1, interval=1800)
202-
def send_discord_webhook_message(
203-
*,
204-
title: str | None = None,
205-
description: str | None = None,
206-
url: str | None = None,
207-
fields: list[dict[str, Any]] | None = None,
208-
color: int | None = None,
209-
) -> httpx.Response | None:
210-
"""Helper method for sending a Discord webhook message using modern embed syntax.
211-
It's limited to one call per 30 minutes with the same parameters.
212-
213-
Args:
214-
title: Optional title for the embed (max 256 chars)
215-
description: Optional description text (max 4096 chars)
216-
url: Optional URL to make the title clickable
217-
fields: Optional list of field dicts with 'name', 'value', and optional 'inline' keys
218-
color: Optional color for the embed (decimal format, e.g., 0xFF0000 for red)
219-
"""
220-
if not settings.discord_webhook_enabled:
221-
logger.error("{}: {}", title, description)
222-
return None
223-
224-
# Apply Discord embed length limits
225-
if title:
226-
title = _truncate_text(title, 250)
227-
if description:
228-
description = _truncate_text(description, 4000, "\n\n*(truncated)*")
229-
if fields:
230-
fields = _truncate_embed_fields(fields)
231-
232-
# Build the embed payload
233-
embed = {
234-
"color": color or 0xE74C3C, # Default to red for errors/alerts
235-
"timestamp": datetime.now(UTC).isoformat(),
236-
}
237-
238-
if title:
239-
embed["title"] = title
240-
if description:
241-
embed["description"] = description
242-
if url:
243-
embed["url"] = url
244-
if fields:
245-
embed["fields"] = fields
246-
247-
payload = {"username": "OverFast API", "embeds": [embed]}
248-
249-
return httpx.post( # pragma: no cover
250-
settings.discord_webhook_url, json=payload, timeout=10
251-
)
252-
253-
254110
@cache
255111
def get_human_readable_duration(duration: int) -> str:
256112
# Define the time units

app/domain/exceptions.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Set of custom exceptions used in the API"""
22

3-
from fastapi import status
3+
from http import HTTPStatus
44

55

66
class RateLimitedError(Exception):
@@ -21,7 +21,7 @@ def __str__(self) -> str:
2121
class OverfastError(Exception):
2222
"""Generic OverFast API Exception"""
2323

24-
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
24+
status_code = HTTPStatus.INTERNAL_SERVER_ERROR.value
2525
message = "OverFast API Error"
2626

2727
def __str__(self):
@@ -33,7 +33,7 @@ class ParserBlizzardError(OverfastError):
3333
initialization, usually when the data is not available
3434
"""
3535

36-
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
36+
status_code = HTTPStatus.INTERNAL_SERVER_ERROR.value
3737
message = "Parser Blizzard Error"
3838

3939
def __init__(self, status_code: int, message: str):
@@ -45,14 +45,30 @@ def __init__(self, status_code: int, message: str):
4545
class ParserParsingError(OverfastError):
4646
"""Exception raised when there was an error during data parsing"""
4747

48-
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
48+
status_code = HTTPStatus.INTERNAL_SERVER_ERROR.value
4949
message = "Parser Parsing Error"
5050

5151
def __init__(self, message: str):
5252
super().__init__()
5353
self.message = message
5454

5555

56+
class ParserInternalError(OverfastError):
57+
"""Raised by domain services when an unexpected parsing failure occurs.
58+
59+
Carries the ``blizzard_url`` that was being parsed and the underlying
60+
``cause`` so the API layer can call ``overfast_internal_error`` and send
61+
an alert without the domain needing to know about FastAPI or Discord.
62+
"""
63+
64+
message = "Internal Server Error"
65+
66+
def __init__(self, blizzard_url: str, cause: Exception):
67+
super().__init__()
68+
self.blizzard_url = blizzard_url
69+
self.cause = cause
70+
71+
5672
class SearchDataRetrievalError(OverfastError):
5773
"""Generic search data retrieval Exception (namecards, titles, etc.)"""
5874

0 commit comments

Comments
 (0)