-
Notifications
You must be signed in to change notification settings - Fork 14
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: added configurable rate limiting system (#205)
* feat: added configurable rate limiting system * feat: finished rate limit feature * fix: updated nginx template
- Loading branch information
Showing
33 changed files
with
525 additions
and
149 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
@@ -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", | ||
|
@@ -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 = [ | ||
|
@@ -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 | ||
|
@@ -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 | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)}, | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.