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
2 changes: 1 addition & 1 deletion .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
uses: ./.github/actions/setup

- name: Run integration tests
run: uv run pytest -m "integration"
run: uv run pytest -m "integration" --log-cli-level=WARNING -v

- name: Upload integration test report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Expand Down
3 changes: 2 additions & 1 deletion compuglobal/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
MasterOfAllScience,
Morbotron,
)
from compuglobal.errors import APIPageStatusError, NoSearchResultsFoundError
from compuglobal.errors import APIPageStatusError, MaximumRetriesExceededError, NoSearchResultsFoundError
from compuglobal.models.comic import ComicLayout, ComicOverlay, ComicPanel, ComicStrip
from compuglobal.models.episode import Episode, EpisodeMetadata, EpisodeSummary
from compuglobal.models.font import FontAlignment, FontColor, FontFamily
Expand Down Expand Up @@ -42,6 +42,7 @@
"FrameResult",
"Frinkiac",
"MasterOfAllScience",
"MaximumRetriesExceededError",
"Morbotron",
"NoSearchResultsFoundError",
"OverlayFormat",
Expand Down
11 changes: 9 additions & 2 deletions compuglobal/aio.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ class AsyncCompuGlobalAPI:
The client session to use for all API calls
default_format : OverlayFormat | None, optional
The default overlay format to use for all overlays/subtitles
max_retries : int, optional
The maximum number of retries for all API requests

Attributes
----------
Expand Down Expand Up @@ -64,7 +66,12 @@ class AsyncCompuGlobalAPI:
media: MediaAPI = MediaAPI()
metadata: MetadataAPI = MetadataAPI()

def __init__(self, session: aiohttp.ClientSession, default_format: OverlayFormat | None = None) -> None:
def __init__(
self,
session: aiohttp.ClientSession,
default_format: OverlayFormat | None = None,
max_retries: int = 0,
) -> None:
extra_fonts = list(self.EXTRA_FONTS)
if default_format is None:
chosen_font = extra_fonts[0] if len(extra_fonts) > 0 else FontFamily.IMPACT
Expand All @@ -77,7 +84,7 @@ def __init__(self, session: aiohttp.ClientSession, default_format: OverlayFormat
allowed_fonts=allowed_fonts,
default_format=default_format,
)
self.client = CompuGlobalAPIClient(base_url=self.BASE_URL, session=session)
self.client = CompuGlobalAPIClient(base_url=self.BASE_URL, session=session, max_retries=max_retries)

async def get_screencap(
self,
Expand Down
65 changes: 56 additions & 9 deletions compuglobal/api/client.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
"""Module for handling API requests to CGHMC APIs."""

import asyncio
import logging
from http import HTTPStatus
from typing import Any

from aiohttp import ClientSession

from compuglobal.api.endpoint import PreparedRequest, RequestMethod
from compuglobal.errors import APIPageStatusError
from compuglobal.errors import APIPageStatusError, MaximumRetriesExceededError

log = logging.getLogger(__name__)

Expand All @@ -21,12 +22,15 @@ class CompuGlobalAPIClient:
The base URL of the API (e.g. https://frinkiac.com)
session : ClientSession
The client session to use for all API requests
max_retries : int, optional
The maximum number of retries for each request before raising an :class:`APIPageStatusError`

"""

def __init__(self, base_url: str, session: ClientSession) -> None:
def __init__(self, base_url: str, session: ClientSession, max_retries: int = 0) -> None:
self.base_url = base_url
self.session = session
self.max_retries = max_retries

async def get(self, url: str, params: dict[str, Any] | None = None) -> dict[str, Any] | list[Any]:
"""Get the JSON response from the API using the given url, and params.
Expand Down Expand Up @@ -54,7 +58,11 @@ async def get(self, url: str, params: dict[str, Any] | None = None) -> dict[str,
log.debug("Response %s | GET %s", response.status, url)
return await response.json()

log.error("Non-2xx response %s | GET %s", response.status, url)
if response.status == HTTPStatus.TOO_MANY_REQUESTS:
retry_after = int(response.headers.get("Retry-After", 60))
raise APIPageStatusError(response.status, self.base_url, retry_after=retry_after)

log.error("Non-2xx response %s | POST %s | Headers %s", response.status, url, response.headers)
raise APIPageStatusError(response.status, self.base_url)

async def post_data(self, url: str, json: dict[str, Any] | list[Any] | None) -> str:
Expand Down Expand Up @@ -83,7 +91,11 @@ async def post_data(self, url: str, json: dict[str, Any] | list[Any] | None) ->
log.debug("Response %s | POST %s", response.status, url)
return await response.text()

log.error("Non-2xx response %s | POST %s")
log.error("Non-2xx response %s | POST %s | Headers %s", response.status, url, response.headers)
if response.status == HTTPStatus.TOO_MANY_REQUESTS:
retry_after = int(response.headers.get("Retry-After", 60))
raise APIPageStatusError(response.status, self.base_url, retry_after=retry_after)

raise APIPageStatusError(response.status, self.base_url)

async def handle_request(self, request: PreparedRequest) -> str | dict[str, Any] | list[Any]:
Expand All @@ -99,9 +111,44 @@ async def handle_request(self, request: PreparedRequest) -> str | dict[str, Any]
str | dict[str, Any] | list[Any]
The json response from the API

"""
log.debug("%s %s | params=%s | body=%s", request.method.value, request.url, request.params, request.body)
if request.method == RequestMethod.POST:
return await self.post_data(request.url, json=request.body)
Raises
------
APIPageStatusError
Raised immediately for non-2xx responses that are not retryable (e.g. anything other than a 429).
MaximumRetriesExceededError
Raised when the maximum number of retries is exceeded.

return await self.get(request.url, params=request.params)
"""
for attempt in range(self.max_retries + 1):
try:
log.debug(
"%s %s | params=%s | body=%s",
request.method.value,
request.url,
request.params,
request.body,
)
if request.method == RequestMethod.POST:
return await self.post_data(request.url, json=request.body)

return await self.get(request.url, params=request.params)

except APIPageStatusError as error:
if error.page_status != HTTPStatus.TOO_MANY_REQUESTS or attempt >= self.max_retries:
raise

log.warning(
"Rate limited (429) | %s %s | retrying in %.2fs (attempt %d/%d)",
request.method.value,
request.url,
error.retry_after,
attempt + 1,
self.max_retries + 1,
)

if error.retry_after is not None:
# Add a buffer of 50-1000ms depending on retry amount
buffer = max(0.05, min(1, error.retry_after * 0.02))
await asyncio.sleep(error.retry_after + buffer)

raise MaximumRetriesExceededError
22 changes: 20 additions & 2 deletions compuglobal/errors.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""All errors/exceptions returned by CompuGlobal APIs."""

from http import HTTPStatus


class NoSearchResultsFoundError(Exception):
"""Raised when no search results are found."""
Expand All @@ -8,6 +10,13 @@ def __init__(self, message: str = "No search results found.") -> None:
super().__init__(message)


class MaximumRetriesExceededError(Exception):
"""Raised when the maximum number of retries is exceeded."""

def __init__(self, message: str = "Maximum number of retries exceeded.") -> None:
super().__init__(message)


class APIPageStatusError(Exception):
"""Raised when the status code for the API is not 200.

Expand All @@ -17,8 +26,17 @@ class APIPageStatusError(Exception):
The page status code returned by the API.
url : str
The base url that raised the error.
retry_after : int | None
Number of seconds to wait before retrying if error was a 429.

"""

def __init__(self, page_status: int, url: str) -> None:
super().__init__(f"Error {page_status}. {url} may be down.")
def __init__(self, page_status: int, url: str, retry_after: int | None = None) -> None:
self.page_status = page_status
self.url = url
self.retry_after = retry_after

msg = f"Error {page_status}. {url} may be down."
if page_status == HTTPStatus.TOO_MANY_REQUESTS and self.retry_after is not None:
msg += f" Retry after {retry_after} seconds."
super().__init__(msg)
2 changes: 1 addition & 1 deletion tests/integration/test_aio_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
async def api(request: pytest.FixtureRequest) -> AsyncGenerator[AsyncCompuGlobalAPI]:
api_class = request.param
async with aiohttp.ClientSession() as session:
yield api_class(session=session)
yield api_class(session=session, max_retries=1)


@pytest_asyncio.fixture
Expand Down