Skip to content

Commit 6f3fd2c

Browse files
authored
Merge pull request #16 from baraline/feat/real-async-io
Feat/real async io
2 parents 7c9709a + 90faf45 commit 6f3fd2c

57 files changed

Lines changed: 2815 additions & 329 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Changelog
2+
3+
All notable changes to this project are documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6+
7+
## Unreleased
8+
9+
### Fixed
10+
11+
- `GLPITokenManager._refresh_access_token`'s retry decorator no longer
12+
retries a `GlpiServerError` from its fall-through to the nested
13+
`_acquire_token()` call. That nested call already carries its own
14+
independent 3-attempt retry decorator for `GlpiServerError`, so the
15+
outer decorator retrying it too meant a persistent 5xx during token
16+
refresh cost 3 (outer attempts) × (1 refresh POST + 3 nested acquire
17+
POSTs) = 12 POST requests and ~33s of `wait_fixed(3)` sleep, instead of
18+
the 3 attempts the retry configuration alone would suggest. The outer
19+
decorator now only retries `requests.RequestException` (a genuine
20+
network fault on the refresh POST itself), which is not covered by the
21+
nested call at all. A persistent 5xx now costs exactly 1 refresh POST +
22+
3 nested acquire POSTs = 4 POST requests. A persistent 401 (2 POSTs) and
23+
a network error on the refresh POST (3 POSTs) are unaffected.
24+
- `AsyncGlpiClient.create_kb_article` / `update_kb_article` no longer
25+
silently drop `categories`. Both methods called the public
26+
`set_kb_article_categories` through `self` from inside a synchronous
27+
method body; `AsyncBridge.__init_subclass__` wraps every public sync
28+
method into a coroutine, so that call returned an un-awaited coroutine
29+
instead of performing the write. The article was created (or updated)
30+
successfully, a valid id was returned, and no exception was raised —
31+
the category assignment simply never happened. Fixed with hand-written
32+
async overrides in `_article_async.py` that strip `categories` from the
33+
v2 body, run the v2 write in a worker thread, and apply the category
34+
fallback through an awaited call.
35+
- `AsyncGlpiClient.get_ticket_custom_fields` / `set_ticket_custom_fields`
36+
raised `TypeError: 'coroutine' object is not iterable` and were
37+
unusable. Same root cause as above: a sync method reaching a sibling
38+
public method through `self` received a coroutine instead of a result.
39+
Fixed with hand-written async overrides in `_fields_async.py`.
40+
- The integration suite is runnable end-to-end again. Two defects, both in
41+
`integration_tests/` only (no library code involved):
42+
- `test_iter_search_tickets_multi_page` walked *every* matching ticket in
43+
batches of 3 with no upper bound — it was the only one of the suite's
44+
seven `iter_search` loops missing a `break`. Against a real instance
45+
(59,879 matching tickets) that is ~19,960 requests and several hours,
46+
which stalled the whole suite. It now stops after 3 pages and asserts
47+
that ids do not repeat across pages, which actually verifies that the
48+
`start` offset advances — the old unbounded loop asserted only
49+
`isinstance(collected, list)` and so could not have detected a stuck
50+
offset.
51+
- The three GLPI Fields plugin tests failed rather than skipped when the
52+
plugin is not installed. `_skip_when_no_v1` only checked that v1
53+
*credentials were configured*, never that the *plugin existed*; an
54+
absent plugin makes GLPI reject the `PluginFieldsContainer` itemtype
55+
with a 400 rather than return an empty list. A new `fields_containers`
56+
fixture skips on exactly that signature (400 +
57+
`ERROR_RESOURCE_NOT_FOUND_NOR_COMMONDBTM`) and re-raises anything else.
58+
- `parse_optional_env_int` (environment/config parsing) and
59+
`StatisticsMixin._resolve_window` (the date-window helper behind
60+
`get_ticket_statistics` / `get_task_durations` / `get_user_activity`)
61+
no longer let a malformed value escape as a bare stdlib `ValueError`
62+
from `int()` / `date.fromisoformat()` (e.g. `GLPI_TIMEOUT=abc` or
63+
`get_ticket_statistics(start_date="2026-13-45")`). Both now raise
64+
`GlpiValidationError`, chaining the original error via `from` rather
65+
than swallowing it. Non-breaking: `GlpiValidationError` inherits
66+
`ValueError`, so `except ValueError` still catches it.
67+
68+
### Added
69+
70+
- `glpi_python_client/clients/tests/test_async_selfcall_guard.py`: a
71+
structural AST guard that fails the suite if any public method on
72+
`GlpiClient` transitively reaches another public method through a
73+
literal `self.name(...)` call (directly, or via a private helper)
74+
without a corresponding hand-written async override on
75+
`AsyncGlpiClient`. This prevents the same bug class — silent data loss
76+
or a `TypeError` at call time, depending on how the dropped coroutine is
77+
used — from being reintroduced by a future endpoint.
78+
- A public exception hierarchy, exported from the package root:
79+
`GlpiError`, `GlpiTransportError`, `GlpiTimeoutError`, `GlpiStatusError`,
80+
`GlpiAuthError`, `GlpiNotFoundError`, `GlpiServerError`,
81+
`GlpiValidationError` and `GlpiProtocolError`. `GlpiStatusError` and its
82+
subclasses carry `.status_code`, `.url` and `.response_text`. A GLPI 404
83+
and a bad argument were previously both a bare `ValueError` and could not
84+
be told apart.
85+
- `FakeResponse` (in the public `glpi_python_client.testing` module) gained
86+
a `url` attribute.
87+
- A user-guide "Error handling" section documenting the exception
88+
hierarchy and the retry behaviour for both the transport layer and OAuth
89+
token acquisition/refresh.
90+
91+
### Changed
92+
93+
- **Breaking:** a persistent 5xx now raises `GlpiServerError` instead of
94+
`tenacity.RetryError`. The retry decorators gained `reraise=True`. Code
95+
doing `except tenacity.RetryError` and digging out
96+
`.last_attempt.exception()` should now catch `GlpiServerError` directly.
97+
- **Breaking:** unexpected HTTP statuses raise a `GlpiStatusError` subclass;
98+
rejected arguments and configuration raise `GlpiValidationError`; 2xx
99+
responses with an unusable body raise `GlpiProtocolError`. All three
100+
inherit `ValueError`, so existing `except ValueError` handlers keep
101+
working.
102+
- **Breaking:** a non-2xx OAuth token response raises `GlpiAuthError` (401/403)
103+
or `GlpiServerError` (5xx). The token retry decorators had no `retry=`
104+
predicate and therefore retried every failure, including a rejected
105+
credential; a wrong `client_secret` cost 3 attempts and 6 seconds. OAuth
106+
4xx is now final, matching the rest of the library. OAuth 5xx is still
107+
retried.
108+
- **Breaking:** the private `glpi_python_client.clients.commons._errors`
109+
module and its `remote_error_message` helper are removed. It had no
110+
library call sites, and `reraise=True` leaves it nothing to unwrap.
111+
112+
### Unchanged (deliberately)
113+
114+
- Retry semantics: 5xx retried 3 times with a 3-second fixed wait, 4xx never
115+
retried.
116+
- Tolerant search endpoints still return `[]` rather than raising on a 4xx.
117+
- The `TypeError` sites in environment parsing and the `RuntimeError` sites
118+
for closed clients, missing v1 sessions and partial KB failures still
119+
raise those types. `GlpiValidationError` inherits `ValueError`, not
120+
`TypeError`, so converting them would break `except TypeError` callers.
121+
- The transport is still `requests`. Network faults (connection reset, DNS,
122+
timeout) still surface as `requests` exceptions; they become
123+
`GlpiTransportError` / `GlpiTimeoutError` when the transport moves to
124+
httpx, with no change to the class names above.
125+
126+
### Notes
127+
128+
- Both fixed bugs shared one root cause: `AsyncBridge` wraps every public
129+
sync method into a coroutine, so a sync method body calling a sibling
130+
public method through `self` (rather than through a hand-written async
131+
override) silently receives a coroutine instead of the real return
132+
value.
133+
- This is a documentation-only release note; **no version was released**
134+
from this branch. The next release is planned as 0.4.0, an httpx +
135+
unasync rewrite that removes `AsyncBridge` entirely, making this class
136+
of bug structurally impossible rather than merely guarded against.

‎docs/api_reference.rst‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,61 @@ the asynchronous one wraps each synchronous method into a coroutine.
2828
:members:
2929
:show-inheritance:
3030

31+
Exceptions
32+
----------
33+
34+
Exceptions raised for a bad argument, an unexpected HTTP status, or an
35+
unusable response body derive from :class:`GlpiError`.
36+
:class:`GlpiStatusError`, :class:`GlpiValidationError` and
37+
:class:`GlpiProtocolError` also inherit :class:`ValueError` for backwards
38+
compatibility with releases that raised bare ``ValueError``.
39+
40+
This is not the library's entire failure surface. Network-level faults
41+
(connection failures, DNS errors, timeouts) still propagate as
42+
``requests`` exceptions today: :class:`GlpiTransportError` and
43+
:class:`GlpiTimeoutError` are reserved for that case but are not raised
44+
until a future httpx transport swap. A handful of sites also
45+
deliberately still raise bare ``RuntimeError`` or ``TypeError`` instead
46+
of a library type, so existing ``except RuntimeError`` / ``except
47+
TypeError`` code keeps working. See :ref:`error-handling` in the user
48+
guide for the full picture, including which methods raise which type.
49+
50+
.. autoexception:: GlpiError
51+
:members:
52+
:show-inheritance:
53+
54+
.. autoexception:: GlpiTransportError
55+
:members:
56+
:show-inheritance:
57+
58+
.. autoexception:: GlpiTimeoutError
59+
:members:
60+
:show-inheritance:
61+
62+
.. autoexception:: GlpiStatusError
63+
:members:
64+
:show-inheritance:
65+
66+
.. autoexception:: GlpiAuthError
67+
:members:
68+
:show-inheritance:
69+
70+
.. autoexception:: GlpiNotFoundError
71+
:members:
72+
:show-inheritance:
73+
74+
.. autoexception:: GlpiServerError
75+
:members:
76+
:show-inheritance:
77+
78+
.. autoexception:: GlpiValidationError
79+
:members:
80+
:show-inheritance:
81+
82+
.. autoexception:: GlpiProtocolError
83+
:members:
84+
:show-inheritance:
85+
3186
Aggregated Models
3287
-----------------
3388

‎docs/development.md‎

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,22 @@ python -m pytest
6262
transport serialises OAuth token acquisition so concurrent
6363
`asyncio.gather` fan-outs on the async client cannot race.
6464
- `glpi_python_client.clients.api.*` contains the contract-aligned
65-
synchronous endpoint mixins, grouped by GLPI subtree (administration,
66-
assistance, assistance/timeline, dropdowns, management).
65+
endpoint mixins, grouped by GLPI subtree (administration, assistance,
66+
assistance/timeline, dropdowns, management, knowledgebase, plugins).
67+
Most are synchronous; `knowledgebase/_article_async.py` and
68+
`plugins/_fields_async.py` are hand-written async overrides needed
69+
because their synchronous bodies call a sibling public method through
70+
`self` (see the `clients.custom` entry below for the other reason a
71+
method needs one).
6772
- `glpi_python_client.clients.custom` contains custom helpers built on
6873
top of the API mixins. Each helper has a synchronous implementation
69-
(`_ticket_context.py`, `_statistics.py`) plus an optional async
70-
override (`_ticket_context_async.py`, `_statistics_async.py`) that
71-
fans the underlying calls out concurrently with `asyncio.gather`.
74+
(`_ticket_context.py`, `_statistics.py`) plus an async override
75+
(`_ticket_context_async.py`, `_statistics_async.py`) that fans the
76+
underlying calls out concurrently with `asyncio.gather`. That is one
77+
of two reasons a method needs a hand-written async override; the
78+
other — a synchronous body calling a sibling public method through
79+
`self` — is why `clients.api.knowledgebase` and `clients.api.plugins`
80+
also ship one (see above).
7281
- `glpi_python_client.auth._v1_session` contains the legacy v1
7382
session used for binary document uploads.
7483
- `glpi_python_client.models` contains typed request and response
@@ -93,15 +102,22 @@ python -m pytest
93102
async client picks the new method up automatically through the
94103
`AsyncBridge` — do not duplicate the method on a parallel async
95104
mixin unless you genuinely need concurrent fan-out (`asyncio.gather`)
96-
inside the method body.
105+
inside the method body, **or** the method calls a sibling public
106+
method through `self` (directly, or transitively via a private
107+
helper): the bridge wraps that sibling into a coroutine, so the
108+
un-awaited call silently drops instead of running. The guard in step
109+
4 fails the suite if you miss this.
97110
3. Put reusable endpoint names, payload builders, response handling, or
98111
pagination logic in the focused
99112
`glpi_python_client.clients.commons` helper module named for that
100113
responsibility.
101114
4. Add unit tests for payload serialization, response parsing, and
102115
client behavior. The parity test in
103116
`glpi_python_client/clients/tests/test_parity.py` will fail if the
104-
sync and async surfaces diverge.
117+
sync and async surfaces diverge, and
118+
`glpi_python_client/clients/tests/test_async_selfcall_guard.py` will
119+
fail if a public method reaches another public method through `self`
120+
without a hand-written async override.
105121
5. Document the new workflow in `docs/user_guide.rst` or the README.
106122

107123
Keep organization-specific defaults outside the package core.

‎docs/user_guide.rst‎

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ The guide is split into the following sections:
4242
context view, and the reporting helpers.
4343
6. **End-to-end examples** — full workflows that combine the previous
4444
building blocks.
45+
7. **Error handling** — the public exception hierarchy, what each
46+
branch means, and how retries behave.
4547

4648
The sample snippets in sections 3 to 6 use the synchronous
4749
:class:`GlpiClient`. Every snippet works on the asynchronous client by
@@ -1396,4 +1398,113 @@ Example output::
13961398
'duration_by_user': {'22': 4500, '21': 1800},
13971399
'duration_by_ticket': {120: 1800, 121: 900, 122: 1800, 123: 1800},
13981400
},
1399-
}
1401+
}
1402+
1403+
.. _error-handling:
1404+
1405+
7. Error handling
1406+
-----------------
1407+
1408+
Exceptions the client raises for a bad argument, an unexpected HTTP
1409+
status, or an unusable response body derive from
1410+
:class:`~glpi_python_client.GlpiError`, so one handler covers that part
1411+
of the library surface:
1412+
1413+
.. code-block:: python
1414+
1415+
from glpi_python_client import GlpiClient, GlpiError
1416+
1417+
client = GlpiClient.from_env()
1418+
try:
1419+
ticket = client.get_ticket(42)
1420+
except GlpiError as exc:
1421+
print(f"GLPI call failed: {exc}")
1422+
1423+
This is not yet the client's entire failure surface. The client is still
1424+
built on ``requests``, and network-level faults -- connection failures,
1425+
DNS errors, timeouts -- still propagate as ``requests`` exceptions today
1426+
rather than a :class:`~glpi_python_client.GlpiError` subclass. Catch
1427+
``requests.RequestException`` alongside :class:`~glpi_python_client.GlpiError`
1428+
if you need to handle those too:
1429+
1430+
.. code-block:: python
1431+
1432+
import requests
1433+
from glpi_python_client import GlpiClient, GlpiError
1434+
1435+
client = GlpiClient.from_env()
1436+
try:
1437+
ticket = client.get_ticket(42)
1438+
except (GlpiError, requests.RequestException) as exc:
1439+
print(f"GLPI call failed: {exc}")
1440+
1441+
A handful of sites also deliberately still raise bare ``RuntimeError``
1442+
(using a closed client, a missing v1 document session, a partially
1443+
failed knowledge-base write) or ``TypeError`` (a malformed environment
1444+
value) instead of a library type, so ``except RuntimeError`` / ``except
1445+
TypeError`` code written against earlier releases keeps working.
1446+
1447+
The hierarchy lets you narrow as far as you need:
1448+
1449+
.. code-block:: text
1450+
1451+
GlpiError
1452+
├── GlpiTransportError reserved for the httpx transport swap;
1453+
│ └── GlpiTimeoutError not raised yet -- see the note above
1454+
├── GlpiStatusError GLPI answered with an unexpected status
1455+
│ ├── GlpiAuthError 401 / 403
1456+
│ ├── GlpiNotFoundError 404
1457+
│ └── GlpiServerError 5xx (retried up to 3 attempts before it
1458+
│ reaches you)
1459+
├── GlpiValidationError the client rejected your argument
1460+
└── GlpiProtocolError GLPI answered 2xx with an unusable body
1461+
1462+
:class:`~glpi_python_client.GlpiStatusError` carries the diagnostics you
1463+
usually want:
1464+
1465+
.. code-block:: python
1466+
1467+
from glpi_python_client import GlpiNotFoundError
1468+
1469+
try:
1470+
ticket = client.get_ticket(999999)
1471+
except GlpiNotFoundError as exc:
1472+
print(exc.status_code) # 404
1473+
print(exc.url) # the absolute URL that was requested
1474+
print(exc.response_text) # the response body
1475+
1476+
.. note::
1477+
1478+
:class:`~glpi_python_client.GlpiStatusError`,
1479+
:class:`~glpi_python_client.GlpiValidationError` and
1480+
:class:`~glpi_python_client.GlpiProtocolError` also inherit
1481+
:class:`ValueError`. Code written against earlier releases, which
1482+
raised bare ``ValueError``, keeps working unchanged.
1483+
1484+
Retry behaviour
1485+
~~~~~~~~~~~~~~~
1486+
1487+
Each transport and v1-session retry decorator retries a server error
1488+
(5xx) up to 3 attempts with a 3-second fixed wait before
1489+
:class:`~glpi_python_client.GlpiServerError` reaches you. Client errors
1490+
(4xx) are never retried — they cannot succeed on a second attempt.
1491+
1492+
OAuth token acquisition follows the same 3-attempt policy, with one
1493+
exception: refreshing an already-issued token does not raise directly on
1494+
a failed response. It logs a warning and falls through to a fresh token
1495+
acquisition, which carries its own independent 3-attempt retry
1496+
decorator. The refresh method's own retry decorator only retries a
1497+
network-level fault on the refresh request itself (a
1498+
``requests.RequestException`` raised before any response is received) —
1499+
it does **not** retry a :class:`~glpi_python_client.GlpiServerError`
1500+
from the fall-through, since that failure is already being retried by
1501+
the nested acquisition call. A persistent 5xx encountered while
1502+
refreshing therefore costs exactly 1 refresh POST + up to 3 nested
1503+
acquisition POSTs = 4 POST requests before
1504+
:class:`~glpi_python_client.GlpiServerError` reaches you. A rejected
1505+
credential (401/403) is not retried at either layer and fails after at
1506+
most 2 POST requests.
1507+
1508+
Search methods are deliberately tolerant: ``search_tickets`` and its
1509+
siblings return an empty list rather than raising when GLPI rejects the
1510+
query. Methods that fetch or mutate one specific record always raise.

0 commit comments

Comments
 (0)