mirror of
https://github.com/rangermix/TwitchDropsMiner.git
synced 2026-05-26 23:19:39 +00:00
Simplified the Translator class to be more direct and Pythonic, and
updated all translation access from callable pattern to dict access.
Translator changes (src/i18n/translator.py):
- Removed complex fallback/merging logic with English translations
- Simplified from callable pattern _("key", "subkey") to dict access _.t["key"]["subkey"]
- Removed _load_english_translation() and module-level English translation
- Changed from list of language names to dict of Translation objects
- Removed unnecessary imports (abc, json_utils, MinerException, TYPE_CHECKING)
- Made language storage and access more direct (self.t property)
- Simplified set_language() to direct dict lookup
Translation access pattern changes (all other files):
- Changed from _("key", "subkey") to _.t["key"]["subkey"] throughout codebase
- This is more Pythonic and aligns with TypedDict structure
- Applied in: __main__.py, http_client.py, auth_state.py, models/drop.py,
services (inventory, message_handlers, watch), web managers (login, settings),
websocket.py
Benefits:
- More Pythonic: Direct dict access instead of callable
- Simpler: Removed ~70 lines of complex fallback logic
- Type-safe: Direct access to TypedDict structure
- More explicit: _.t["key"]["subkey"] shows the structure clearly
- Easier to maintain: No complex merging or fallback logic
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
234 lines
7.3 KiB
Python
234 lines
7.3 KiB
Python
"""
|
|
HTTP client for Twitch API requests.
|
|
|
|
Handles HTTP session management, request retries, and connection quality settings.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from collections import abc
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import TYPE_CHECKING
|
|
|
|
import aiohttp
|
|
from yarl import URL
|
|
|
|
from src.config import COOKIES_PATH
|
|
from src.exceptions import ExitRequest, RequestInvalid
|
|
from src.i18n import _
|
|
from src.utils import ExponentialBackoff
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
from src.config import ClientInfo
|
|
from src.config.settings import Settings
|
|
from src.core.client import Twitch
|
|
from src.web.gui_manager import WebGUIManager
|
|
|
|
|
|
logger = logging.getLogger("TwitchDrops")
|
|
|
|
|
|
class HTTPClient:
|
|
"""
|
|
Manages HTTP session and handles request retries with exponential backoff.
|
|
|
|
This client provides:
|
|
- Session management with cookie persistence
|
|
- Automatic request retries on connection errors
|
|
- Connection quality-based timeout configuration
|
|
- Proxy support
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
settings: Settings,
|
|
gui: WebGUIManager,
|
|
twitch: Twitch,
|
|
client_type: ClientInfo,
|
|
):
|
|
"""
|
|
Initialize the HTTP client.
|
|
|
|
Parameters
|
|
----------
|
|
settings : Settings
|
|
Application settings for connection quality and proxy configuration
|
|
gui : WebGUIManager
|
|
GUI manager for user notifications
|
|
twitch : Twitch
|
|
Twitch client for state checking
|
|
client_type : ClientInfo
|
|
Client type information (User-Agent, Client-ID, etc.)
|
|
"""
|
|
self.settings = settings
|
|
self.gui = gui
|
|
self._twitch = twitch
|
|
self._client_type = client_type
|
|
self._session: aiohttp.ClientSession | None = None
|
|
|
|
async def get_session(self) -> aiohttp.ClientSession:
|
|
"""
|
|
Get or create the HTTP session.
|
|
|
|
Returns
|
|
-------
|
|
aiohttp.ClientSession
|
|
The active HTTP session
|
|
|
|
Raises
|
|
------
|
|
RuntimeError
|
|
If the session is closed
|
|
"""
|
|
if (session := self._session) is not None:
|
|
if session.closed:
|
|
raise RuntimeError("Session is closed")
|
|
return session
|
|
|
|
# Load cookies
|
|
cookie_jar = aiohttp.CookieJar()
|
|
try:
|
|
if COOKIES_PATH.exists():
|
|
cookie_jar.load(COOKIES_PATH)
|
|
except Exception:
|
|
# If loading cookies fails, clear the jar and continue
|
|
cookie_jar.clear()
|
|
|
|
# Create timeouts based on connection quality
|
|
connection_quality = self.settings.connection_quality
|
|
if connection_quality < 1:
|
|
connection_quality = self.settings.connection_quality = 1
|
|
elif connection_quality > 6:
|
|
connection_quality = self.settings.connection_quality = 6
|
|
|
|
timeout = aiohttp.ClientTimeout(
|
|
sock_connect=5 * connection_quality,
|
|
total=10 * connection_quality,
|
|
)
|
|
|
|
# Create session with connection pooling
|
|
connector = aiohttp.TCPConnector(limit=50)
|
|
self._session = aiohttp.ClientSession(
|
|
timeout=timeout,
|
|
connector=connector,
|
|
cookie_jar=cookie_jar,
|
|
headers={"User-Agent": self._client_type.USER_AGENT},
|
|
)
|
|
return self._session
|
|
|
|
@asynccontextmanager
|
|
async def request(
|
|
self,
|
|
method: str,
|
|
url: URL | str,
|
|
*,
|
|
invalidate_after: datetime | None = None,
|
|
**kwargs,
|
|
) -> abc.AsyncIterator[aiohttp.ClientResponse]:
|
|
"""
|
|
Make an HTTP request with automatic retries.
|
|
|
|
Parameters
|
|
----------
|
|
method : str
|
|
HTTP method (GET, POST, etc.)
|
|
url : URL | str
|
|
Request URL
|
|
invalidate_after : datetime | None, optional
|
|
Datetime after which the request should not be retried
|
|
**kwargs
|
|
Additional arguments passed to aiohttp.ClientSession.request
|
|
|
|
Yields
|
|
------
|
|
aiohttp.ClientResponse
|
|
The HTTP response
|
|
|
|
Raises
|
|
------
|
|
ExitRequest
|
|
If the application is closing
|
|
RequestInvalid
|
|
If the request expires during retry loop
|
|
aiohttp.ClientConnectorCertificateError
|
|
If SSL verification fails
|
|
"""
|
|
session = await self.get_session()
|
|
method = method.upper()
|
|
|
|
if self.settings.proxy and "proxy" not in kwargs:
|
|
kwargs["proxy"] = self.settings.proxy
|
|
|
|
logger.debug(f"Request: ({method=}, {url=}, {kwargs=})")
|
|
session_timeout = timedelta(seconds=session.timeout.total or 0)
|
|
backoff = ExponentialBackoff(maximum=3 * 60)
|
|
|
|
for delay in backoff:
|
|
from src.config import State
|
|
|
|
if self._twitch._state == State.EXIT:
|
|
raise ExitRequest()
|
|
elif (
|
|
invalidate_after is not None
|
|
# Account for expiration landing during the request
|
|
and datetime.now(timezone.utc) >= (invalidate_after - session_timeout)
|
|
):
|
|
raise RequestInvalid()
|
|
|
|
try:
|
|
response: aiohttp.ClientResponse | None = None
|
|
response = await session.request(method, url, **kwargs)
|
|
assert response is not None
|
|
logger.debug(f"Response: {response.status}: {response}")
|
|
|
|
if response.status < 500:
|
|
# Pre-read the response to avoid getting errors outside the context manager
|
|
raw_response = await response.read() # noqa: F841
|
|
yield response
|
|
return
|
|
|
|
self.gui.print(_.t["error"]["site_down"].format(seconds=round(delay)))
|
|
except aiohttp.ClientConnectorCertificateError:
|
|
# SSL verification failures should not be retried
|
|
raise
|
|
except (
|
|
aiohttp.ClientConnectionError,
|
|
asyncio.TimeoutError,
|
|
aiohttp.ClientPayloadError,
|
|
):
|
|
# Connection problems, retry with backoff
|
|
if backoff.steps > 1:
|
|
# Don't show quick retries to the user
|
|
self.gui.print(_.t["error"]["no_connection"].format(seconds=round(delay)))
|
|
finally:
|
|
if response is not None:
|
|
response.release()
|
|
|
|
# Wait for the backoff delay
|
|
await asyncio.sleep(delay)
|
|
|
|
async def close(self) -> None:
|
|
"""
|
|
Close the HTTP session and save cookies.
|
|
|
|
This should be called during application shutdown.
|
|
"""
|
|
if self._session is not None:
|
|
cookie_jar = self._session.cookie_jar
|
|
assert isinstance(cookie_jar, aiohttp.CookieJar)
|
|
|
|
# Clear empty cookie entries before saving
|
|
# NOTE: Unfortunately, aiohttp provides no easy way of clearing empty cookies,
|
|
# so we need to access the private '_cookies' attribute
|
|
for cookie_key, cookie in list(cookie_jar._cookies.items()):
|
|
if not cookie:
|
|
del cookie_jar._cookies[cookie_key]
|
|
|
|
cookie_jar.save(COOKIES_PATH)
|
|
await self._session.close()
|
|
self._session = None
|