Repository navigation
Add async support via httpx (non-breaking, dual sync/async API) #149
Description
Context
Issue #148 proposed migrating from requests to aiohttp to meet Home Assistant's Platinum quality scale requirements (async-dependency and inject-websession). That proposal involved a full breaking change (all methods becoming async).
After researching the HA requirements and migration approaches in depth, we believe there is a better path: replace requests with httpx and provide both sync and async clients as a non-breaking change.
This issue supersedes #148 with a revised design.
Why httpx instead of aiohttp?
HA does not require aiohttp
The HA quality scale rules say:
- async-dependency: "your library should ideally also use asyncio" — no specific library mandated
- inject-websession: "support passing in a web session to the dependency client" — HA provides helpers for both
aiohttpandhttpx:async_get_clientsession(hass)returnsaiohttp.ClientSessionget_async_client(hass)returnshttpx.AsyncClient
Several HA core integrations already use httpx (openai_conversation, rest platform, generic camera).
httpx gives us sync + async in one dependency
httpx provides httpx.Client (sync, API nearly identical to requests.Session) and httpx.AsyncClient (async) in a single package. This means:
- One HTTP dependency instead of two (
requests+aiohttp) - The sync client is a near drop-in for
requests.Session— same.get(),.post(),.raise_for_status()patterns - No code duplication between sync and async implementations beyond the HTTP call itself
Proven pattern
This is exactly what the OpenAI, Anthropic, and other modern Python SDKs do: sync class + async class, both backed by httpx.
Why not other approaches?
We evaluated several alternatives:
| Approach | Problem |
|---|---|
| aiohttp-only (#148) | Breaking change for all sync users — every call site needs await and asyncio.run() |
Async core + asyncio.run() sync wrappers |
Crashes inside already-running event loops (Jupyter, HA). nest_asyncio workaround is broken on Python 3.14+ |
| Background thread event loop | Complex lifecycle management, thread-safety concerns, hard to debug |
| Keep requests + add aiohttp | Two HTTP dependencies, significant code duplication across ~80 methods, double maintenance burden |
Proposed design
Structure
growattServer/
__init__.py # adds AsyncGrowattApi, AsyncOpenApiV1 to exports
_base.py # shared logic: URL building, param construction, hashing
api.py # GrowattApi (sync, httpx.Client)
async_api.py # AsyncGrowattApi (async, httpx.AsyncClient)
exceptions.py # unchanged
open_api_v1/
__init__.py # OpenApiV1 (sync)
async_client.py # AsyncOpenApiV1 (async)
devices/
abstract_device.py
min.py # Min (sync) + AsyncMin (async)
sph.py # Sph (sync) + AsyncSph (async)
Shared base (minimize duplication)
Extract all non-HTTP logic into a shared _base.py:
- URL construction (
get_url(),server_url) - Parameter building (the
data={...}andparams={...}dicts each method constructs) - Response parsing (JSON extraction, error checking)
- Password hashing, user-agent construction
- Constants (
BATT_MODE_*,Timespan, etc.)
The sync and async classes inherit from this base and only differ in the HTTP call itself.
Sync API (non-breaking)
GrowattApi keeps the exact same constructor signature, method signatures, and return types:
class GrowattApi(GrowattApiBase):
def __init__(self, add_random_user_id=False, agent_identifier=None):
super().__init__(add_random_user_id, agent_identifier)
self.session = httpx.Client(
headers={"User-Agent": self.agent_identifier}, ...
)
def login(self, username, password, is_password_hashed=False):
# same signature, same return value
response = self.session.post(
self.get_url("newTwoLoginAPI.do"), data={...}
)
response.raise_for_status()
return response.json()["back"]Existing user code works unchanged:
from growattServer import GrowattApi
api = GrowattApi()
api.login("user", "pass")
plants = api.plant_list(user_id)Async API (new, additive)
AsyncGrowattApi mirrors the sync class with async def methods and accepts an optional session:
class AsyncGrowattApi(GrowattApiBase):
def __init__(
self,
add_random_user_id=False,
agent_identifier=None,
session: httpx.AsyncClient | None = None,
):
super().__init__(add_random_user_id, agent_identifier)
self._external_session = session is not None
self.session = session or httpx.AsyncClient(
headers={"User-Agent": self.agent_identifier}, ...
)
async def login(self, username, password, is_password_hashed=False):
response = await self.session.post(
self.get_url("newTwoLoginAPI.do"), data={...}
)
response.raise_for_status()
return response.json()["back"]
async def close(self):
if not self._external_session:
await self.session.aclose()Note: session is the last constructor parameter to avoid breaking positional callers in subclasses.
Home Assistant integration usage:
from growattServer import AsyncGrowattApi
from homeassistant.helpers.httpx_client import get_async_client
session = get_async_client(hass)
api = AsyncGrowattApi(session=session)
data = await api.login("user", "pass")OpenApiV1 — same pattern
class OpenApiV1(GrowattApi): # sync, unchanged signature
def __init__(self, token: str): ...
class AsyncOpenApiV1(AsyncGrowattApi): # new async variant
def __init__(self, token: str, session: httpx.AsyncClient | None = None): ...Device classes
Min, Sph get async counterparts (AsyncMin, AsyncSph) that take an AsyncOpenApiV1 instead of OpenApiV1. The shared parameter construction and response parsing logic lives in common base classes.
Exception handling
Replace requests.exceptions.* references in docstrings:
requests.exceptions.HTTPError->httpx.HTTPStatusErrorrequests.exceptions.ConnectionError->httpx.ConnectErrorrequests.exceptions.Timeout->httpx.TimeoutException
The custom exceptions (GrowattError, GrowattParameterError, GrowattV1ApiError) remain unchanged.
Dependencies
install_requires=[
"httpx",
]requests is dropped. httpx is the sole HTTP dependency.
Migration path for existing users
Sync users: nothing changes
# Before (v2.x)
from growattServer import GrowattApi
api = GrowattApi()
api.login("user", "pass")
# After (v3.0) — identical
from growattServer import GrowattApi
api = GrowattApi()
api.login("user", "pass")Users accessing api.session directly
api.session changes from requests.Session to httpx.Client. The .get() / .post() API is nearly identical, but this is the one potential compatibility edge case. This is undocumented/internal usage, but we should note it in the changelog.
Async/HA users: new opt-in
from growattServer import AsyncGrowattApi
api = AsyncGrowattApi(session=external_session)
await api.login("user", "pass")Versioning
Since the public API is non-breaking but the underlying HTTP dependency changes (requests -> httpx), this warrants a major version bump to 3.0.0. The api.session type change and dependency swap justify it even though method signatures are preserved.
Scope summary
| What | Breaking? |
|---|---|
GrowattApi constructor and methods |
No — same signatures, same returns |
OpenApiV1 constructor and methods |
No — same signatures, same returns |
api.session type |
Yes (minor) — requests.Session -> httpx.Client |
| Dependency | Yes — requests -> httpx |
New AsyncGrowattApi class |
Additive |
New AsyncOpenApiV1 class |
Additive |
| Custom exceptions | No change |
hash_password, Timespan, etc. |
No change |
I'm happy to implement this — wanted to align on the design first.
Activity
johanzander commentedon Jun 3, 2026
While working on #150, we realized that replacing
requestswithhttpxfor both sync and async is a breaking change — notably, Home Assistant's Growatt integration catchesrequests.RequestExceptiondirectly, so swapping the HTTP library would break existing consumers.To address this, we created an alternative approach in #151:
requests— completely untouched, no breaking changeshttpxas an optional dependency (pip install growattServer[async])The trade-off is code duplication (~4 files), but it means existing consumers continue to work without any changes, and async can be adopted incrementally.
Both PRs are up as drafts so we can compare the approaches.
Add async support using httpx with coroutine-passthrough pattern