Skip to content

Commit 2f7c3fa

Browse files
committed
Add HttpxAsyncClient to wrap httpx.AsyncClient
1 parent a4bc029 commit 2f7c3fa

File tree

3 files changed

+440
-13
lines changed

3 files changed

+440
-13
lines changed

firebase_admin/_http_client.py

Lines changed: 180 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,20 @@
1414

1515
"""Internal HTTP client module.
1616
17-
This module provides utilities for making HTTP calls using the requests library.
18-
"""
19-
20-
from google.auth import transport
17+
This module provides utilities for making HTTP calls using the requests library.
18+
"""
19+
20+
from __future__ import annotations
21+
from typing import Any, Optional
22+
from google.auth import credentials
23+
from google.auth.transport import requests as google_auth_requests
24+
import httpx
2125
import requests
22-
from requests.packages.urllib3.util import retry # pylint: disable=import-error
26+
import requests.adapters
27+
from urllib3.util import retry
2328

2429
from firebase_admin import _utils
30+
from firebase_admin._retry import HttpxRetry, HttpxRetryTransport
2531

2632
if hasattr(retry.Retry.DEFAULT, 'allowed_methods'):
2733
_ANY_METHOD = {'allowed_methods': None}
@@ -32,7 +38,10 @@
3238
# last response upon exhausting all retries.
3339
DEFAULT_RETRY_CONFIG = retry.Retry(
3440
connect=1, read=1, status=4, status_forcelist=[500, 503],
35-
raise_on_status=False, backoff_factor=0.5, **_ANY_METHOD)
41+
raise_on_status=False, backoff_factor=0.5, allowed_methods=None)
42+
# raise_on_status=False, backoff_factor=0.5, **_ANY_METHOD)
43+
44+
DEFAULT_HTTPX_RETRY_CONFIG = HttpxRetry(status=4, status_forcelist=[500, 503], backoff_factor=0.5)
3645

3746

3847
DEFAULT_TIMEOUT_SECONDS = 120
@@ -68,7 +77,7 @@ def __init__(
6877
None to disable timeouts (optional).
6978
"""
7079
if credential:
71-
self._session = transport.requests.AuthorizedSession(credential)
80+
self._session = google_auth_requests.AuthorizedSession(credential)
7281
elif session:
7382
self._session = session
7483
else:
@@ -153,3 +162,167 @@ def __init__(self, **kwargs):
153162

154163
def parse_body(self, resp):
155164
return resp.json()
165+
166+
167+
# Auth Flow
168+
# TODO: Remove comments
169+
# The aim here is to be able to get auth credentials right before the request is sent.
170+
# This is similar to what is done in transport.requests.AuthorizedSession().
171+
# We can then pass this in at the client level.
172+
173+
# Notes:
174+
# - This implementations does not cover timeouts on requests sent to refresh credentials.
175+
# - Uses HTTP/1 and a blocking credential for refreshing.
176+
class GoogleAuthCredentialFlow(httpx.Auth):
177+
"""Google Auth Credential Auth Flow"""
178+
def __init__(self, credential: credentials.Credentials):
179+
self._credential = credential
180+
self._max_refresh_attempts = 2
181+
self._refresh_status_codes = (401,)
182+
183+
def apply_auth_headers(self, request: httpx.Request):
184+
# Build request used to refresh credentials if needed
185+
auth_request = google_auth_requests.Request()
186+
# This refreshes the credentials if needed and mutates the request headers to
187+
# contain access token and any other google auth headers
188+
self._credential.before_request(auth_request, request.method, request.url, request.headers)
189+
190+
def auth_flow(self, request: httpx.Request):
191+
# Keep original headers since `credentials.before_request` mutates the passed headers and we
192+
# want to keep the original in cause we need an auth retry.
193+
_original_headers = request.headers.copy()
194+
195+
_credential_refresh_attempt = 0
196+
while _credential_refresh_attempt <= self._max_refresh_attempts:
197+
# copy original headers
198+
request.headers = _original_headers.copy()
199+
# mutates request headers
200+
self.apply_auth_headers(request)
201+
202+
# Continue to perform the request
203+
# yield here dispatches the request and returns with the response
204+
response: httpx.Response = yield request
205+
206+
# We can check the result of the response and determine in we need to retry
207+
# on refreshable status codes. Current transport.requests.AuthorizedSession()
208+
# only does this on 401 errors. We should do the same.
209+
if response.status_code in self._refresh_status_codes:
210+
_credential_refresh_attempt += 1
211+
else:
212+
break
213+
# Last yielded response is auto returned.
214+
215+
216+
217+
class HttpxAsyncClient():
218+
def __init__(
219+
self,
220+
credential: Optional[credentials.Credentials] = None,
221+
base_url: str = '',
222+
headers: Optional[httpx.Headers] = None,
223+
retry_config: HttpxRetry = DEFAULT_HTTPX_RETRY_CONFIG,
224+
timeout: int = DEFAULT_TIMEOUT_SECONDS,
225+
http2: bool = True
226+
) -> None:
227+
"""Creates a new HttpxAsyncClient instance from the provided arguments.
228+
229+
If a credential is provided, initializes a new async HTTPX client authorized with it.
230+
Otherwise, initializes a new unauthorized async HTTPX client.
231+
232+
Args:
233+
credential: A Google credential that can be used to authenticate requests (optional).
234+
base_url: A URL prefix to be added to all outgoing requests (optional).
235+
headers: A map of headers to be added to all outgoing requests (optional).
236+
retry_config: A HttpxRetry configuration. Default settings would retry up to 4 times for
237+
HTTP 500 and 503 errors (optional).
238+
timeout: HTTP timeout in seconds. Defaults to 120 seconds when not specified (optional).
239+
http2: A boolean indicating if HTTP/2 support should be enabled. Defaults to `True` when
240+
not specified (optional).
241+
"""
242+
243+
self._base_url = base_url
244+
self._timeout = timeout
245+
self._headers = headers
246+
self._retry_config = retry_config
247+
248+
# Only set up retries on urls starting with 'http://' and 'https://'
249+
self._mounts = {
250+
'http://': HttpxRetryTransport(retry=self._retry_config, http2=http2),
251+
'https://': HttpxRetryTransport(retry=self._retry_config, http2=http2)
252+
}
253+
254+
if credential:
255+
self._async_client = httpx.AsyncClient(
256+
http2=http2,
257+
timeout=self._timeout,
258+
headers=self._headers,
259+
auth=GoogleAuthCredentialFlow(credential), # Add auth flow for credentials.
260+
mounts=self._mounts
261+
)
262+
else:
263+
self._async_client = httpx.AsyncClient(
264+
http2=http2,
265+
timeout=self._timeout,
266+
headers=self._headers,
267+
mounts=self._mounts
268+
)
269+
pass
270+
271+
@property
272+
def base_url(self):
273+
return self._base_url
274+
275+
@property
276+
def timeout(self):
277+
return self._timeout
278+
279+
@property
280+
def async_client(self):
281+
return self._async_client
282+
283+
async def request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
284+
"""Makes an HTTP call using the HTTPX library.
285+
286+
This is the sole entry point to the HTTPX library. All other helper methods in this
287+
class call this method to send HTTP requests out. Refer to
288+
https://www.python-httpx.org/api/ for more information on supported options
289+
and features.
290+
291+
Args:
292+
method: HTTP method name as a string (e.g. get, post).
293+
url: URL of the remote endpoint.
294+
**kwargs: An additional set of keyword arguments to be passed into the HTTPX API
295+
(e.g. json, params, timeout).
296+
297+
Returns:
298+
Response: An HTTPX response object.
299+
300+
Raises:
301+
HTTPError: Any HTTPX exceptions encountered while making the HTTP call.
302+
"""
303+
if 'timeout' not in kwargs:
304+
kwargs['timeout'] = self.timeout
305+
resp = await self._async_client.request(method, self.base_url + url, **kwargs)
306+
return resp.raise_for_status()
307+
308+
async def headers(self, method: str, url: str, **kwargs: Any) -> httpx.Headers:
309+
resp = await self.request(method, url, **kwargs)
310+
return resp.headers
311+
312+
async def body_and_response(self, method: str, url: str, **kwargs: Any) -> tuple[Any, httpx.Response]:
313+
resp = await self.request(method, url, **kwargs)
314+
return self.parse_body(resp), resp
315+
316+
async def body(self, method: str, url: str, **kwargs: Any) -> Any:
317+
resp = await self.request(method, url, **kwargs)
318+
return self.parse_body(resp)
319+
320+
async def headers_and_body(self, method: str, url: str, **kwargs: Any) -> tuple[httpx.Headers, Any]:
321+
resp = await self.request(method, url, **kwargs)
322+
return resp.headers, self.parse_body(resp)
323+
324+
def parse_body(self, resp: httpx.Response) -> Any:
325+
return resp.json()
326+
327+
async def aclose(self) -> None:
328+
await self._async_client.aclose()

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy