Coverage for src\baobab_web_api_caller\transport\http_transport_caller.py: 92%
90 statements
« prev ^ index » next coverage.py v7.10.3, created at 2026-03-21 12:10 +0100
« prev ^ index » next coverage.py v7.10.3, created at 2026-03-21 12:10 +0100
1"""Transport HTTP synchrone basé sur `requests`."""
3from __future__ import annotations
5from dataclasses import dataclass
6from typing import Final
8import requests
10from baobab_web_api_caller.config.default_header_provider import DefaultHeaderProvider
11from baobab_web_api_caller.config.service_config import ServiceConfig
12from baobab_web_api_caller.core.baobab_request import BaobabRequest
13from baobab_web_api_caller.core.baobab_response import BaobabResponse
14from baobab_web_api_caller.core.baobab_web_api_caller import BaobabWebApiCaller
15from baobab_web_api_caller.core.error_response_mapper import ErrorResponseMapper
16from baobab_web_api_caller.core.json_response_decoder import JsonResponseDecoder
17from baobab_web_api_caller.core.request_url_builder import RequestUrlBuilder
18from baobab_web_api_caller.core.response_decoder import ResponseDecoder
19from baobab_web_api_caller.exceptions.rate_limit_exception import RateLimitException
20from baobab_web_api_caller.exceptions.server_http_exception import ServerHttpException
21from baobab_web_api_caller.exceptions.timeout_exception import TimeoutException
22from baobab_web_api_caller.exceptions.transport_exception import TransportException
23from baobab_web_api_caller.transport.call_context_builder import build_call_context
24from baobab_web_api_caller.transport.requests_session_factory import RequestsSessionFactory
25from baobab_web_api_caller.transport.system_sleeper import SystemSleeper
26from baobab_web_api_caller.transport.system_time_provider import SystemTimeProvider
27from baobab_web_api_caller.transport.throttler import Throttler
30@dataclass(frozen=True, slots=True)
31class HttpTransportCaller(BaobabWebApiCaller):
32 """Implémentation concrète du transport HTTP synchrone.
34 L'authentification est appliquée via la stratégie configurée, par composition.
35 Le mapping des erreurs HTTP (4xx/5xx) est délégué à `ErrorResponseMapper`, afin d'exposer un
36 contexte utile (code, extrait de body et certains headers) via la hiérarchie d'exceptions
37 du projet.
38 Cette classe se concentre ensuite sur la normalisation de la réponse (`BaobabResponse`) et
39 l'encapsulation des erreurs réseau (timeouts / erreurs requests) via exceptions du projet.
40 """
42 service_config: ServiceConfig
43 session_factory: RequestsSessionFactory
44 url_builder: RequestUrlBuilder
45 default_header_provider: DefaultHeaderProvider
46 response_decoder: ResponseDecoder
47 error_response_mapper: ErrorResponseMapper
48 throttler: Throttler
50 @classmethod
51 def from_service_config(
52 cls, service_config: ServiceConfig, session_factory: RequestsSessionFactory
53 ) -> "HttpTransportCaller":
54 """Construit un transport à partir d'une configuration de service."""
56 throttler: Final[Throttler] = Throttler(
57 rate_limit_policy=service_config.rate_limit_policy,
58 time_provider=SystemTimeProvider(),
59 sleeper=SystemSleeper(),
60 )
61 return cls(
62 service_config=service_config,
63 session_factory=session_factory,
64 url_builder=RequestUrlBuilder(base_url=service_config.base_url),
65 default_header_provider=DefaultHeaderProvider(
66 default_headers=service_config.default_headers
67 ),
68 response_decoder=JsonResponseDecoder(),
69 error_response_mapper=ErrorResponseMapper(),
70 throttler=throttler,
71 )
73 # pylint: disable-next=inconsistent-return-statements
74 def call(self, request: BaobabRequest) -> BaobabResponse:
75 """Exécute une requête HTTP synchrone via `requests`.
77 Comportement principal :
78 - assemble les en-têtes (défauts, requête, authentification) via `build_call_context` ;
79 - applique le throttling avant chaque tentative ;
80 - applique la politique de retry sur erreurs réseau (`requests`) et statuts retryables
81 (`429`, `5xx`) ;
82 - mappe les erreurs HTTP finales via `ErrorResponseMapper` ;
83 - ferme systématiquement la `requests.Session` en fin d'appel ;
84 - ferme chaque `requests.Response` après normalisation/décodage.
85 """
87 ctx = build_call_context(
88 request=request,
89 service_config=self.service_config,
90 default_header_provider=self.default_header_provider,
91 url_builder=self.url_builder,
92 session_factory=self.session_factory,
93 )
95 try:
96 retry_policy = self.service_config.retry_policy
97 last_error: Exception | None = None
98 for attempt in range(1, retry_policy.max_attempts + 1): 98 ↛ 123line 98 didn't jump to line 123 because the loop on line 98 didn't complete
99 self.throttler.throttle()
100 result = self._try_call_once(
101 ctx.session, ctx.prepared_request, ctx.url, ctx.timeout
102 )
104 if isinstance(result, BaobabResponse):
105 if self._is_retryable_status_code(result.status_code):
106 last_error = self._exception_for_retryable_status(result.status_code)
107 if attempt >= retry_policy.max_attempts:
108 self.error_response_mapper.raise_for_error(result)
109 else:
110 self.error_response_mapper.raise_for_error(result)
111 return result
112 else:
113 last_error = result
114 if attempt >= retry_policy.max_attempts:
115 raise result
117 delay = self._compute_backoff_seconds(
118 attempt, retry_policy.backoff_seconds, retry_policy.backoff_multiplier
119 )
120 if delay > 0: 120 ↛ 98line 120 didn't jump to line 98 because the condition on line 120 was always true
121 self.throttler.sleeper.sleep(delay)
123 if last_error is None:
124 raise TransportException("retry exhausted without error")
125 raise last_error
126 finally:
127 ctx.session.close()
129 @staticmethod
130 def _compute_backoff_seconds(attempt: int, base: float, multiplier: float) -> float:
131 if attempt <= 0: 131 ↛ 132line 131 didn't jump to line 132 because the condition on line 131 was never true
132 return 0.0
133 return float(base) * pow(float(multiplier), attempt - 1)
135 @staticmethod
136 def _is_retryable_status_code(status_code: int) -> bool:
137 return status_code == 429 or 500 <= status_code <= 599
139 @staticmethod
140 def _exception_for_retryable_status(status_code: int) -> Exception:
141 if status_code == 429:
142 return RateLimitException(
143 status_code=status_code,
144 message="HTTP 429 Too Many Requests",
145 )
146 return ServerHttpException(
147 status_code=status_code,
148 message=f"HTTP {status_code} Server Error",
149 )
151 def _try_call_once(
152 self,
153 session: requests.Session,
154 prepared: BaobabRequest,
155 url: str,
156 timeout: float | None,
157 ) -> BaobabResponse | Exception:
158 response: requests.Response | None = None
159 try:
160 response = session.request(
161 method=prepared.method.value,
162 url=url,
163 params=None,
164 headers=dict(prepared.headers),
165 json=prepared.json_body,
166 data=dict(prepared.form_body) if prepared.form_body is not None else None,
167 timeout=timeout,
168 )
169 except requests.Timeout as exc:
170 return TimeoutException(str(exc))
171 except requests.RequestException as exc:
172 return TransportException(str(exc))
174 try:
175 raw = self._to_baobab_response(response)
176 return self.response_decoder.decode(raw)
177 finally:
178 if response is not None:
179 response.close()
181 @staticmethod
182 def _to_baobab_response(response: requests.Response) -> BaobabResponse:
183 headers: dict[str, str] = {str(k): str(v) for k, v in response.headers.items()}
185 return BaobabResponse(
186 status_code=response.status_code,
187 headers=headers,
188 text=response.text,
189 content=response.content,
190 json_data=None,
191 )