Skip to content

Feat/real async io#16

Open
baraline wants to merge 21 commits into
mainfrom
feat/real-async-io
Open

Feat/real async io#16
baraline wants to merge 21 commits into
mainfrom
feat/real-async-io

Conversation

@baraline

Copy link
Copy Markdown
Owner

Add new error contract, async-bridge fixes, integration-suite repairs

baraline and others added 20 commits July 15, 2026 15:26
Address code review findings on the KB article async-bridge fix:
add an end-to-end canary pinning _public_names, _self_call_map, and
_reaches_public together against the real create_kb_article shape
(the synthetic pinning test alone couldn't catch either helper going
silently empty); cover the untested ValueError branch for a category
ref without an id on both the create (wrapped into RuntimeError) and
update (raw ValueError) paths; capture and assert the v2 request body
on create so a wider model_copy strip would be caught; switch the
update test to whole-list call equality to catch a double-write; and
correct two stale docstrings (the knowledgebase package docstring and
the executor parameter, which no longer covers the hand-written KB
article overrides that use asyncio.to_thread directly).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Document the two async-bridge self-call bugs fixed on this branch (silent
KB category loss, and set/get_ticket_custom_fields raising TypeError) and
the structural AST guard added to prevent regressions, plus the shared
root cause and the plan to remove AsyncBridge entirely in 0.4.0. No
version bump: this branch merges to main unreleased.

Also closes an assertion gap in test_update_kb_article_clears_categories:
patch_bodies was captured by the fixture but never asserted, so a
model_copy regression that stripped more than categories from the v2
patch body would have gone unnoticed on the update path even though the
symmetric create path was already covered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Corrects five minor findings from the whole-branch review:

- CLAUDE.md and docs/development.md's "single source of truth" rule
  said hand-crafted async overrides are warranted only for
  asyncio.gather fan-out; add the self-call trap as the second,
  independent reason (the actual cause of both bugs this branch
  fixed), and point at the new AST guard.
- async_client.py's module/class docstrings still said async
  overrides exist only for fan-out and only live under
  clients.custom; both are now also true of clients.api.knowledgebase
  and clients.api.plugins, for the self-call reason.
- _fields_async.py's `type: ignore[misc]` calls had no explanation;
  add the same rationale _article_async.py already documents, and
  contrast with the `[attr-defined]` codes in _statistics_async.py.
- CHANGELOG.md described the guard as detecting any self-call
  "transitively... through self"; narrow to the literal
  `self.name(...)` shape it actually matches (getattr/aliasing/
  partial/super()/property getters are invisible to it).
- Add test_update_kb_article_links_categories, the missing direct
  test for update_kb_article with a non-empty category list -
  previously only covered by composition of the create and
  clear-categories tests.

No behavior changes; docs, docstrings, comments, and one new test.
Fix two live AsyncGlpiClient bugs sharing one root cause: the async bridge
wraps public sync methods into coroutines, so a sync body calling a sibling
public method through self silently dropped the call.

- create_kb_article/update_kb_article silently discarded categories
  (article created, id returned, no exception, category never linked)
- get_ticket_custom_fields/set_ticket_custom_fields raised TypeError

Adds a structural AST guard so the bug class cannot be reintroduced.

Unreleased: version stays 0.3.4. The next release is 0.4.0 (httpx + unasync),
which removes the bridge and makes this class of bug impossible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Declares GlpiError and its transport/status/validation/protocol branches
and exports them from the package root. Nothing raises them yet.

GlpiStatusError, GlpiValidationError and GlpiProtocolError also inherit
ValueError so callers written against the previous bare-ValueError
contract keep working.
BREAKING CHANGE: a persistent 5xx now raises GlpiServerError instead of
tenacity.RetryError. Callers no longer need to dig the real error out of
RetryError.last_attempt.exception().

BREAKING CHANGE: unexpected response statuses now raise a GlpiStatusError
subclass instead of a bare ValueError. GlpiStatusError inherits ValueError,
so existing 'except ValueError' handlers keep working.

The retry predicate must gain GlpiServerError in the same commit: 5xx no
longer raises a requests exception, so the old predicate would silently
stop retrying it. requests.RequestException stays in the predicate because
the transport is still requests and network faults are still raised raw.
Retry behaviour is unchanged: 5xx retried 3x, 4xx not retried.

The 4xx raise deliberately stays in ensure_response_status; moving it into
finalize_request_response would flip 7 tolerant search endpoints from
returning [] to raising.
Closes review findings on plan-1 Task 2: adds a committed test that
drives a requests.RequestException through the v1 session's retry
predicate (previously only the GlpiServerError member was pinned, so
plan 3's predicate rewrite could silently drop v1 network retries from
3 attempts to 1 with the suite staying green), corrects stale retry
docstrings in _transport.py and the async integration suite that still
described the pre-hierarchy requests.HTTPError behaviour, adds a
Raises section to finalize_request_response, and parametrizes the v2
transport's attempt-count tests across all four retried verbs instead
of GET only. No production behaviour changed.
BREAKING CHANGE: a non-2xx OAuth token response now raises GlpiAuthError
(401/403), GlpiServerError (5xx) or GlpiStatusError instead of a bare
ValueError wrapped in tenacity.RetryError.

BREAKING CHANGE: the token retry decorators had no retry= predicate, so
they retried every exception -- including the ValueError raised for a
rejected credential. A wrong client_secret cost 3 attempts and 6s of
sleep. They now use the same predicate as the rest of the library: 5xx
retried, 4xx final.

Adds the first tests covering a non-2xx token response.
…r it

Task 3 gave `_refresh_access_token` the same 4xx-fail-fast decorator as
`_acquire_token`, but nothing drove ensure_token() through the refresh
path, so the decision was only half-verified. Add tests that prime a
manager (access_token + refresh_token + expired) so refresh is actually
reached, and measure the real attempt counts instead of assuming them:
a persistent 401 costs 2 POSTs (refresh + one nested, non-retried
_acquire_token call), but a persistent 5xx costs 12 POSTs, because
_refresh_access_token falls through to a nested _acquire_token() call
on any non-2xx instead of raising directly, and both methods retry
GlpiServerError independently (3 outer x (1 + 3 inner) = 12). This
predates Task 3 and is not fixed here, only measured and pinned so a
future change cannot silently alter it. Also documents the real
behavior in _refresh_access_token's Raises section, and adds the
missing requests.RequestException coverage for _acquire_token that
Task 2's transport tests already had.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BREAKING CHANGE: the 7 sites where GLPI answers 2xx with a body the client
cannot use now raise GlpiProtocolError instead of a bare ValueError.
GlpiProtocolError inherits ValueError, so existing handlers keep working.

These are deliberately not GlpiValidationError: the caller did nothing
wrong, the server did.
BREAKING CHANGE: the 23 argument- and configuration-validation sites now
raise GlpiValidationError instead of a bare ValueError. GlpiValidationError
inherits ValueError, so existing handlers keep working.

The 2 TypeError and 4 RuntimeError sites are deliberately left alone:
GlpiValidationError inherits ValueError, not TypeError, so converting them
would silently break 'except TypeError' / 'except RuntimeError' callers.

Adds an AST audit test pinning the whole raise-site contract, with a
positive-control test proving the walk itself cannot silently return
nothing (caught a real off-by-path-prefix bug in the control while writing
it). Also extends test coverage for every converted site to assert both
the new type and the ValueError back-compat guarantee, including two
sites ("Container has no id" in the Fields plugin mixin, sync and async)
that previously had no test at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The container that hits `container.id is None` in
`set_ticket_custom_fields` came from the server's own
`list_plugin_fields_containers` response, not from caller input -- the
caller only supplied a container name that matched correctly. A missing
documented `id` on an otherwise-valid server row is a protocol defect,
not a validation failure, matching the already-GlpiProtocolError
`_extract_row_id` sites in the same file.

Also tighten the raise-site audit's docstring with the real census
(39 sites: 21 GlpiValidationError, 9 GlpiProtocolError, 4 RuntimeError,
2 TypeError, 1 GlpiServerError, 2 status_error_class dispatches) instead
of "dozens"/"a dozen".

Investigated the analogous _statistics.py "no users matched" site and
left it as GlpiValidationError: search_users legitimately returns an
empty list for well-formed, caller-driven criteria, so a zero-match
result is not a server contract violation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BREAKING CHANGE: glpi_python_client.clients.commons._errors is removed.
It was private and had zero library call sites -- only its own tests
referenced it.

Its sole job was unwrapping tenacity.RetryError to recover the underlying
message. reraise=True means RetryError no longer reaches callers, so the
helper has nothing left to unwrap.
Adds the user-guide error-handling section, records the breaking changes
in the changelog, and updates the async integration test to catch
GlpiNotFoundError instead of a requests exception.
Plan 1 (errors) final-review fixes. The exception hierarchy itself was
already correct; the prose describing it was not.

- Stop claiming every failure derives from GlpiError: network faults
  still raise requests exceptions until the httpx transport swap, and
  a handful of sites deliberately still raise RuntimeError/TypeError.
- Stop documenting GlpiTransportError/GlpiTimeoutError as if they were
  raised today -- they are reserved for the httpx swap.
- Wrap the two remaining stdlib ValueError leaks (parse_optional_env_int,
  StatisticsMixin._resolve_window) as chained GlpiValidationError, with
  tests proving both the typed error and except ValueError still catch it.
- Sweep 54 stale "Raises: ValueError" docstrings (the 51 the review found
  plus 3 async-mirror sites) to name the actual typed exception.
- Fix the 12-POST retry arithmetic explanation and the self-contradictory
  v1-session retry-policy docstring.
- Pin _refresh_access_token's network-error retry count (3, independent
  of the already-pinned 12-POST persistent-5xx path) with a new test.

No retry predicate, reraise=, retry policy, or 4xx/5xx raise site changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_refresh_access_token does not raise on a non-2xx refresh response: it logs
and falls through to a nested _acquire_token(), which carries its own
3-attempt decorator. Because the outer decorator also retried
GlpiServerError, a persistent 5xx cost 3 x (1 refresh POST + 3 nested
acquire POSTs) = 12 POSTs and ~33s of waiting.

Any GlpiServerError reaching the outer decorator has already been retried
three times by the nested call, so retrying it again was pure duplication.
Narrowing the outer predicate to requests.RequestException keeps the useful
retry (a network failure of the refresh POST itself, which raises rather
than falling through) and drops the duplicate one.

Measured: persistent 5xx 12 -> 4 POSTs. Persistent 401 (2) and network
errors on the refresh POST (3) are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two defects in integration_tests/ only; no library code involved.

test_iter_search_tickets_multi_page walked every matching ticket in
batches of 3 with no upper bound -- the only one of the suite's seven
iter_search loops missing a break. Against preprod (59,879 matching
tickets, ~4.9s per page) that is ~19,960 requests and several hours, so
the test never finished and stalled the whole suite behind it.

It now stops after 3 pages and asserts ids do not repeat across pages,
which actually verifies the start offset advances. The unbounded loop
asserted only isinstance(collected, list), so it could not have detected
a stuck offset no matter how long it ran.

Its filter also moves from `status==1` to `status.id==1`. The v2 contract
types Ticket.status as object{id,name}, so a bare `status==1` is silently
dropped and the search degrades to "every ticket" -- GLPI ignores filter
fields it does not recognise and returns the full set with no error. The
remaining `status==1` call sites are left for the dedicated filter-
correctness branch.

The three Fields plugin tests failed rather than skipped when the plugin
is absent. _skip_when_no_v1 only checked that v1 credentials were
configured, never that the plugin existed; without it GLPI rejects the
PluginFieldsContainer itemtype with a 400 instead of returning an empty
list, which the "no containers -> skip" guard could not catch. The new
fields_containers fixture skips on exactly that signature (400 +
ERROR_RESOURCE_NOT_FOUND_NOR_COMMONDBTM) and re-raises anything else, so
a genuine failure still fails.

Verified against live preprod: 42 passed, 4 skipped, no deselect, 3m16s.
Previously the suite could not complete at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@codecov-commenter

codecov-commenter commented Jul 17, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 96.31902% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.39%. Comparing base (7c9709a) to head (90faf45).

Files with missing lines Patch % Lines
...python_client/clients/api/plugins/_fields_async.py 90.00% 4 Missing ⚠️
glpi_python_client/clients/custom/_statistics.py 84.61% 2 Missing ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #16      +/-   ##
==========================================
+ Coverage   97.18%   97.39%   +0.20%     
==========================================
  Files          77       79       +2     
  Lines        2419     2536     +117     
==========================================
+ Hits         2351     2470     +119     
+ Misses         68       66       -2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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