Skip to content

Allow injecting a requests.Session to keep logins from piling up - #162

Merged
johanzander merged 4 commits into
indykoning:masterfrom
proscar87:feat/injectable-session
Oct 15, 2026
Merged

johanzander merged 4 commits into
indykoning:masterfrom
proscar87:feat/injectable-session

Conversation

@proscar87

@proscar87 proscar87 commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

Follows up on the session-persistence idea from #160, which you asked for there.

Why

Every GrowattApi builds its own fresh requests.Session, so nothing is shared or persisted. As you described, in the HA integration login() is called from the config flow, from setup, and from the coordinator on every update cycle — and that login frequency is what drives accounts into the ~24h lockout. #160 only reports the lockout; this is the part that can prevent it.

The change

A constructor-injected session: requests.Session | None = None, the shape you preferred. It lets a consumer share one session across API instances and restore persisted cookies at startup. The library stores nothing itself — where the cookies live stays the caller's business.

Default behaviour is unchanged: with no argument it creates its own session exactly as before.

One thing worth reviewing

self.session.hooks = {"response": [_raise_for_status]} assigned the hooks dict, which is fine for a session this library owns but destructive for one it was handed — a caller sharing a session across libraries would have their own hooks silently dropped. It now appends, and normalises the bare-callable form that requests also permits.

If you'd rather this library not touch a caller-supplied session's hooks at all, say so and I'll gate it — but then a shared session loses raise_for_status, which the rest of the code relies on, so it would need a different error path.

The hook is installed once per session: a second GrowattApi on the same session sees the first one's hook and doesn't add another. The first version of this PR appended one per instance, so three instances left three hooks and every response was checked three times.

What a caller should know before sharing a session:

  • The hook runs on every response that goes through that session, including requests that have nothing to do with Growatt. A non-2xx answer from another service raises HTTPError from raise_for_status.
  • With Raise GrowattRateLimitError when a response body carries Growatt's 507 code #160 merged as well, the same hook also reads every response body, so a foreign HTTP 200 whose JSON happens to carry success: false, msg: "507" would raise GrowattRateLimitError. I checked this with both heads merged (no conflicts): one hook after three instances, and that body raises GrowattRateLimitError.
  • So the safe pattern is a session dedicated to Growatt, shared only between GrowattApi instances.

Validation

Nine tests covering: default session still created, injected session used as-is, two instances sharing one, restored cookies surviving, pre-existing hooks kept, the hook installed once when three instances share a session, each response checked once with three instances, bare-callable hooks normalised, and the user-agent applied to the injected session. Eight of the nine fail on master (without the parameter). The two hook-count tests also fail on this PR's first commit (assert 4 == 2, assert 3 == 1). Full suite: 10 passed (master: 1). ruff 0.15.16 and mypy --ignore-missing-imports growattServer/ are clean, as in CI. Merged with #160's current head: 20 passed, ruff and mypy clean.

Not validated: I did not test against Growatt's server, so I never checked that restored cookies are actually accepted there, or for how long. The tests only show that the cookies stay on the injected session; whether Growatt honours them in place of a fresh login() is unmeasured.

🤖 Generated with Claude Code

Requested by @johanzander in indykoning#160: every GrowattApi instance builds its own
fresh Session, so nothing is shared or persisted. In the Home Assistant
integration login() is called from the config flow, from setup, and from the
coordinator on every update cycle -- which is the login frequency that drives
accounts into the ~24h 507 lockout that indykoning#160 only reports.

A constructor-injected `session` lets a consumer share one Session across API
instances *and* restore persisted cookies at startup. The library takes on no
storage responsibility: where the cookies live stays the caller's business.

The raise_for_status hook is now appended rather than assigned. A shared
Session may already carry hooks and replacing `session.hooks` outright would
drop them silently; a bare callable is normalised to a list, which requests
permits.

Six of the seven tests fail without the parameter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every GrowattApi appended its own raise_for_status hook, so N instances
sharing one injected session left N hooks on it and each response was
checked N times. Skip the install when an earlier instance already put
the hook there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@johanzander johanzander left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, sharing a session and restoring cookies is a good way to reduce re-logins and 507 lockouts. Appending to existing hooks rather than replacing them is the right call, and the tests cover it well.

Main point: headers.update overwrites the User-Agent on an injected session even when no agent_identifier was passed, so a caller's own User-Agent is silently replaced. With several instances on one session, the last one wins. Consider only setting it when the caller passed an agent_identifier.

Minor:

  • De-duplicating by __qualname__ would also match any unrelated hook with the same qualname. A module-level hook function or a marker attribute, compared by identity, would be sturdier.
  • The hook doesn't catch the 507 lockout, which comes back as HTTP 200 with msg: "507" in the body. That's fine, but the docstring could say that skipping login() while the persisted cookies are still valid is up to the caller, and that the session now raises on HTTP errors for any request.

…entity

Review feedback on indykoning#162:
- The User-Agent of an injected session is only changed when the caller
  passes agent_identifier or add_random_user_id. Before, every instance
  overwrote it, and with several instances on one session the last won.
- The response hook is a module-level function and is matched by
  identity, so an unrelated hook with the same name is not mistaken for
  ours.
- The docstring says that skipping login() while restored cookies are
  valid is up to the caller, that the session now raises on HTTP errors
  for every request, and that the 507 lockout (HTTP 200, msg 507) is not
  caught by that hook.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@proscar87

Copy link
Copy Markdown
Contributor Author

Thanks @johanzander, all three addressed in 5bb15b8:

  • User-Agent: an injected session's User-Agent is only changed when the caller passes agent_identifier or add_random_user_id (asking for a random suffix is asking for the header to change). A session the library creates itself still gets the default, as before.
  • Hook de-duplication: _raise_for_status is now a module-level function, matched by identity. A test puts an unrelated hook with the same name on the session and checks both end up installed.
  • Docstring: it now says that skipping login() while restored cookies are still valid is up to the caller, that the session raises on HTTP errors for every request made through it, and that the 507 lockout (HTTP 200 with msg: "507") is not caught by that hook.

Four new tests; each fails against the change it guards. ruff check ./growattServer (0.15.16, as in CI) and mypy pass.

indykoning#160 put the 507 check inside the response hook that this branch moved to
module level. The module-level _raise_for_status now calls
_raise_if_rate_limited before raise_for_status, so an injected session
gets it too, and a test holds that. The docstring said the hook did not
catch the 507 lockout, which indykoning#160 made false; it now says it does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@proscar87

Copy link
Copy Markdown
Contributor Author

Merged master to resolve the conflict with #160 (9d31cfb). Since #160 put the 507 check in the response hook, the module-level _raise_for_status now runs _raise_if_rate_limited first, so an injected session gets it too, and a new test covers that. It also means my earlier note was wrong: the hook does now catch the 507 lockout, and the docstring says so. 25 tests pass; ruff (./growattServer, 0.15.16) and mypy are clean.

@johanzander johanzander left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing the points. Checked the latest head (9d31cfb): the User-Agent handling, identity-based hook matching and docstring all look good, and the 507 check from #160 now applies to injected sessions too. mypy and ruff pass in CI, and all 25 tests pass locally.

@johanzander

Copy link
Copy Markdown
Collaborator

@indykoning - want to have a look before merging?

@indykoning indykoning left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good and i appreciate the addition of the tests!

@johanzander
johanzander merged commit f331758 into indykoning:master Oct 15, 2026
2 checks passed
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.

3 participants