Skip to content

Commit

Permalink
feat: added configurable rate limiting system (#205)
Browse files Browse the repository at this point in the history
* feat: added configurable rate limiting system

* feat: finished rate limit feature

* fix: updated nginx template
  • Loading branch information
TeKrop authored Oct 26, 2024
1 parent 332f684 commit b8f66de
Show file tree
Hide file tree
Showing 33 changed files with 525 additions and 149 deletions.
7 changes: 6 additions & 1 deletion .env.dist
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ APP_BASE_URL=https://overfast-api.tekrop.fr
LOG_LEVEL=info
MAX_CONCURRENT_REQUESTS=5
STATUS_PAGE_URL=
TOO_MANY_REQUESTS_RESPONSE=false

# Rate limiting
BLIZZARD_RATE_LIMIT_RETRY_AFTER=5
RATE_LIMIT_PER_SECOND_PER_IP=10
RATE_LIMIT_PER_IP_BURST=2
MAX_CONNECTIONS_PER_IP=5

# Redis
REDIS_CACHING_ENABLED=true
Expand Down
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,11 @@ up: ## Build & run OverFastAPI application (production mode)

down: ## Stop the app and remove containers
@echo "Stopping OverFastAPI and cleaning containers..."
docker compose down -v --remove-orphans
docker compose --profile "*" down -v --remove-orphans

up-testing: ## Run OverFastAPI application (testing mode)
@echo "Launching OverFastAPI (testing mode)..."
docker compose --profile testing up -d

clean: down ## Clean up Docker environment
@echo "Cleaning Docker environment..."
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@


## [Live instance](https://overfast-api.tekrop.fr)
The live instance is restricted to **30 req/s** per IP (a shared limit across all endpoints). If you require more, consider hosting your own instance on a server 👍
The live instance is restricted with a rate limit around 10 req/s per IP (a shared limit across all endpoints). This limit may be adjusted as needed. If you require higher throughput, consider hosting your own instance on a server 👍

- Live instance (Redoc documentation) : https://overfast-api.tekrop.fr/
- Swagger UI : https://overfast-api.tekrop.fr/docs
Expand All @@ -44,6 +44,7 @@ Then, execute the following commands to launch the dev server :
```shell
make build # Build the images, needed for all further commands
make start # Launch OverFast API (dev mode)
make testing # Launch OverFast API (testing mode with reverse proxy)
```
The dev server will be running on the port `8000`. You can use the `make down` command to stop and remove the containers. Feel free to type `make` or `make help` to access a comprehensive list of all available commands for your reference.

Expand Down
6 changes: 3 additions & 3 deletions app/commands/check_and_update_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@

import asyncio

import httpx
from fastapi import HTTPException

from app.common.cache_manager import CacheManager
from app.common.exceptions import ParserBlizzardError, ParserParsingError
from app.common.helpers import overfast_client_settings, overfast_internal_error
from app.common.helpers import overfast_internal_error
from app.common.logging import logger
from app.common.overfast_client import OverFastClient
from app.config import settings
from app.parsers.gamemodes_parser import GamemodesParser
from app.parsers.generics.abstract_parser import AbstractParser
Expand Down Expand Up @@ -112,7 +112,7 @@ async def main():
logger.info("Done ! Retrieved keys : {}", len(keys_to_update))

# Instanciate one HTTPX Client to use for all the updates
client = httpx.AsyncClient(**overfast_client_settings)
client = OverFastClient()

tasks = []
for key in keys_to_update:
Expand Down
8 changes: 4 additions & 4 deletions app/commands/check_new_hero.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,17 @@

import asyncio

import httpx
from fastapi import HTTPException

from app.common.enums import HeroKey
from app.common.helpers import overfast_client_settings, send_discord_webhook_message
from app.common.helpers import send_discord_webhook_message
from app.common.logging import logger
from app.common.overfast_client import OverFastClient
from app.config import settings
from app.parsers.heroes_parser import HeroesParser


async def get_distant_hero_keys(client: httpx.AsyncClient) -> set[str]:
async def get_distant_hero_keys(client: OverFastClient) -> set[str]:
"""Get a set of Overwatch hero keys from the Blizzard heroes page"""
heroes_parser = HeroesParser(client=client)

Expand All @@ -42,7 +42,7 @@ async def main():
logger.info("OK ! Starting to check if a new hero is here...")

# Instanciate one HTTPX Client to use for all the updates
client = httpx.AsyncClient(**overfast_client_settings)
client = OverFastClient()

distant_hero_keys = await get_distant_hero_keys(client)
local_hero_keys = get_local_hero_keys()
Expand Down
16 changes: 16 additions & 0 deletions app/common/cache_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,19 @@ def update_parser_cache_last_update(self, cache_key: str, expire: int) -> None:
@redis_connection_handler
def delete_keys(self, keys: Iterable[str]) -> None:
self.redis_server.delete(*keys)

@redis_connection_handler
def is_being_rate_limited(self) -> bool:
return self.redis_server.exists(settings.blizzard_rate_limit_key)

@redis_connection_handler
def get_global_rate_limit_remaining_time(self) -> int:
return self.redis_server.ttl(settings.blizzard_rate_limit_key)

@redis_connection_handler
def set_global_rate_limit(self) -> None:
self.redis_server.set(
settings.blizzard_rate_limit_key,
value=0,
ex=settings.blizzard_rate_limit_retry_after,
)
73 changes: 19 additions & 54 deletions app/common/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@
from typing import TYPE_CHECKING, Any

import httpx
from fastapi import HTTPException, Request, status
from fastapi import HTTPException, status

from app.config import settings
from app.models.errors import BlizzardErrorMessage, InternalServerErrorMessage
from app.models.errors import (
BlizzardErrorMessage,
InternalServerErrorMessage,
RateLimitErrorMessage,
)

from .decorators import rate_limited
from .logging import logger
Expand All @@ -23,6 +27,19 @@

# Typical routes responses to return
routes_responses = {
status.HTTP_429_TOO_MANY_REQUESTS: {
"model": RateLimitErrorMessage,
"description": "Rate Limit Error",
"headers": {
"Retry-After": {
"description": "Indicates how long to wait before making a new request",
"schema": {
"type": "string",
"example": "5",
},
}
},
},
status.HTTP_500_INTERNAL_SERVER_ERROR: {
"model": InternalServerErrorMessage,
"description": "Internal Server Error",
Expand All @@ -32,10 +49,6 @@
"description": "Blizzard Server Error",
},
}
if settings.too_many_requests_response:
routes_responses[status.HTTP_429_TOO_MANY_REQUESTS] = {
"description": "Rate Limit Error",
}

# List of players used for testing
players_ids = [
Expand All @@ -49,35 +62,6 @@
"JohnV1-1190", # Player without any title ingame
]

# httpx client settings
overfast_client_settings = {
"headers": {
"User-Agent": (
f"OverFastAPI v{settings.app_version} - "
"https://github.com/TeKrop/overfast-api"
),
"From": "[email protected]",
},
"http2": True,
"timeout": 10,
"follow_redirects": True,
}


async def overfast_request(client: httpx.AsyncClient, url: str) -> httpx.Response:
"""Make an HTTP GET request with custom headers and retrieve the result"""
try:
logger.debug("Requesting {}...", url)
response = await client.get(url)
except httpx.TimeoutException as error:
raise blizzard_response_error(
status_code=0,
error="Blizzard took more than 10 seconds to respond, resulting in a timeout",
) from error
else:
logger.debug("OverFast request done !")
return response


def overfast_internal_error(url: str, error: Exception) -> HTTPException:
"""Returns an Internal Server Error. Also log it and eventually send
Expand Down Expand Up @@ -110,25 +94,6 @@ def overfast_internal_error(url: str, error: Exception) -> HTTPException:
)


def blizzard_response_error(status_code: int, error: str) -> HTTPException:
"""Retrieve a generic error response when a Blizzard page doesn't load"""
logger.error(
"Received an error from Blizzard. HTTP {} : {}",
status_code,
error,
)

return HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail=f"Couldn't get Blizzard page (HTTP {status_code} error) : {error}",
)


def blizzard_response_error_from_request(req: Request) -> HTTPException:
"""Alias for sending Blizzard error from a request directly"""
return blizzard_response_error(req.status_code, req.text)


@rate_limited(max_calls=1, interval=1800)
def send_discord_webhook_message(message: str) -> httpx.Response | None:
"""Helper method for sending a Discord webhook message. It's limited to
Expand Down
112 changes: 112 additions & 0 deletions app/common/overfast_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import httpx
from fastapi import HTTPException, Request, status

from app.config import settings

from .cache_manager import CacheManager
from .helpers import send_discord_webhook_message
from .logging import logger
from .metaclasses import Singleton


class OverFastClient(metaclass=Singleton):
def __init__(self):
self.cache_manager = CacheManager()
self.client = httpx.AsyncClient(
headers={
"User-Agent": (
f"OverFastAPI v{settings.app_version} - "
"https://github.com/TeKrop/overfast-api"
),
"From": "[email protected]",
},
http2=True,
timeout=10,
follow_redirects=True,
)

async def get(self, url: str) -> httpx.Response:
"""Make an HTTP GET request with custom headers and retrieve the result"""

# First, check if we're being rate limited
self._check_rate_limit()

# Make the API call
try:
response = await self.client.get(url)
except httpx.TimeoutException as error:
raise self._blizzard_response_error(
status_code=0,
error="Blizzard took more than 10 seconds to respond, resulting in a timeout",
) from error

logger.debug("OverFast request done !")

# Make sure we catch HTTP 403 from Blizzard when it happens,
# so we don't make any more call before some amount of time
if response.status_code == status.HTTP_403_FORBIDDEN:
raise self._blizzard_forbidden_error()

return response

async def aclose(self) -> None:
"""Properly close HTTPX Async Client"""
await self.client.aclose()

def _check_rate_limit(self) -> None:
"""Make sure we're not being rate limited by Blizzard before making
any API call. Else, return an HTTP 429 with Retry-After header.
"""
if self.cache_manager.is_being_rate_limited():
raise self._too_many_requests_response(
retry_after=self.cache_manager.get_global_rate_limit_remaining_time()
)

def blizzard_response_error_from_request(self, req: Request) -> HTTPException:
"""Alias for sending Blizzard error from a request directly"""
return self._blizzard_response_error(req.status_code, req.text)

@staticmethod
def _blizzard_response_error(status_code: int, error: str) -> HTTPException:
"""Retrieve a generic error response when a Blizzard page doesn't load"""
logger.error(
"Received an error from Blizzard. HTTP {} : {}",
status_code,
error,
)

return HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail=f"Couldn't get Blizzard page (HTTP {status_code} error) : {error}",
)

def _blizzard_forbidden_error(self) -> HTTPException:
"""Retrieve a generic error response when Blizzard returns forbidden error.
Also prevent further calls to Blizzard for a given amount of time.
"""

# We have to block future requests to Blizzard, cache the information on Redis
self.cache_manager.set_global_rate_limit()

# If Discord Webhook configuration is enabled, send a message to the
# given channel using Discord Webhook URL
send_discord_webhook_message(
"Blizzard Rate Limit reached ! Blocking further calls for "
f"{settings.blizzard_rate_limit_retry_after} seconds..."
)

return self._too_many_requests_response(
retry_after=settings.blizzard_rate_limit_retry_after
)

@staticmethod
def _too_many_requests_response(retry_after: int) -> HTTPException:
"""Generic method to return an HTTP 429 response with Retry-After header"""
return HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=(
"API has been rate limited by Blizzard, please wait for "
f"{retry_after} seconds before retrying"
),
headers={"Retry-After": str(retry_after)},
)
21 changes: 18 additions & 3 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,24 @@ class Settings(BaseSettings):
# Optional, status page URL if you have any to provide
status_page_url: str | None = None

# Enable this option if you're using a reverse proxy to handle rate limits
# in order to indicate HTTP 429 as possible response from the API in doc
too_many_requests_response: bool = True
############
# RATE LIMITING
############

# Redis key for Blizzard rate limit storage
blizzard_rate_limit_key: str = "blizzard-rate-limit"

# Number of seconds before the user is authorized to make calls to Blizzard again
blizzard_rate_limit_retry_after: int = 5

# Global rate limit of requests per second per ip to apply on the API
rate_limit_per_second_per_ip: int = 10

# Global burst value to apply on rate limit before rejecting requests
rate_limit_per_ip_burst: int = 2

# Global maximum number of connection per ip
max_connections_per_ip: int = 5

############
# REDIS CONFIGURATION
Expand Down
3 changes: 1 addition & 2 deletions app/handlers/api_request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ class APIRequestHandler(ABC):

def __init__(self, request: Request):
self.cache_key = CacheManager.get_cache_key_from_request(request)
self.overfast_client = request.app.overfast_client

@property
@abstractmethod
Expand Down Expand Up @@ -52,7 +51,7 @@ async def process_request(self, **kwargs) -> dict:
# Instanciate the parser, it will check if a Parser Cache is here.
# If not, it will retrieve its associated Blizzard
# page and use the kwargs to generate the appropriate URL
parser = parser_class(client=self.overfast_client, **kwargs)
parser = parser_class(**kwargs)

# Do the parsing. Internally, it will check for Parser Cache
# before doing a real parsing using BeautifulSoup
Expand Down
Loading

0 comments on commit b8f66de

Please sign in to comment.