Skip to content

Commit eca191f

Browse files
committed
refactor clients and v1 session handling
1 parent 8e019b8 commit eca191f

12 files changed

Lines changed: 413 additions & 330 deletions

File tree

.coverage

0 Bytes
Binary file not shown.

glpi_python_client/auth/_v1_session.py

Lines changed: 103 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,43 @@
1212
The session wrapper owns the authenticated v1 lifecycle (init, refresh,
1313
kill) and exposes the typed ``upload_document`` helper plus the generic
1414
``request_json`` JSON-only HTTP helper that newer mixins build on.
15+
16+
Retry policy
17+
------------
18+
Every public dispatch helper (``_init_session``, ``request_json``,
19+
``upload_document``) carries the same :mod:`tenacity` retry decorator
20+
used by the v2 transport: three attempts spaced by three seconds,
21+
triggered exclusively by :class:`requests.RequestException` (which
22+
:func:`finalize_request_response` raises for 5xx server errors).
23+
:class:`ValueError` raised by status-code or payload checks does not
24+
trigger a retry — client-side or 4xx failures are surfaced immediately.
1525
"""
1626

1727
from __future__ import annotations
1828

1929
import json
2030
import logging
2131
from datetime import datetime, timedelta, timezone
22-
from typing import cast
32+
from typing import Any, cast
2333

2434
import requests
25-
from tenacity import retry, stop_after_attempt, wait_fixed
35+
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
36+
37+
from glpi_python_client.clients.commons._http import (
38+
ensure_response_status,
39+
finalize_request_response,
40+
response_json_or_empty,
41+
)
2642

2743
logger = logging.getLogger(__name__)
2844

2945
_DEFAULT_SESSION_REFRESH_INTERVAL_SECONDS = 15 * 60
3046
_AUTH_FAILURE_STATUS_CODES = frozenset({401, 403})
47+
_RETRY_ON_NETWORK_ERRORS = retry(
48+
retry=retry_if_exception_type(requests.RequestException),
49+
stop=stop_after_attempt(3),
50+
wait=wait_fixed(3),
51+
)
3152

3253

3354
class GLPIV1Session:
@@ -43,7 +64,7 @@ def __init__(
4364
*,
4465
base_url: str,
4566
user_token: str,
46-
app_token: str,
67+
app_token: str | None = None,
4768
verify_ssl: bool = True,
4869
session_refresh_interval_seconds: int = (
4970
_DEFAULT_SESSION_REFRESH_INTERVAL_SECONDS
@@ -66,12 +87,14 @@ def __init__(
6687
self._session_token: str | None = None
6788
self._session_started_at: datetime | None = None
6889

69-
@retry(stop=stop_after_attempt(3), wait=wait_fixed(3))
90+
@_RETRY_ON_NETWORK_ERRORS
7091
def _init_session(self) -> None:
7192
"""Acquire one fresh GLPI v1 session token via ``GET /initSession``.
7293
7394
The call replaces any existing session state and stores the
7495
authentication timestamp used by the refresh-interval check.
96+
Network errors and 5xx responses are retried; 4xx and payload
97+
errors propagate immediately as :class:`ValueError`.
7598
"""
7699

77100
headers: dict[str, str] = {
@@ -82,16 +105,20 @@ def _init_session(self) -> None:
82105
if self._app_token:
83106
headers["App-Token"] = self._app_token
84107

85-
response = self._http.get(
86-
f"{self._base_url}/initSession",
87-
headers=headers,
88-
timeout=30,
108+
url = f"{self._base_url}/initSession"
109+
response = self._http.get(url, headers=headers, timeout=30)
110+
finalize_request_response(
111+
response,
112+
method="get",
113+
url=url,
114+
success_statuses=(200,),
115+
logger=logger,
116+
)
117+
ensure_response_status(
118+
response,
119+
success_statuses=(200,),
120+
failure_message="GLPI v1 initSession failed",
89121
)
90-
if response.status_code != 200:
91-
raise ValueError(
92-
"GLPI v1 initSession failed: "
93-
f"{response.status_code} {response.text[:300]}"
94-
)
95122

96123
token = response.json().get("session_token")
97124
if not token:
@@ -147,11 +174,12 @@ def _renew_session(self) -> None:
147174
"""Drop the current GLPI v1 session token and acquire a new one.
148175
149176
The previous token is best-effort killed so the GLPI server can release
150-
the associated session state immediately.
177+
the associated session state immediately. ``_init_session`` will set
178+
the new token on success or raise, leaving the existing state
179+
untouched on failure (the retry decorator handles transients).
151180
"""
152181

153-
old_token = self._session_token
154-
if old_token is not None:
182+
if self._session_token is not None:
155183
try:
156184
self._http.get(
157185
f"{self._base_url}/killSession",
@@ -160,8 +188,6 @@ def _renew_session(self) -> None:
160188
)
161189
except Exception:
162190
logger.warning("Failed to kill stale GLPI v1 session.", exc_info=True)
163-
self._session_token = None
164-
self._session_started_at = None
165191
self._init_session()
166192

167193
def _headers(self) -> dict[str, str]:
@@ -179,13 +205,19 @@ def _authenticated_request(
179205
method: str,
180206
url: str,
181207
*,
208+
success_statuses: tuple[int, ...],
182209
headers: dict[str, str] | None = None,
183-
**kwargs: object,
210+
**kwargs: Any,
184211
) -> requests.Response:
185-
"""Send one authenticated GLPI v1 request with one auth-failure retry.
186-
187-
When the GLPI server rejects the current token, the helper renews the
188-
session and retries the request once before returning the response.
212+
"""Send one authenticated GLPI v1 request and finalize the response.
213+
214+
When the GLPI server rejects the current token the helper renews
215+
the session and retries the request once. The returned response
216+
has already been passed through :func:`finalize_request_response`
217+
so 5xx errors surface as :class:`requests.HTTPError` for the
218+
outer tenacity retry to catch; non-success statuses outside the
219+
``success_statuses`` set are logged but otherwise returned for
220+
the caller to validate with :func:`ensure_response_status`.
189221
"""
190222

191223
request_headers = {**self._headers(), **(headers or {})}
@@ -194,18 +226,23 @@ def _authenticated_request(
194226
requests.Response,
195227
request_method(url, headers=request_headers, **kwargs),
196228
)
197-
if not _is_auth_failure_response(response):
198-
return response
199-
200-
logger.warning(
201-
"GLPI v1 session token was rejected; refreshing session and retrying "
202-
"request once."
203-
)
204-
self._renew_session()
205-
request_headers = {**self._headers(), **(headers or {})}
206-
return cast(
207-
requests.Response,
208-
request_method(url, headers=request_headers, **kwargs),
229+
if _is_auth_failure_response(response):
230+
logger.warning(
231+
"GLPI v1 session token was rejected; refreshing session and "
232+
"retrying request once."
233+
)
234+
self._renew_session()
235+
request_headers = {**self._headers(), **(headers or {})}
236+
response = cast(
237+
requests.Response,
238+
request_method(url, headers=request_headers, **kwargs),
239+
)
240+
return finalize_request_response(
241+
response,
242+
method=method,
243+
url=url,
244+
success_statuses=success_statuses,
245+
logger=logger,
209246
)
210247

211248
def close(self) -> None:
@@ -230,6 +267,7 @@ def close(self) -> None:
230267
self._session_started_at = None
231268
self._http.close()
232269

270+
@_RETRY_ON_NETWORK_ERRORS
233271
def request_json(
234272
self,
235273
method: str,
@@ -244,7 +282,9 @@ def request_json(
244282
245283
The helper centralises session-token handling, the one-shot retry
246284
on token rejection, status validation and JSON parsing so callers
247-
can stay focused on their endpoint semantics.
285+
can stay focused on their endpoint semantics. Network errors and
286+
5xx responses are retried; 4xx and payload errors propagate
287+
immediately as :class:`ValueError`.
248288
249289
Parameters
250290
----------
@@ -276,28 +316,35 @@ def request_json(
276316
Raises
277317
------
278318
ValueError
279-
If the v1 server returns a non-success HTTP status.
319+
If the v1 server returns a non-success HTTP status outside
320+
the 5xx range (which surfaces as :class:`requests.HTTPError`
321+
and is retried).
280322
"""
281323

282324
url = f"{self._base_url}/{path.lstrip('/')}"
283-
kwargs: dict[str, object] = {"timeout": 30}
325+
kwargs: dict[str, Any] = {"timeout": 30}
284326
if params is not None:
285327
kwargs["params"] = params
286328
headers: dict[str, str] = {}
287329
if json_body is not None:
288330
kwargs["data"] = json.dumps(json_body)
289331
headers["Content-Type"] = "application/json"
290332
response = self._authenticated_request(
291-
method, url, headers=headers or None, **kwargs
333+
method,
334+
url,
335+
success_statuses=success_statuses,
336+
headers=headers or None,
337+
**kwargs,
338+
)
339+
ensure_response_status(
340+
response,
341+
success_statuses=success_statuses,
342+
failure_message=failure_message
343+
or f"GLPI v1 {method.upper()} {path} failed",
292344
)
293-
if response.status_code not in success_statuses:
294-
prefix = failure_message or f"GLPI v1 {method.upper()} {path} failed"
295-
raise ValueError(f"{prefix}: {response.status_code} {response.text[:300]}")
296-
if not response.content or not response.text.strip():
297-
return {}
298-
return response.json()
299-
300-
@retry(stop=stop_after_attempt(3), wait=wait_fixed(3))
345+
return response_json_or_empty(response)
346+
347+
@_RETRY_ON_NETWORK_ERRORS
301348
def upload_document(
302349
self,
303350
filename: str,
@@ -312,7 +359,9 @@ def upload_document(
312359
313360
The legacy v1 endpoint uses a multipart upload manifest so the GLPI
314361
server can create the document, link it to the optional parent ticket,
315-
and assign it to the provided entity in a single round-trip.
362+
and assign it to the provided entity in a single round-trip. Network
363+
errors and 5xx responses are retried; 4xx and payload errors
364+
propagate immediately as :class:`ValueError`.
316365
"""
317366

318367
manifest_input: dict[str, object] = {
@@ -329,17 +378,18 @@ def upload_document(
329378
response = self._authenticated_request(
330379
"POST",
331380
f"{self._base_url}/Document",
381+
success_statuses=(200, 201),
332382
files=[
333383
("uploadManifest", (None, manifest, "application/json")),
334384
("filename[]", (filename, content, mime_type)),
335385
],
336386
timeout=60,
337387
)
338-
if response.status_code not in (200, 201):
339-
raise ValueError(
340-
"GLPI v1 document upload failed: "
341-
f"{response.status_code} {response.text[:300]}"
342-
)
388+
ensure_response_status(
389+
response,
390+
success_statuses=(200, 201),
391+
failure_message="GLPI v1 document upload failed",
392+
)
343393
payload = response.json()
344394
if not isinstance(payload, dict):
345395
raise ValueError(

0 commit comments

Comments
 (0)