66import logging
77import time
88from base64 import urlsafe_b64decode
9+ from dataclasses import dataclass
910from typing import Any , ClassVar , Self , cast
1011
11- import httpx
12+ from aiohttp import ClientSession , ClientTimeout
1213
1314logger = 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+
1631class GraphQLClientError (Exception ):
1732 """Base exception for GraphQL client errors."""
1833
1934
2035class 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:
2944class 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
8196class 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