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

1"""Transport HTTP synchrone basé sur `requests`.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass 

6from typing import Final 

7 

8import requests 

9 

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 

28 

29 

30@dataclass(frozen=True, slots=True) 

31class HttpTransportCaller(BaobabWebApiCaller): 

32 """Implémentation concrète du transport HTTP synchrone. 

33 

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 """ 

41 

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 

49 

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.""" 

55 

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 ) 

72 

73 # pylint: disable-next=inconsistent-return-statements 

74 def call(self, request: BaobabRequest) -> BaobabResponse: 

75 """Exécute une requête HTTP synchrone via `requests`. 

76 

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 """ 

86 

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 ) 

94 

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 ) 

103 

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 

116 

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) 

122 

123 if last_error is None: 

124 raise TransportException("retry exhausted without error") 

125 raise last_error 

126 finally: 

127 ctx.session.close() 

128 

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) 

134 

135 @staticmethod 

136 def _is_retryable_status_code(status_code: int) -> bool: 

137 return status_code == 429 or 500 <= status_code <= 599 

138 

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 ) 

150 

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)) 

173 

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() 

180 

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()} 

184 

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 )