Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ on:
workflow_dispatch:

env:
POETRY_VERSION: 1.8.5
POETRY_VERSION: 2.2.1

jobs:
release:
Expand Down
14 changes: 1 addition & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ on:
workflow_dispatch:

env:
POETRY_VERSION: 1.8.5
POETRY_VERSION: 2.2.1

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
Expand Down Expand Up @@ -55,9 +55,6 @@ jobs:

- name: Install Dependencies
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: poetry install --no-interaction --no-root

- name: Install library
run: poetry install --no-interaction

- name: Log currently installed packages and versions
Expand Down Expand Up @@ -92,9 +89,6 @@ jobs:

- name: Install dependencies
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: poetry install --no-interaction --no-root

- name: Install library
run: poetry install --no-interaction

- name: Check code style
Expand Down Expand Up @@ -129,9 +123,6 @@ jobs:

- name: Install dependencies
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: poetry install --no-interaction --no-root

- name: Install library
run: poetry install --no-interaction

- name: Static type checker
Expand Down Expand Up @@ -171,9 +162,6 @@ jobs:

- name: Install dependencies
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: poetry install --no-interaction --no-root

- name: Install library
run: poetry install --no-interaction

- name: Run pytest
Expand Down
3 changes: 3 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Changelog

## 6.0.1
* [FIX] Fix race condition in lazy HTTP client initialization. Concurrent coroutines could previously create multiple ``httpx.AsyncClient`` instances, orphaning connections. The synchronous ``_client`` property is replaced with an async ``_get_client()`` method guarded by ``asyncio.Lock``, and ``close()`` now acquires the same lock to prevent races between teardown and initialization.

## 6.0.0
FCM message parity with the official ``firebase-admin-python`` SDK.

Expand Down
35 changes: 21 additions & 14 deletions async_firebase/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
to authorize request which is being made to Firebase.
"""

import asyncio
import logging
import typing as t
import uuid
Expand Down Expand Up @@ -68,6 +69,7 @@ def __init__(
self._request_limits = request_limits
self._use_http2 = use_http2
self._http_client: t.Optional[httpx.AsyncClient] = None
self._client_lock = asyncio.Lock()

async def __aenter__(self):
return self
Expand All @@ -78,19 +80,22 @@ async def __aexit__(self, exc_type, exc_val, exc_tb):

async def close(self) -> None:
"""Close the underlying HTTP client and release resources."""
if self._http_client is not None and not self._http_client.is_closed:
await self._http_client.aclose()
async with self._client_lock:
if self._http_client is not None and not self._http_client.is_closed:
await self._http_client.aclose()
self._http_client = None

@property
def _client(self) -> httpx.AsyncClient:
if self._http_client is None or self._http_client.is_closed:
self._http_client = httpx.AsyncClient(
timeout=httpx.Timeout(**self._request_timeout.__dict__),
limits=httpx.Limits(**self._request_limits.__dict__),
http2=self._use_http2,
)
return self._http_client
async def _get_client(self) -> httpx.AsyncClient:
if self._http_client is not None and not self._http_client.is_closed:
return self._http_client
async with self._client_lock:
if self._http_client is None or self._http_client.is_closed:
self._http_client = httpx.AsyncClient(
timeout=httpx.Timeout(**self._request_timeout.__dict__),
limits=httpx.Limits(**self._request_limits.__dict__),
http2=self._use_http2,
)
return self._http_client

@property
def _credentials(self) -> service_account.Credentials:
Expand All @@ -114,7 +119,7 @@ def creds_from_service_account_file(self, service_account_filename: t.Union[str,

async def _get_access_token(self) -> str:
"""Get OAuth 2 access token."""
return await self._credential_manager.get_access_token(self._client)
return await self._credential_manager.get_access_token(await self._get_client())

@staticmethod
def get_request_id():
Expand Down Expand Up @@ -148,7 +153,8 @@ async def _send_fcm_request(
headers,
)
try:
raw_fcm_response: httpx.Response = await self._client.post(
client = await self._get_client()
raw_fcm_response: httpx.Response = await client.post(
url,
json=json_payload,
headers=headers or await self.prepare_headers(),
Expand Down Expand Up @@ -180,7 +186,8 @@ async def _send_topic_request(
headers,
)
try:
raw_fcm_response: httpx.Response = await self._client.post(
client = await self._get_client()
raw_fcm_response: httpx.Response = await client.post(
url,
json=json_payload,
headers=headers or await self.prepare_headers(),
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "async-firebase"
version = "6.0.0"
version = "6.0.1"
description = "Async Firebase Client - a Python asyncio client to interact with Firebase Cloud Messaging in an easy way."
license = "MIT"
authors = [
Expand Down
6 changes: 3 additions & 3 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -709,8 +709,8 @@ async def test_async_context_manager(fake_service_account):
"""Test that async context manager properly opens and closes the client."""
async with AsyncFirebaseClient() as client:
client.creds_from_service_account_info(fake_service_account)
# Accessing _client should create the http client
_ = client._client
# Accessing _get_client should create the http client
_ = await client._get_client()
assert client._http_client is not None
assert not client._http_client.is_closed

Expand All @@ -729,7 +729,7 @@ async def test_close_without_http_client():
async def test_close_already_closed_client():
"""Calling close() when HTTP client is already closed should be a no-op."""
client = AsyncFirebaseClient()
_ = client._client # create the http client
_ = await client._get_client() # create the http client
await client._http_client.aclose()
assert client._http_client.is_closed
# close() should handle the already-closed case gracefully
Expand Down
Loading