diff --git a/CHANGELOG.md b/CHANGELOG.md index cd801c76..499ae7f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.06.70 (2026-06-07) + +### Performance (notifications/ECM — pooled outbound HTTP clients) + +The SendGrid/Resend/Twilio/Firebase notification providers and the DocuSign/Adobe Sign/Logalty +e-signature adapters built a **new `httpx.AsyncClient` per call** (no connection reuse). They now +keep one long-lived, lazily-created client (connection pool reused across calls) and close it on +shutdown via new `start()`/`stop()` lifecycle methods. A shared `PooledHttpClient` async-context +wrapper keeps the existing `async with await self._client()` call sites unchanged while reusing +the pooled client (it does not close on exit). Found by the ports/adapters audit. + ## v26.06.69 (2026-06-07) ### Added (OAuth2 — persistent token stores; fixes a multi-instance production blocker) diff --git a/README.md b/README.md index 95173ec8..c8129544 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.06.69 + Version: 26.06.70 Type Checked: mypy strict Code Style: Ruff Async First diff --git a/pyproject.toml b/pyproject.toml index c0109ae8..b18a7ed8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "pyfly" # CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4); # git tag, GitHub release and human-readable display use leading-zero form # (v26.05.04) to match the Java/.NET/Go siblings. -version = "26.6.69" +version = "26.6.70" description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more." readme = "README.md" license = "Apache-2.0" diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py index 0981a311..1cb50280 100644 --- a/src/pyfly/__init__.py +++ b/src/pyfly/__init__.py @@ -13,4 +13,4 @@ # limitations under the License. """PyFly — Enterprise Python Framework.""" -__version__ = "26.06.69" +__version__ = "26.06.70" diff --git a/src/pyfly/client/pooled.py b/src/pyfly/client/pooled.py new file mode 100644 index 00000000..3229c628 --- /dev/null +++ b/src/pyfly/client/pooled.py @@ -0,0 +1,37 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Pooled HTTP client helper for outbound provider adapters. + +Lets a provider keep ONE long-lived ``httpx.AsyncClient`` (connection pool reused across calls) +while leaving the existing ``async with await self._client() as client:`` call sites unchanged — +:class:`PooledHttpClient` is an async context manager that yields the shared client but does +**not** close it on exit. The client is closed once, on the provider's ``stop()`` lifecycle. +""" + +from __future__ import annotations + +from typing import Any + + +class PooledHttpClient: + """Async-CM wrapper yielding a shared client without closing it on ``__aexit__``.""" + + def __init__(self, client: Any) -> None: + self._client = client + + async def __aenter__(self) -> Any: + return self._client + + async def __aexit__(self, *exc: Any) -> bool: + return False # keep the pooled client open for reuse diff --git a/src/pyfly/ecm/adapters/adobe_sign.py b/src/pyfly/ecm/adapters/adobe_sign.py index 355a4e85..39518f8a 100644 --- a/src/pyfly/ecm/adapters/adobe_sign.py +++ b/src/pyfly/ecm/adapters/adobe_sign.py @@ -7,6 +7,7 @@ from datetime import UTC, datetime from typing import Any +from pyfly.client.pooled import PooledHttpClient from pyfly.ecm.models import ( ESignatureEnvelope, ESignatureStatus, @@ -27,14 +28,17 @@ class AdobeSignESignatureAdapter: def __init__(self, *, api_base: str, access_token: str) -> None: self._api_base = api_base.rstrip("/") self._access_token = access_token + self._http: Any = None async def _client(self) -> Any: - try: - import httpx # type: ignore[import-not-found, unused-ignore] - except ImportError as exc: # noqa: BLE001 - msg = "AdobeSignESignatureAdapter requires httpx — `pip install pyfly[client]`" - raise ImportError(msg) from exc - return httpx.AsyncClient(timeout=60.0) + if self._http is None: + try: + import httpx # type: ignore[import-not-found, unused-ignore] + except ImportError as exc: # noqa: BLE001 + msg = "AdobeSignESignatureAdapter requires httpx — `pip install pyfly[client]`" + raise ImportError(msg) from exc + self._http = httpx.AsyncClient(timeout=60.0) + return PooledHttpClient(self._http) @property def _headers(self) -> dict[str, str]: @@ -95,6 +99,15 @@ async def cancel(self, envelope_id: str) -> bool: ) return resp.status_code in (200, 204) + async def start(self) -> None: + """No-op — the pooled HTTP client is created lazily on first use.""" + + async def stop(self) -> None: + """Close the pooled HTTP client on shutdown.""" + if self._http is not None: + await self._http.aclose() + self._http = None + def _map_status(value: str) -> ESignatureStatus: return { diff --git a/src/pyfly/ecm/adapters/docusign.py b/src/pyfly/ecm/adapters/docusign.py index 490eb9ef..619cda0e 100644 --- a/src/pyfly/ecm/adapters/docusign.py +++ b/src/pyfly/ecm/adapters/docusign.py @@ -8,6 +8,7 @@ from datetime import UTC, datetime from typing import Any +from pyfly.client.pooled import PooledHttpClient from pyfly.ecm.models import ( ESignatureEnvelope, ESignatureStatus, @@ -38,14 +39,17 @@ def __init__( self._base_url = base_url.rstrip("/") self._account_id = account_id self._access_token = access_token + self._http: Any = None async def _client(self) -> Any: - try: - import httpx # type: ignore[import-not-found, unused-ignore] - except ImportError as exc: # noqa: BLE001 - msg = "DocuSignESignatureAdapter requires httpx — `pip install pyfly[client]`" - raise ImportError(msg) from exc - return httpx.AsyncClient(timeout=60.0) + if self._http is None: + try: + import httpx # type: ignore[import-not-found, unused-ignore] + except ImportError as exc: # noqa: BLE001 + msg = "DocuSignESignatureAdapter requires httpx — `pip install pyfly[client]`" + raise ImportError(msg) from exc + self._http = httpx.AsyncClient(timeout=60.0) + return PooledHttpClient(self._http) @property def _headers(self) -> dict[str, str]: @@ -123,6 +127,15 @@ async def cancel(self, envelope_id: str) -> bool: ) return bool(resp.status_code == 200) + async def start(self) -> None: + """No-op — the pooled HTTP client is created lazily on first use.""" + + async def stop(self) -> None: + """Close the pooled HTTP client on shutdown.""" + if self._http is not None: + await self._http.aclose() + self._http = None + def _map_status(value: str) -> ESignatureStatus: return { diff --git a/src/pyfly/ecm/adapters/logalty.py b/src/pyfly/ecm/adapters/logalty.py index c075d6a4..5638a52d 100644 --- a/src/pyfly/ecm/adapters/logalty.py +++ b/src/pyfly/ecm/adapters/logalty.py @@ -7,6 +7,7 @@ from datetime import UTC, datetime from typing import Any +from pyfly.client.pooled import PooledHttpClient from pyfly.ecm.models import ( ESignatureEnvelope, ESignatureStatus, @@ -27,14 +28,17 @@ class LogaltyESignatureAdapter: def __init__(self, *, api_base: str, api_key: str) -> None: self._api_base = api_base.rstrip("/") self._api_key = api_key + self._http: Any = None async def _client(self) -> Any: - try: - import httpx # type: ignore[import-not-found, unused-ignore] - except ImportError as exc: # noqa: BLE001 - msg = "LogaltyESignatureAdapter requires httpx — `pip install pyfly[client]`" - raise ImportError(msg) from exc - return httpx.AsyncClient(timeout=60.0) + if self._http is None: + try: + import httpx # type: ignore[import-not-found, unused-ignore] + except ImportError as exc: # noqa: BLE001 + msg = "LogaltyESignatureAdapter requires httpx — `pip install pyfly[client]`" + raise ImportError(msg) from exc + self._http = httpx.AsyncClient(timeout=60.0) + return PooledHttpClient(self._http) @property def _headers(self) -> dict[str, str]: @@ -82,6 +86,15 @@ async def cancel(self, envelope_id: str) -> bool: resp = await client.delete(f"{self._api_base}/envelopes/{envelope_id}", headers=self._headers) return resp.status_code in (200, 204) + async def start(self) -> None: + """No-op — the pooled HTTP client is created lazily on first use.""" + + async def stop(self) -> None: + """Close the pooled HTTP client on shutdown.""" + if self._http is not None: + await self._http.aclose() + self._http = None + def _map_status(value: str) -> ESignatureStatus: return { diff --git a/src/pyfly/notifications/providers/firebase.py b/src/pyfly/notifications/providers/firebase.py index 87cdeb36..2995f847 100644 --- a/src/pyfly/notifications/providers/firebase.py +++ b/src/pyfly/notifications/providers/firebase.py @@ -6,6 +6,7 @@ from typing import Any +from pyfly.client.pooled import PooledHttpClient from pyfly.notifications.models import EmailStatus, NotificationResult, PushMessage @@ -22,14 +23,17 @@ class FirebasePushProvider: def __init__(self, *, project_id: str, access_token: str) -> None: self._project_id = project_id self._access_token = access_token + self._http: Any = None async def _client(self) -> Any: - try: - import httpx # type: ignore[import-not-found, unused-ignore] - except ImportError as exc: # noqa: BLE001 - msg = "FirebasePushProvider requires httpx — `pip install pyfly[client]`" - raise ImportError(msg) from exc - return httpx.AsyncClient(timeout=30.0) + if self._http is None: + try: + import httpx # type: ignore[import-not-found, unused-ignore] + except ImportError as exc: # noqa: BLE001 + msg = "FirebasePushProvider requires httpx — `pip install pyfly[client]`" + raise ImportError(msg) from exc + self._http = httpx.AsyncClient(timeout=30.0) + return PooledHttpClient(self._http) async def send(self, message: PushMessage) -> NotificationResult: async with await self._client() as client: @@ -62,3 +66,12 @@ async def send(self, message: PushMessage) -> NotificationResult: error="; ".join(errors) or None, provider_id=";".join(sent_ids) or None, ) + + async def start(self) -> None: + """No-op — the pooled HTTP client is created lazily on first use.""" + + async def stop(self) -> None: + """Close the pooled HTTP client on shutdown.""" + if self._http is not None: + await self._http.aclose() + self._http = None diff --git a/src/pyfly/notifications/providers/resend.py b/src/pyfly/notifications/providers/resend.py index 942825e3..3b28243b 100644 --- a/src/pyfly/notifications/providers/resend.py +++ b/src/pyfly/notifications/providers/resend.py @@ -6,6 +6,7 @@ from typing import Any +from pyfly.client.pooled import PooledHttpClient from pyfly.notifications.models import EmailMessage, EmailStatus, NotificationResult @@ -20,14 +21,17 @@ def __init__( self._api_key = api_key self._api_base = api_base.rstrip("/") self._default_from = default_from + self._http: Any = None async def _client(self) -> Any: - try: - import httpx # type: ignore[import-not-found, unused-ignore] - except ImportError as exc: # noqa: BLE001 - msg = "ResendEmailProvider requires httpx — `pip install pyfly[client]`" - raise ImportError(msg) from exc - return httpx.AsyncClient(timeout=30.0) + if self._http is None: + try: + import httpx # type: ignore[import-not-found, unused-ignore] + except ImportError as exc: # noqa: BLE001 + msg = "ResendEmailProvider requires httpx — `pip install pyfly[client]`" + raise ImportError(msg) from exc + self._http = httpx.AsyncClient(timeout=30.0) + return PooledHttpClient(self._http) async def send(self, message: EmailMessage) -> NotificationResult: async with await self._client() as client: @@ -73,3 +77,12 @@ async def send(self, message: EmailMessage) -> NotificationResult: status=EmailStatus.FAILED, error=f"http {resp.status_code}: {resp.text}", ) + + async def start(self) -> None: + """No-op — the pooled HTTP client is created lazily on first use.""" + + async def stop(self) -> None: + """Close the pooled HTTP client on shutdown.""" + if self._http is not None: + await self._http.aclose() + self._http = None diff --git a/src/pyfly/notifications/providers/sendgrid.py b/src/pyfly/notifications/providers/sendgrid.py index 5a9d3c3f..f7da57ec 100644 --- a/src/pyfly/notifications/providers/sendgrid.py +++ b/src/pyfly/notifications/providers/sendgrid.py @@ -7,6 +7,7 @@ import base64 from typing import Any +from pyfly.client.pooled import PooledHttpClient from pyfly.notifications.models import EmailMessage, EmailStatus, NotificationResult @@ -18,14 +19,17 @@ class SendGridEmailProvider: def __init__(self, api_key: str, *, api_base: str = "https://api.sendgrid.com/v3") -> None: self._api_key = api_key self._api_base = api_base.rstrip("/") + self._http: Any = None async def _client(self) -> Any: - try: - import httpx # type: ignore[import-not-found, unused-ignore] - except ImportError as exc: # noqa: BLE001 - msg = "SendGridEmailProvider requires httpx — `pip install pyfly[client]`" - raise ImportError(msg) from exc - return httpx.AsyncClient(timeout=30.0) + if self._http is None: + try: + import httpx # type: ignore[import-not-found, unused-ignore] + except ImportError as exc: # noqa: BLE001 + msg = "SendGridEmailProvider requires httpx — `pip install pyfly[client]`" + raise ImportError(msg) from exc + self._http = httpx.AsyncClient(timeout=30.0) + return PooledHttpClient(self._http) async def send(self, message: EmailMessage) -> NotificationResult: async with await self._client() as client: @@ -81,3 +85,12 @@ async def send(self, message: EmailMessage) -> NotificationResult: status=EmailStatus.FAILED, error=f"http {resp.status_code}: {resp.text}", ) + + async def start(self) -> None: + """No-op — the pooled HTTP client is created lazily on first use.""" + + async def stop(self) -> None: + """Close the pooled HTTP client on shutdown.""" + if self._http is not None: + await self._http.aclose() + self._http = None diff --git a/src/pyfly/notifications/providers/twilio.py b/src/pyfly/notifications/providers/twilio.py index f4c918db..eece57c3 100644 --- a/src/pyfly/notifications/providers/twilio.py +++ b/src/pyfly/notifications/providers/twilio.py @@ -6,6 +6,7 @@ from typing import Any +from pyfly.client.pooled import PooledHttpClient from pyfly.notifications.models import EmailStatus, NotificationResult, SmsMessage @@ -18,14 +19,17 @@ def __init__(self, account_sid: str, auth_token: str, *, from_number: str | None self._sid = account_sid self._token = auth_token self._from = from_number + self._http: Any = None async def _client(self) -> Any: - try: - import httpx # type: ignore[import-not-found, unused-ignore] - except ImportError as exc: # noqa: BLE001 - msg = "TwilioSmsProvider requires httpx — `pip install pyfly[client]`" - raise ImportError(msg) from exc - return httpx.AsyncClient(timeout=30.0) + if self._http is None: + try: + import httpx # type: ignore[import-not-found, unused-ignore] + except ImportError as exc: # noqa: BLE001 + msg = "TwilioSmsProvider requires httpx — `pip install pyfly[client]`" + raise ImportError(msg) from exc + self._http = httpx.AsyncClient(timeout=30.0) + return PooledHttpClient(self._http) async def send(self, message: SmsMessage) -> NotificationResult: async with await self._client() as client: @@ -49,3 +53,12 @@ async def send(self, message: SmsMessage) -> NotificationResult: status=EmailStatus.FAILED, error=f"http {resp.status_code}: {resp.text}", ) + + async def start(self) -> None: + """No-op — the pooled HTTP client is created lazily on first use.""" + + async def stop(self) -> None: + """Close the pooled HTTP client on shutdown.""" + if self._http is not None: + await self._http.aclose() + self._http = None diff --git a/tests/client/test_pooled_http_client.py b/tests/client/test_pooled_http_client.py new file mode 100644 index 00000000..ba245e0e --- /dev/null +++ b/tests/client/test_pooled_http_client.py @@ -0,0 +1,63 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Pooled outbound HTTP client (v26.06.70) — wrapper + provider pooling/lifecycle.""" + +from __future__ import annotations + +import pytest + +from pyfly.client.pooled import PooledHttpClient + + +class _FakeClient: + def __init__(self) -> None: + self.closed = False + + async def aclose(self) -> None: + self.closed = True + + +@pytest.mark.asyncio +async def test_pooled_wrapper_does_not_close_on_exit() -> None: + fake = _FakeClient() + async with PooledHttpClient(fake) as client: + assert client is fake + assert fake.closed is False # the shared client stays open for reuse + + +@pytest.mark.asyncio +async def test_email_provider_pools_and_closes_client() -> None: + from pyfly.notifications.providers.sendgrid import SendGridEmailProvider + + provider = SendGridEmailProvider(api_key="x") + async with await provider._client() as c1: + pass + async with await provider._client() as c2: + pass + assert c1 is c2 # one pooled client reused across calls + assert not c1.is_closed + await provider.stop() + assert c1.is_closed and provider._http is None + + +@pytest.mark.asyncio +async def test_ecm_adapter_pools_and_closes_client() -> None: + from pyfly.ecm.adapters.docusign import DocuSignESignatureAdapter + + adapter = DocuSignESignatureAdapter(base_url="https://demo.docusign.net", account_id="a", access_token="t") + c1 = (await adapter._client())._client + c2 = (await adapter._client())._client + assert c1 is c2 + await adapter.stop() + assert c1.is_closed and adapter._http is None diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 00000000..08a143ef --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,59 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Shared fixtures for testcontainers-backed integration tests. + +``@requires_docker`` only checks that a Docker *daemon* answers — but CI can have a daemon that +cannot *pull* images (registry timeout). These fixtures therefore start the container inside a +guard and ``pytest.skip`` on any startup failure, so the suite degrades to "skipped" (never +"errored") wherever Docker isn't fully functional. +""" + +from __future__ import annotations + +import contextlib +from collections.abc import Iterator + +import pytest + +from pyfly.testing import postgres_container, pyfly_config_for, redis_container + + +@pytest.fixture +def redis_url() -> Iterator[str]: + try: + container = redis_container() + container.start() + except Exception as exc: # noqa: BLE001 — daemon present but cannot run/pull -> skip, don't error + pytest.skip(f"Redis testcontainer unavailable: {exc}") + try: + host = container.get_container_host_ip() + port = container.get_exposed_port(6379) + yield f"redis://{host}:{port}/0" + finally: + with contextlib.suppress(Exception): + container.stop() + + +@pytest.fixture +def pg_url() -> Iterator[str]: + try: + container = postgres_container() + container.start() + except Exception as exc: # noqa: BLE001 + pytest.skip(f"Postgres testcontainer unavailable: {exc}") + try: + yield pyfly_config_for(container)["pyfly.data.relational.url"] + finally: + with contextlib.suppress(Exception): + container.stop() diff --git a/tests/integration/test_postgres_lock_integration.py b/tests/integration/test_postgres_lock_integration.py index 68a6878b..fe1466f2 100644 --- a/tests/integration/test_postgres_lock_integration.py +++ b/tests/integration/test_postgres_lock_integration.py @@ -15,17 +15,9 @@ from __future__ import annotations -from collections.abc import Iterator - import pytest -from pyfly.testing import postgres_container, pyfly_config_for, requires_docker - - -@pytest.fixture -def pg_url() -> Iterator[str]: - with postgres_container() as container: - yield pyfly_config_for(container)["pyfly.data.relational.url"] +from pyfly.testing import requires_docker # the `pg_url` fixture is provided by conftest.py @requires_docker diff --git a/tests/integration/test_postgres_session_registry_integration.py b/tests/integration/test_postgres_session_registry_integration.py index 15b5d703..bf001655 100644 --- a/tests/integration/test_postgres_session_registry_integration.py +++ b/tests/integration/test_postgres_session_registry_integration.py @@ -15,17 +15,9 @@ from __future__ import annotations -from collections.abc import Iterator - import pytest -from pyfly.testing import postgres_container, pyfly_config_for, requires_docker - - -@pytest.fixture -def pg_url() -> Iterator[str]: - with postgres_container() as container: - yield pyfly_config_for(container)["pyfly.data.relational.url"] +from pyfly.testing import requires_docker # the `pg_url` fixture is provided by conftest.py @requires_docker diff --git a/tests/integration/test_redis_adapters_integration.py b/tests/integration/test_redis_adapters_integration.py index 0dc2882f..8b5f41ea 100644 --- a/tests/integration/test_redis_adapters_integration.py +++ b/tests/integration/test_redis_adapters_integration.py @@ -21,19 +21,10 @@ from __future__ import annotations import asyncio -from collections.abc import Iterator import pytest -from pyfly.testing import redis_container, requires_docker - - -@pytest.fixture -def redis_url() -> Iterator[str]: - with redis_container() as container: - host = container.get_container_host_ip() - port = container.get_exposed_port(6379) - yield f"redis://{host}:{port}/0" +from pyfly.testing import requires_docker # the `redis_url` fixture is provided by conftest.py @requires_docker diff --git a/tests/integration/test_token_store_integration.py b/tests/integration/test_token_store_integration.py index 192ea7cd..686cad7b 100644 --- a/tests/integration/test_token_store_integration.py +++ b/tests/integration/test_token_store_integration.py @@ -15,23 +15,9 @@ from __future__ import annotations -from collections.abc import Iterator - import pytest -from pyfly.testing import postgres_container, pyfly_config_for, redis_container, requires_docker - - -@pytest.fixture -def redis_url() -> Iterator[str]: - with redis_container() as container: - yield f"redis://{container.get_container_host_ip()}:{container.get_exposed_port(6379)}/0" - - -@pytest.fixture -def pg_url() -> Iterator[str]: - with postgres_container() as container: - yield pyfly_config_for(container)["pyfly.data.relational.url"] +from pyfly.testing import requires_docker # `redis_url` / `pg_url` fixtures provided by conftest.py @requires_docker diff --git a/uv.lock b/uv.lock index 1fb488ef..d4d33554 100644 --- a/uv.lock +++ b/uv.lock @@ -1981,7 +1981,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.6.69" +version = "26.6.70" source = { editable = "." } dependencies = [ { name = "pydantic" },