1212The session wrapper owns the authenticated v1 lifecycle (init, refresh,
1313kill) 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
1727from __future__ import annotations
1828
1929import json
2030import logging
2131from datetime import datetime , timedelta , timezone
22- from typing import cast
32+ from typing import Any , cast
2333
2434import 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
2743logger = 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
3354class 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