|
| 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. |
0 commit comments