Skip to content

Commit 5df513a

Browse files
committed
feat(deps)!: Migrate from httpx to httpx2 and drop Python 3.9
Replace httpx with httpx2, update imports and documentation, and alias httpx in tests so RESPX mocks intercept requests. Disable the RESPX pytest plugin to ensure aliasing happens before import. Update packaging, CI workflows, and documentation to require Python 3.10 or newer. BREAKING CHANGE: Python 3.9 is no longer supported. Signed-off-by: Fredrik Fornwall <fredrik@fornwall.net>
1 parent de45607 commit 5df513a

16 files changed

Lines changed: 157 additions & 133 deletions

‎.github/workflows/ci.yml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
strategy:
1212
fail-fast: false
1313
matrix:
14-
python: ['3.9', '3.10', '3.11', '3.12', '3.13', 'pypy3.9']
14+
python: ['3.10', '3.11', '3.12', '3.13', 'pypy3.10']
1515

1616
steps:
1717
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -60,10 +60,10 @@ jobs:
6060
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
6161
with:
6262
persist-credentials: false
63-
- name: Set up Python 3.9
63+
- name: Set up Python 3.10
6464
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
6565
with:
66-
python-version: 3.9
66+
python-version: '3.10'
6767
- name: Install dependencies
6868
run: |
6969
python -m pip install --upgrade pip

‎.github/workflows/nightly.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ jobs:
4040
- name: Set up Python
4141
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
4242
with:
43-
python-version: 3.9
43+
python-version: '3.10'
4444

4545
- name: Install dependencies
4646
run: |

‎.github/workflows/release.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ jobs:
5656
- name: Set up Python
5757
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
5858
with:
59-
python-version: 3.9
59+
python-version: '3.10'
6060

6161
- name: Install dependencies
6262
run: |

‎CONTRIBUTING.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ information on using pull requests.
8585

8686
### Initial Setup
8787

88-
You need Python 3.9+ to build and test the code in this repo.
88+
You need Python 3.10+ to build and test the code in this repo.
8989

9090
We recommend using [pip](https://pypi.python.org/pypi/pip) for installing the necessary tools and
9191
project dependencies. Most recent versions of Python ship with pip. If your development environment

‎README.md‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,8 @@ requests, code review feedback, and also pull requests.
4343

4444
## Supported Python Versions
4545

46-
We currently support Python 3.9+. However, Python 3.9 support is deprecated,
47-
and developers are strongly advised to use Python 3.10 or higher. Firebase
48-
Admin Python SDK is also tested on PyPy and
49-
[Google App Engine](https://cloud.google.com/appengine/) environments.
46+
We currently support Python 3.10+. Firebase Admin Python SDK is also tested on
47+
PyPy and [Google App Engine](https://cloud.google.com/appengine/) environments.
5048

5149

5250
## Documentation

‎firebase_admin/_http_client.py‎

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from __future__ import annotations
2121
import logging
2222
from typing import Any, Dict, Generator, Optional, Tuple, Union
23-
import httpx
23+
import httpx2
2424
import requests.adapters
2525
from requests.packages.urllib3.util import retry # pylint: disable=import-error
2626
from google.auth import credentials
@@ -165,7 +165,7 @@ def __init__(self, **kwargs):
165165
def parse_body(self, resp):
166166
return resp.json()
167167

168-
class GoogleAuthCredentialFlow(httpx.Auth):
168+
class GoogleAuthCredentialFlow(httpx2.Auth):
169169
"""Google Auth Credential Auth Flow"""
170170
def __init__(self, credential: credentials.Credentials):
171171
self._credential = credential
@@ -174,7 +174,7 @@ def __init__(self, credential: credentials.Credentials):
174174

175175
def apply_auth_headers(
176176
self,
177-
request: httpx.Request,
177+
request: httpx2.Request,
178178
auth_request: google_auth_requests.Request
179179
) -> None:
180180
"""A helper function that refreshes credentials if needed and mutates the request headers
@@ -189,7 +189,9 @@ def apply_auth_headers(
189189
)
190190
logger.debug('Auth headers applied. Credential validity after: %s', self._credential.valid)
191191

192-
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
192+
def auth_flow(
193+
self, request: httpx2.Request
194+
) -> Generator[httpx2.Request, httpx2.Response, None]:
193195
_original_headers = request.headers.copy()
194196
_credential_refresh_attempt = 0
195197

@@ -207,7 +209,7 @@ def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Re
207209
'Dispatching request, attempt %d of %d',
208210
_credential_refresh_attempt, self._max_refresh_attempts
209211
)
210-
response: httpx.Response = yield request
212+
response: httpx2.Response = yield request
211213

212214
if response.status_code in self._refresh_status_codes:
213215
if _credential_refresh_attempt < self._max_refresh_attempts:
@@ -235,7 +237,7 @@ def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Re
235237
response.status_code
236238
)
237239
break
238-
# The last yielded response is automatically returned by httpx's auth flow.
240+
# The last yielded response is automatically returned by httpx2's auth flow.
239241

240242
class HttpxAsyncClient():
241243
"""Async HTTP client used to make HTTP/2 calls using HTTPX.
@@ -247,7 +249,7 @@ def __init__(
247249
self,
248250
credential: Optional[credentials.Credentials] = None,
249251
base_url: str = '',
250-
headers: Optional[Union[httpx.Headers, Dict[str, str]]] = None,
252+
headers: Optional[Union[httpx2.Headers, Dict[str, str]]] = None,
251253
retry_config: HttpxRetry = DEFAULT_HTTPX_RETRY_CONFIG,
252254
timeout: int = DEFAULT_TIMEOUT_SECONDS,
253255
http2: bool = True
@@ -279,15 +281,15 @@ def __init__(
279281
}
280282

281283
if credential:
282-
self._async_client = httpx.AsyncClient(
284+
self._async_client = httpx2.AsyncClient(
283285
http2=http2,
284286
timeout=self._timeout,
285287
headers=self._headers,
286288
auth=GoogleAuthCredentialFlow(credential), # Add auth flow for credentials.
287289
mounts=self._mounts
288290
)
289291
else:
290-
self._async_client = httpx.AsyncClient(
292+
self._async_client = httpx2.AsyncClient(
291293
http2=http2,
292294
timeout=self._timeout,
293295
headers=self._headers,
@@ -306,12 +308,12 @@ def timeout(self):
306308
def async_client(self):
307309
return self._async_client
308310

309-
async def request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
311+
async def request(self, method: str, url: str, **kwargs: Any) -> httpx2.Response:
310312
"""Makes an HTTP call using the HTTPX library.
311313
312314
This is the sole entry point to the HTTPX library. All other helper methods in this
313315
class call this method to send HTTP requests out. Refer to
314-
https://www.python-httpx.org/api/ for more information on supported options
316+
https://pydantic.dev/docs/httpx2/api/api/ for more information on supported options
315317
and features.
316318
317319
Args:
@@ -332,12 +334,12 @@ class call this method to send HTTP requests out. Refer to
332334
resp = await self._async_client.request(method, self.base_url + url, **kwargs)
333335
return resp.raise_for_status()
334336

335-
async def headers(self, method: str, url: str, **kwargs: Any) -> httpx.Headers:
337+
async def headers(self, method: str, url: str, **kwargs: Any) -> httpx2.Headers:
336338
resp = await self.request(method, url, **kwargs)
337339
return resp.headers
338340

339341
async def body_and_response(
340-
self, method: str, url: str, **kwargs: Any) -> Tuple[Any, httpx.Response]:
342+
self, method: str, url: str, **kwargs: Any) -> Tuple[Any, httpx2.Response]:
341343
resp = await self.request(method, url, **kwargs)
342344
return self.parse_body(resp), resp
343345

@@ -346,11 +348,11 @@ async def body(self, method: str, url: str, **kwargs: Any) -> Any:
346348
return self.parse_body(resp)
347349

348350
async def headers_and_body(
349-
self, method: str, url: str, **kwargs: Any) -> Tuple[httpx.Headers, Any]:
351+
self, method: str, url: str, **kwargs: Any) -> Tuple[httpx2.Headers, Any]:
350352
resp = await self.request(method, url, **kwargs)
351353
return resp.headers, self.parse_body(resp)
352354

353-
def parse_body(self, resp: httpx.Response) -> Any:
355+
def parse_body(self, resp: httpx2.Response) -> Any:
354356
return resp.json()
355357

356358
async def aclose(self) -> None:

‎firebase_admin/_retry.py‎

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from typing import Any, Callable, List, Optional, Tuple, Coroutine
2727
import logging
2828
import asyncio
29-
import httpx
29+
import httpx2
3030

3131
logger = logging.getLogger(__name__)
3232

@@ -47,8 +47,8 @@ def __init__(
4747
backoff_max: float = DEFAULT_BACKOFF_MAX,
4848
backoff_jitter: float = 0,
4949
history: Optional[List[Tuple[
50-
httpx.Request,
51-
Optional[httpx.Response],
50+
httpx2.Request,
51+
Optional[httpx2.Response],
5252
Optional[Exception]
5353
]]] = None,
5454
respect_retry_after_header: bool = False,
@@ -68,7 +68,7 @@ def copy(self) -> HttpxRetry:
6868
"""Creates a deep copy of this instance."""
6969
return copy.deepcopy(self)
7070

71-
def is_retryable_response(self, response: httpx.Response) -> bool:
71+
def is_retryable_response(self, response: httpx2.Response) -> bool:
7272
"""Determine if a response implies that the request should be retried if possible."""
7373
if self.status_forcelist and response.status_code in self.status_forcelist:
7474
return True
@@ -98,7 +98,8 @@ def _parse_retry_after(self, retry_after_header: str) -> float | None:
9898
else:
9999
retry_date_tuple = email.utils.parsedate_tz(retry_after_header)
100100
if retry_date_tuple is None:
101-
raise httpx.RemoteProtocolError(f"Invalid Retry-After header: {retry_after_header}")
101+
raise httpx2.RemoteProtocolError(
102+
f"Invalid Retry-After header: {retry_after_header}")
102103

103104
retry_date = email.utils.mktime_tz(retry_date_tuple)
104105
seconds = retry_date - time.time()
@@ -107,7 +108,7 @@ def _parse_retry_after(self, retry_after_header: str) -> float | None:
107108

108109
return seconds
109110

110-
def get_retry_after(self, response: httpx.Response) -> float | None:
111+
def get_retry_after(self, response: httpx2.Response) -> float | None:
111112
"""Determine the Retry-After time needed before sending the next request."""
112113
retry_after_header = response.headers.get('Retry-After', None)
113114
if retry_after_header:
@@ -133,7 +134,7 @@ async def sleep_for_backoff(self) -> None:
133134
logger.debug('Sleeping for backoff of %f seconds following failed request', backoff)
134135
await asyncio.sleep(backoff)
135136

136-
async def sleep(self, response: httpx.Response) -> None:
137+
async def sleep(self, response: httpx2.Response) -> None:
137138
"""Determine and wait the time needed before sending the next request."""
138139
if self.respect_retry_after_header:
139140
retry_after = self.get_retry_after(response)
@@ -148,16 +149,16 @@ async def sleep(self, response: httpx.Response) -> None:
148149

149150
def increment(
150151
self,
151-
request: httpx.Request,
152-
response: Optional[httpx.Response] = None,
152+
request: httpx2.Request,
153+
response: Optional[httpx2.Response] = None,
153154
error: Optional[Exception] = None
154155
) -> None:
155156
"""Update the retry state based on request attempt."""
156157
self.retries_left -= 1
157158
self.history.append((request, response, error))
158159

159160

160-
class HttpxRetryTransport(httpx.AsyncBaseTransport):
161+
class HttpxRetryTransport(httpx2.AsyncBaseTransport):
161162
"""HTTPX transport with retry logic."""
162163

163164
DEFAULT_RETRY = HttpxRetry(max_retries=4, status_forcelist=[500, 503], backoff_factor=0.5)
@@ -170,17 +171,17 @@ def __init__(self, retry: HttpxRetry = DEFAULT_RETRY, **kwargs: Any) -> None:
170171
# We use a full AsyncHTTPTransport under the hood that is already
171172
# set up to handle requests. We also insure that that transport's internal
172173
# retries are not allowed.
173-
self._wrapped_transport = httpx.AsyncHTTPTransport(**transport_kwargs)
174+
self._wrapped_transport = httpx2.AsyncHTTPTransport(**transport_kwargs)
174175

175-
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
176+
async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response:
176177
return await self._dispatch_with_retry(
177178
request, self._wrapped_transport.handle_async_request)
178179

179180
async def _dispatch_with_retry(
180181
self,
181-
request: httpx.Request,
182-
dispatch_method: Callable[[httpx.Request], Coroutine[Any, Any, httpx.Response]]
183-
) -> httpx.Response:
182+
request: httpx2.Request,
183+
dispatch_method: Callable[[httpx2.Request], Coroutine[Any, Any, httpx2.Response]]
184+
) -> httpx2.Response:
184185
"""Sends a request with retry logic using a provided dispatch method."""
185186
# This request config is used across all requests that use this transport and therefore
186187
# needs to be copied to be used for just this request and it's retries.
@@ -201,7 +202,7 @@ async def _dispatch_with_retry(
201202
logger.debug('Sending request in _dispatch_with_retry(): %r', request)
202203
response = await dispatch_method(request)
203204
logger.debug('Received response: %r', response)
204-
except httpx.HTTPError as err:
205+
except httpx2.HTTPError as err:
205206
logger.debug('Received error: %r', err)
206207
error = err
207208

‎firebase_admin/_utils.py‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
import google.auth
2222
import requests
23-
import httpx
23+
import httpx2
2424

2525
import firebase_admin
2626
from firebase_admin import exceptions
@@ -132,15 +132,15 @@ def handle_platform_error_from_requests(error, handle_func=None):
132132
return exc if exc else _handle_func_requests(error, message, error_dict)
133133

134134
def handle_platform_error_from_httpx(
135-
error: httpx.HTTPError,
135+
error: httpx2.HTTPError,
136136
handle_func: Optional[Callable[..., Optional[exceptions.FirebaseError]]] = None
137137
) -> exceptions.FirebaseError:
138138
"""Constructs a ``FirebaseError`` from the given httpx error.
139139
140140
This can be used to handle errors returned by Google Cloud Platform (GCP) APIs.
141141
142142
Args:
143-
error: An error raised by the httpx module while making an HTTP call to a GCP API.
143+
error: An error raised by the httpx2 module while making an HTTP call to a GCP API.
144144
handle_func: A function that can be used to handle platform errors in a custom way. When
145145
specified, this function will be called with three arguments. It has the same
146146
signature as ```_handle_func_httpx``, but may return ``None``.
@@ -149,7 +149,7 @@ def handle_platform_error_from_httpx(
149149
FirebaseError: A ``FirebaseError`` that can be raised to the user code.
150150
"""
151151

152-
if isinstance(error, httpx.HTTPStatusError):
152+
if isinstance(error, httpx2.HTTPStatusError):
153153
response = error.response
154154
content = response.content.decode()
155155
status_code = response.status_code
@@ -237,11 +237,11 @@ def handle_requests_error(error, message=None, code=None):
237237
err_type = _error_code_to_exception_type(code)
238238
return err_type(message=message, cause=error, http_response=error.response)
239239

240-
def _handle_func_httpx(error: httpx.HTTPError, message, error_dict) -> exceptions.FirebaseError:
240+
def _handle_func_httpx(error: httpx2.HTTPError, message, error_dict) -> exceptions.FirebaseError:
241241
"""Constructs a ``FirebaseError`` from the given GCP error.
242242
243243
Args:
244-
error: An error raised by the httpx module while making an HTTP call.
244+
error: An error raised by the httpx2 module while making an HTTP call.
245245
message: A message to be included in the resulting ``FirebaseError``.
246246
error_dict: Parsed GCP error response.
247247
@@ -252,15 +252,16 @@ def _handle_func_httpx(error: httpx.HTTPError, message, error_dict) -> exception
252252
return handle_httpx_error(error, message, code)
253253

254254

255-
def handle_httpx_error(error: httpx.HTTPError, message=None, code=None) -> exceptions.FirebaseError:
255+
def handle_httpx_error(
256+
error: httpx2.HTTPError, message=None, code=None) -> exceptions.FirebaseError:
256257
"""Constructs a ``FirebaseError`` from the given httpx error.
257258
258259
This method is agnostic of the remote service that produced the error, whether it is a GCP
259260
service or otherwise. Therefore, this method does not attempt to parse the error response in
260261
any way.
261262
262263
Args:
263-
error: An error raised by the httpx module while making an HTTP call.
264+
error: An error raised by the httpx2 module while making an HTTP call.
264265
message: A message to be included in the resulting ``FirebaseError`` (optional). If not
265266
specified the string representation of the ``error`` argument is used as the message.
266267
code: A GCP error code that will be used to determine the resulting error type (optional).
@@ -270,15 +271,15 @@ def handle_httpx_error(error: httpx.HTTPError, message=None, code=None) -> excep
270271
Returns:
271272
FirebaseError: A ``FirebaseError`` that can be raised to the user code.
272273
"""
273-
if isinstance(error, httpx.TimeoutException):
274+
if isinstance(error, httpx2.TimeoutException):
274275
return exceptions.DeadlineExceededError(
275276
message=f'Timed out while making an API call: {error}',
276277
cause=error)
277-
if isinstance(error, httpx.ConnectError):
278+
if isinstance(error, httpx2.ConnectError):
278279
return exceptions.UnavailableError(
279280
message=f'Failed to establish a connection: {error}',
280281
cause=error)
281-
if isinstance(error, httpx.HTTPStatusError):
282+
if isinstance(error, httpx2.HTTPStatusError):
282283
if not code:
283284
code = _http_status_to_error_code(error.response.status_code)
284285
if not message:

0 commit comments

Comments
 (0)