|
14 | 14 |
|
15 | 15 | """Internal HTTP client module.
|
16 | 16 |
|
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 |
21 | 25 | import requests
|
22 |
| -from requests.packages.urllib3.util import retry # pylint: disable=import-error |
| 26 | +import requests.adapters |
| 27 | +from urllib3.util import retry |
23 | 28 |
|
24 | 29 | from firebase_admin import _utils
|
| 30 | +from firebase_admin._retry import HttpxRetry, HttpxRetryTransport |
25 | 31 |
|
26 | 32 | if hasattr(retry.Retry.DEFAULT, 'allowed_methods'):
|
27 | 33 | _ANY_METHOD = {'allowed_methods': None}
|
|
32 | 38 | # last response upon exhausting all retries.
|
33 | 39 | DEFAULT_RETRY_CONFIG = retry.Retry(
|
34 | 40 | 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) |
36 | 45 |
|
37 | 46 |
|
38 | 47 | DEFAULT_TIMEOUT_SECONDS = 120
|
@@ -68,7 +77,7 @@ def __init__(
|
68 | 77 | None to disable timeouts (optional).
|
69 | 78 | """
|
70 | 79 | if credential:
|
71 |
| - self._session = transport.requests.AuthorizedSession(credential) |
| 80 | + self._session = google_auth_requests.AuthorizedSession(credential) |
72 | 81 | elif session:
|
73 | 82 | self._session = session
|
74 | 83 | else:
|
@@ -153,3 +162,167 @@ def __init__(self, **kwargs):
|
153 | 162 |
|
154 | 163 | def parse_body(self, resp):
|
155 | 164 | 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