Skip to content

Commit 8933b59

Browse files
committed
feat: migrate HTTP backend from httpx to aiohttp
1 parent 3018874 commit 8933b59

8 files changed

Lines changed: 850 additions & 345 deletions

File tree

deezer_python_gql/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Async typed Python client for Deezer's Pipe GraphQL API."""
22

33
from deezer_python_gql.generated.base_client import (
4+
GQLResponse,
45
GraphQLClientError,
56
GraphQLClientGraphQLError,
67
GraphQLClientGraphQLMultiError,
@@ -11,6 +12,7 @@
1112

1213
__all__ = [
1314
"DeezerGQLClient",
15+
"GQLResponse",
1416
"GraphQLClientError",
1517
"GraphQLClientGraphQLError",
1618
"GraphQLClientGraphQLMultiError",

deezer_python_gql/base_client.py

Lines changed: 71 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,36 @@
66
import logging
77
import time
88
from base64 import urlsafe_b64decode
9+
from dataclasses import dataclass
910
from typing import Any, ClassVar, Self, cast
1011

11-
import httpx
12+
from aiohttp import ClientSession, ClientTimeout
1213

1314
logger = logging.getLogger(__name__)
1415

1516

17+
@dataclass(frozen=True, slots=True)
18+
class GQLResponse:
19+
"""
20+
Lightweight response container for GraphQL HTTP responses.
21+
22+
aiohttp responses cannot escape their context manager, so we capture
23+
the essential fields inside the ``async with`` block and return this.
24+
"""
25+
26+
status: int
27+
data: bytes
28+
is_success: bool
29+
30+
1631
class GraphQLClientError(Exception):
1732
"""Base exception for GraphQL client errors."""
1833

1934

2035
class GraphQLClientHttpError(GraphQLClientError):
2136
"""Raised when the HTTP response indicates an error."""
2237

23-
def __init__(self, status_code: int, response: httpx.Response) -> None:
38+
def __init__(self, status_code: int, response: GQLResponse) -> None:
2439
self.status_code = status_code
2540
self.response = response
2641
super().__init__(f"HTTP status code: {status_code}")
@@ -29,7 +44,7 @@ def __init__(self, status_code: int, response: httpx.Response) -> None:
2944
class GraphQLClientInvalidResponseError(GraphQLClientError):
3045
"""Raised when the response cannot be parsed as valid GraphQL."""
3146

32-
def __init__(self, response: httpx.Response) -> None:
47+
def __init__(self, response: GQLResponse) -> None:
3348
self.response = response
3449
super().__init__("Invalid response format")
3550

@@ -79,17 +94,15 @@ def from_errors_dicts(
7994

8095

8196
class DeezerBaseClient:
82-
"""Async HTTP client for Deezer's Pipe GraphQL API with ARL-based auth.
83-
84-
Handles the ARL cookie → JWT token exchange and automatic refresh.
85-
This class is used as the base client for ariadne-codegen's generated client.
97+
"""
98+
Async HTTP client for Deezer's Pipe GraphQL API with ARL-based auth.
8699
87-
Manages its own httpx connection pool by default. Pass an external
88-
``http_client`` only if you need to share a pool across multiple clients.
100+
Manages its own aiohttp session by default. Pass an external
101+
``session`` to share a connection pool across multiple clients.
89102
90103
:param arl: Deezer ARL cookie value for authentication.
91104
:param url: GraphQL endpoint URL (defaults to Pipe API).
92-
:param http_client: Optional pre-configured httpx.AsyncClient.
105+
:param session: Optional pre-configured aiohttp.ClientSession.
93106
If provided, the caller is responsible for closing it.
94107
"""
95108

@@ -101,33 +114,34 @@ def __init__(
101114
self,
102115
arl: str,
103116
url: str = PIPE_URL,
104-
http_client: httpx.AsyncClient | None = None,
117+
session: ClientSession | None = None,
105118
) -> None:
106119
self.url = url
107120
self._arl = arl
108-
self._http_client = http_client
109-
self._owns_http_client = http_client is None
121+
self._session = session
122+
self._owns_session = session is None
110123
self._jwt: str | None = None
111124
self._jwt_expires_at: float = 0
112125
self._last_operation_name: str | None = None
113126
self._last_variables: dict[str, Any] | None = None
114127

115-
def _get_http_client(self) -> httpx.AsyncClient:
116-
"""Return the HTTP client, creating an internal one if needed."""
117-
if self._http_client is None:
118-
self._http_client = httpx.AsyncClient()
119-
self._owns_http_client = True
120-
return self._http_client
128+
def _get_session(self) -> ClientSession:
129+
"""Return the HTTP session, creating an internal one if needed."""
130+
if self._session is None:
131+
self._session = ClientSession()
132+
self._owns_session = True
133+
return self._session
121134

122135
async def close(self) -> None:
123-
"""Close the internal HTTP client if we own it.
136+
"""
137+
Close the internal HTTP session if we own it.
124138
125139
Safe to call multiple times. Does nothing if an external
126-
``http_client`` was provided at construction time.
140+
``session`` was provided at construction time.
127141
"""
128-
if self._owns_http_client and self._http_client is not None:
129-
await self._http_client.aclose()
130-
self._http_client = None
142+
if self._owns_session and self._session is not None:
143+
await self._session.close()
144+
self._session = None
131145

132146
async def __aenter__(self) -> Self:
133147
"""Enter the async context manager."""
@@ -143,15 +157,14 @@ async def execute(
143157
operation_name: str | None = None,
144158
variables: dict[str, Any] | None = None,
145159
**kwargs: Any,
146-
) -> httpx.Response:
147-
"""Execute a GraphQL query against the Pipe API.
148-
149-
Automatically handles JWT acquisition and refresh from the ARL cookie.
160+
) -> GQLResponse:
161+
"""
162+
Execute a GraphQL query against the Pipe API.
150163
151164
:param query: The GraphQL query string.
152165
:param operation_name: Optional operation name for multi-operation documents.
153166
:param variables: Optional query variables.
154-
:param kwargs: Additional keyword arguments passed to httpx.
167+
:param kwargs: Additional keyword arguments passed to the session request.
155168
"""
156169
logger.debug("GQL execute: %s (variables=%s)", operation_name or "<unnamed>", variables)
157170
self._last_operation_name = operation_name
@@ -174,34 +187,34 @@ async def execute(
174187
k: v for k, v in variables.items() if not isinstance(v, UnsetType)
175188
}
176189

177-
client = self._get_http_client()
178-
resp = await client.post(
179-
self.url,
180-
json=payload,
181-
headers=headers,
182-
**kwargs,
183-
)
190+
session = self._get_session()
191+
async with session.post(self.url, json=payload, headers=headers, **kwargs) as resp:
192+
body = await resp.read()
193+
gql_response = GQLResponse(
194+
status=resp.status,
195+
data=body,
196+
is_success=resp.ok,
197+
)
198+
184199
logger.debug(
185200
"GQL response: %s status=%s length=%s",
186201
operation_name or "<unnamed>",
187-
resp.status_code,
188-
len(resp.content),
202+
gql_response.status,
203+
len(gql_response.data),
189204
)
190-
return resp
205+
return gql_response
191206

192-
def get_data(self, response: httpx.Response) -> dict[str, Any]:
193-
"""Parse a GraphQL response and return the data dict.
194-
195-
Handles the Pipe API's text/plain content type and standard
196-
GraphQL error responses.
207+
def get_data(self, response: GQLResponse) -> dict[str, Any]:
208+
"""
209+
Parse a GraphQL response and return the data dict.
197210
198-
:param response: The HTTP response from execute().
211+
:param response: The GQLResponse from execute().
199212
"""
200213
if not response.is_success:
201-
raise GraphQLClientHttpError(status_code=response.status_code, response=response)
214+
raise GraphQLClientHttpError(status_code=response.status, response=response)
202215

203216
try:
204-
response_json = response.json()
217+
response_json = json.loads(response.data)
205218
except ValueError as exc:
206219
raise GraphQLClientInvalidResponseError(response=response) from exc
207220

@@ -268,29 +281,26 @@ def _inject_missing_typenames(cls, obj: Any) -> None:
268281
cls._inject_missing_typenames(item)
269282

270283
async def _ensure_jwt(self) -> str:
271-
"""Acquire or refresh the JWT token from ARL cookie.
272-
273-
The Pipe API uses short-lived JWTs (~6 min TTL) obtained by
274-
POSTing the ARL cookie to auth.deezer.com. The response is
275-
Content-Type: text/plain containing JSON.
276-
"""
284+
"""Acquire or refresh the JWT token from ARL cookie."""
277285
now = time.time()
278286
if self._jwt and now < (self._jwt_expires_at - self.JWT_REFRESH_MARGIN_SECONDS):
279287
return self._jwt
280288

281289
logger.debug("JWT expired or missing, refreshing from ARL")
282290
params = {"jo": "p", "rto": "c", "i": "c"}
283291

284-
client = self._get_http_client()
285-
resp = await client.post(
292+
session = self._get_session()
293+
async with session.post(
286294
self.AUTH_URL,
287295
params=params,
288296
cookies={"arl": self._arl},
289-
)
290-
resp.raise_for_status()
297+
timeout=ClientTimeout(total=10),
298+
) as resp:
299+
resp.raise_for_status()
300+
# Response body is text/plain containing JSON
301+
text = await resp.text()
291302

292-
# Response body is text/plain containing JSON
293-
data = json.loads(resp.text)
303+
data = json.loads(text)
294304
self._jwt = data["jwt"]
295305

296306
# Decode expiration from JWT payload (second segment, base64url-encoded)
@@ -304,11 +314,8 @@ async def _ensure_jwt(self) -> str:
304314
return self._jwt
305315

306316
async def check_audiobook_ids(self, album_ids: list[str]) -> set[str]:
307-
"""Check which album IDs are also valid audiobooks on Deezer.
308-
309-
Uses GraphQL aliases to batch-check many IDs in a single request.
310-
Returns the subset of IDs that are audiobooks (i.e., the audiobook
311-
query returns non-null for them).
317+
"""
318+
Check which album IDs are also valid audiobooks on Deezer.
312319
313320
:param album_ids: List of Deezer album/audiobook IDs to check.
314321
"""

0 commit comments

Comments
 (0)