Skip to content

Add async support using httpx#150

Draft
johanzander wants to merge 10 commits into
indykoning:masterfrom
johanzander:httpx-async-support
Draft

Add async support using httpx#150
johanzander wants to merge 10 commits into
indykoning:masterfrom
johanzander:httpx-async-support

Conversation

@johanzander

Copy link
Copy Markdown
Collaborator

Summary

  • Replaces requests with httpx for both sync and async HTTP support
  • Adds async counterparts: AsyncGrowattApi, AsyncOpenApiV1, AsyncMin, AsyncSph
  • Uses a coroutine-passthrough pattern where base class methods share ~60 methods with zero duplication — only 6 methods need explicit async overrides
  • Async classes accept an optional session (httpx.AsyncClient) for session sharing (e.g. Home Assistant integrations)

Trade-offs to discuss

This is a draft PR for discussion — see #149 for the broader conversation.

Breaking change: Replaces requests with httpx, so consumers catching requests.exceptions.RequestException need to switch to httpx.HTTPError.

Complexity: The coroutine-passthrough pattern avoids code duplication but adds architectural complexity (shared base classes, MRO-dependent dispatch). An alternative approach is to duplicate sync files into async copies (how large SDKs like openai do it), potentially with auto-generation — but that brings its own maintenance trade-offs.

Key questions:

  1. Is the requestshttpx breaking change acceptable, or should we keep requests for sync and only use httpx for async?
  2. Is the coroutine-passthrough pattern too clever, or is the duplication reduction worth it?
  3. Would a code-generation approach (sync → async transformation) be preferable?

Test plan

  • Verify sync usage works identically to before (minus requests exception types)
  • Verify async usage with asyncio.run() and async with context manager
  • Verify session sharing with externally-provided httpx.AsyncClient
  • Run existing examples against test API token

🤖 Generated with Claude Code

@indykoning

Copy link
Copy Markdown
Owner

If the switch to httpx.HTTPError is the only breaking part of this transition (excluding the requirement) i'm fine with that.
So long as we make sure we tag it as a major update, which we should anyways due to the dependency change. And document a migration path from requests.exceptions.RequestException to httpx.HTTPError in the release notes we'd be fine.

The changes look good so far, less overhaul needed than i expected.


Only thing i'm concerned about is, how do we ensure feature parity between sync and async in future PRs?
So we don't end up in a situation where a feature only exists in the async version because somebody added it there.

Perhaps (for a future PR) we need to look into testing the package in both sync and async mode with Github actions so any change will get tested for sync and async functionality.
We won't be able to use the Growatt API itself so we'll probably need to set up fake data.

@johanzander

Copy link
Copy Markdown
Collaborator Author

Good points! The feature parity concern is actually one of the main reasons I lean toward the shared-base approach in this PR over the duplicated-files approach in #151.

With the coroutine-passthrough pattern here, ~60 methods live in the shared base class and work for both sync and async automatically. A future contributor adding a new API method just adds it to the base — both modes get it for free. Only the handful of HTTP-touching methods (6 currently) need explicit async overrides. With duplicated files, every new method would need to be added in two places, which is exactly the drift scenario you're worried about.

I also considered auto-generating async from sync (like unasync does), but for this codebase it would add build tooling complexity without much benefit since the shared-base pattern already solves the parity problem.

Regarding the breaking change — the main concern is that Home Assistant (and potentially other consumers) currently catch requests.RequestException directly. Rather than forcing everyone to switch to httpx.HTTPError overnight, I think we should introduce library-owned exceptions (e.g. GrowattApiError) and have the library catch transport errors internally and re-raise them as our own types. That way consumers depend on the library's API, not its transport layer.

For the transition we could do a deprecation period: have the library exceptions inherit from both the new GrowattApiError and the old requests.RequestException, and log a deprecation warning when they're caught via the old type. That gives consumers time to migrate from requests.RequestExceptionGrowattApiError before we drop the requests dependency entirely in a later major release.

For testing, I agree we should set up mocked HTTP responses and run the same test suite against both sync and async classes. Happy to put that together in a follow-up PR.

johanzander and others added 2 commits June 7, 2026 16:32
Replaces requests with httpx and adds async counterparts for all API
classes (AsyncGrowattApi, AsyncOpenApiV1, AsyncMin, AsyncSph). Uses a
shared base class pattern where regular def methods return
self._request(...) — producing a coroutine in async context — so ~60
methods are shared with zero duplication. Only methods that chain async
calls need explicit async overrides (6 total).

Closes indykoning#149

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Wrap httpx transport errors in GrowattApiError hierarchy so consumers
depend on the library API rather than the underlying HTTP library.

New exceptions:
- GrowattApiError: base for all transport/HTTP errors
- GrowattApiConnectionError: connection failures
- GrowattApiTimeoutError: request timeouts
- GrowattApiStatusError: HTTP 4XX/5XX responses (with status_code attr)

For backwards compatibility during the requests-to-httpx transition,
GrowattApiError inherits from requests.RequestException when the
requests package is installed. A one-time deprecation warning is
logged to prompt consumers to migrate their except clauses.

Also adds set_classic_inverter_active_power_rate() and
set_classic_inverter_on_off() convenience methods from upstream.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@johanzander johanzander force-pushed the httpx-async-support branch from c2f0b26 to 3827ab7 Compare June 7, 2026 14:39
johanzander and others added 8 commits June 7, 2026 20:19
The method returns a dict (with 'data' and 'totalData' keys) from the
API's "back" field, not a list. The annotation incorrectly stated
list[dict[str, Any]].

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The method returns a list of devices (confirmed by TLX examples iterating
over the result), not a dict. Fix annotation to list[dict[str, Any]] and
default from {} to [].

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ples

- Fix update_inverter_setting passing settings_parameters instead of merged
- Fix update_noah_settings passing settings_parameters instead of merged
- Wrap v1_request (sync and async) with GrowattApiError exception hierarchy
  so transport errors are consistently wrapped regardless of code path
- Stringify date object in plant_power_overview params
- Replace httpx.HTTPError catches in all examples with GrowattApiError
- Update docstring Raises sections to reference GrowattApiError
- Update docs/README.md migration guidance to reference GrowattApiError

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add .pyi type stubs for async classes (PEP 561) providing strict typing
- Fix plant_list/device_list MRO conflict with explicit overrides (non-breaking)
- Refactor v1_request to delegate to _request with extract callback
- Remove event hooks, use explicit raise_for_status()
- Switch to warnings.warn(DeprecationWarning) from logging.warning
- Add DEFAULT_TIMEOUT=30s with configurable timeout parameter
- Add __all__ to devices package for proper exports
- Add async detail()/settings() overrides in async device classes
- Change process_response return type to Any
- Change abstract_device api type to _OpenApiV1Base

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Move Callable import into TYPE_CHECKING block (TC003)
- Remove docstrings and __future__ annotations from .pyi stubs (PYI021/PYI044)
- Use Self return type for __aenter__ in stubs (PYI034)
- Remove unused noqa directives from devices __init__ (RUF100)
- Remove unused httpx import from V1 stub (F401)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- pytest + pytest-asyncio test suite using httpx.MockTransport
- Tests for _request(), v1_request(), exception mapping, device methods
  (Min, Sph), coroutine-passthrough parity, and helper functions
- CI workflow: pytest on Python 3.12/3.13, stubtest for .pyi sync
- Fix _GrowattApiBase._request signature (missing files param)
- Move deprecation warning to import-time to avoid firing during except

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Both GrowattApi._request and AsyncGrowattApi._request were missing the
files keyword argument defined in the base class signature.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants