Skip to content

Add async support via httpx (non-breaking, dual sync/async API) #149

Description

@johanzander

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 aiohttp and httpx:
    • async_get_clientsession(hass) returns aiohttp.ClientSession
    • get_async_client(hass) returns httpx.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={...} and params={...} 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.HTTPStatusError
  • requests.exceptions.ConnectionError -> httpx.ConnectError
  • requests.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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions