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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<a href="https://github.com/fireflyframework"><img src="https://img.shields.io/badge/Firefly_Framework-official-ff6600?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyeiIvPjwvc3ZnPg==" alt="Firefly Framework"></a>
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white" alt="Python 3.12+"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License: Apache 2.0"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.69-brightgreen" alt="Version: 26.06.69"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.70-brightgreen" alt="Version: 26.06.70"></a>
<a href="#"><img src="https://img.shields.io/badge/type--checked-mypy%20strict-blue?logo=python&logoColor=white" alt="Type Checked: mypy strict"></a>
<a href="#"><img src="https://img.shields.io/badge/code%20style-ruff-purple?logo=ruff&logoColor=white" alt="Code Style: Ruff"></a>
<a href="#"><img src="https://img.shields.io/badge/async-first-brightgreen" alt="Async First"></a>
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/pyfly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""

__version__ = "26.06.69"
__version__ = "26.06.70"
37 changes: 37 additions & 0 deletions src/pyfly/client/pooled.py
Original file line number Diff line number Diff line change
@@ -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
25 changes: 19 additions & 6 deletions src/pyfly/ecm/adapters/adobe_sign.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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]:
Expand Down Expand Up @@ -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 {
Expand Down
25 changes: 19 additions & 6 deletions src/pyfly/ecm/adapters/docusign.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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 {
Expand Down
25 changes: 19 additions & 6 deletions src/pyfly/ecm/adapters/logalty.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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]:
Expand Down Expand Up @@ -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 {
Expand Down
25 changes: 19 additions & 6 deletions src/pyfly/notifications/providers/firebase.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from typing import Any

from pyfly.client.pooled import PooledHttpClient
from pyfly.notifications.models import EmailStatus, NotificationResult, PushMessage


Expand All @@ -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:
Expand Down Expand Up @@ -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
25 changes: 19 additions & 6 deletions src/pyfly/notifications/providers/resend.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from typing import Any

from pyfly.client.pooled import PooledHttpClient
from pyfly.notifications.models import EmailMessage, EmailStatus, NotificationResult


Expand All @@ -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:
Expand Down Expand Up @@ -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
25 changes: 19 additions & 6 deletions src/pyfly/notifications/providers/sendgrid.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import base64
from typing import Any

from pyfly.client.pooled import PooledHttpClient
from pyfly.notifications.models import EmailMessage, EmailStatus, NotificationResult


Expand All @@ -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:
Expand Down Expand Up @@ -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
Loading