Coverage for src/taipanstack/bridges/http_bridge.py: 100%

157 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-10-13 20:52 +0000

1""" 

2HTTP Bridge — safe httpx client with SSRF protection and resilience. 

3 

4Wraps ``httpx.AsyncClient`` with TaipanStack's ``guard_ssrf``, 

5retry, and circuit breaker integrations. All outbound URLs are 

6validated against SSRF before the request is sent. 

7""" 

8 

9from __future__ import annotations 

10 

11import asyncio 

12import logging 

13from collections.abc import Awaitable, Callable, Mapping, Sequence 

14from typing import TYPE_CHECKING, TypeAlias, cast 

15 

16from typing_extensions import TypedDict, Unpack 

17 

18if TYPE_CHECKING: 

19 import httpx 

20 

21 

22from taipanstack.core.result import Err, Ok, Result 

23from taipanstack.resilience.circuit_breaker import ( 

24 CircuitBreaker, 

25 CircuitBreakerError, 

26) 

27from taipanstack.resilience.retry import RetryConfig, calculate_delay 

28from taipanstack.security.guards import guard_ssrf 

29 

30logger = logging.getLogger("taipanstack.bridges.http") 

31 

32 

33# JsonType definition for robust typing 

34JsonType: TypeAlias = dict[str, object] | list[object] | str | int | float | bool | None 

35 

36 

37class HttpRequestKwargs(TypedDict, total=False): 

38 """Type definitions for HTTP request kwargs.""" 

39 

40 content: bytes | str | None 

41 data: ( 

42 dict[str, str | int | float | bool | None] 

43 | list[tuple[str, str]] 

44 | bytes 

45 | str 

46 | None 

47 ) 

48 files: dict[str, bytes | tuple[str, bytes]] 

49 json: JsonType 

50 params: ( 

51 dict[ 

52 str, 

53 str | int | float | bool | None | Sequence[str | int | float | bool | None], 

54 ] 

55 | list[tuple[str, str | int | float | bool | None]] 

56 | str 

57 | bytes 

58 | None 

59 ) 

60 headers: dict[str, str] 

61 cookies: dict[str, str] 

62 auth: tuple[str, str] 

63 follow_redirects: bool 

64 extensions: dict[str, object] 

65 

66 

67class HttpClientKwargs(TypedDict, total=False): 

68 """Type definitions for HTTP client kwargs.""" 

69 

70 base_url: str 

71 headers: dict[str, str] 

72 cookies: dict[str, str] 

73 verify: bool | str 

74 cert: str | tuple[str, str] | tuple[str, str, str] 

75 http1: bool 

76 http2: bool 

77 proxy: str 

78 mounts: Mapping[str, httpx.AsyncBaseTransport | None] 

79 follow_redirects: bool 

80 max_redirects: int 

81 event_hooks: dict[str, list[Callable[..., Awaitable[object]]]] 

82 trust_env: bool 

83 default_encoding: str | Callable[[bytes], str] 

84 

85 

86# --- optional httpx import ------------------------------------------------ 

87 

88try: 

89 import httpx 

90 

91 _HAS_HTTPX = True 

92except ImportError: 

93 _HAS_HTTPX = False 

94 

95if TYPE_CHECKING: 

96 import httpx 

97 

98# Default status codes that trigger a retry 

99_RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({500, 502, 503, 504, 429}) 

100 

101 

102def _check_circuit_breaker( 

103 circuit_breaker: CircuitBreaker, 

104) -> CircuitBreakerError | None: 

105 """Check circuit breaker state and return error if OPEN. 

106 

107 Args: 

108 circuit_breaker: The circuit breaker to check. 

109 

110 Returns: 

111 ``CircuitBreakerError`` if open, ``None`` otherwise. 

112 

113 """ 

114 if not circuit_breaker._should_attempt(): 

115 return CircuitBreakerError( 

116 f"Circuit '{circuit_breaker.name}' is open", 

117 state=circuit_breaker.state, 

118 ) 

119 return None 

120 

121 

122def _check_ssrf(url: str, ssrf_protection: bool) -> Result[None, Exception]: 

123 """Validate URL against SSRF if protection is enabled. 

124 

125 Args: 

126 url: The URL to validate. 

127 ssrf_protection: Whether SSRF protection is enabled. 

128 

129 Returns: 

130 ``Ok(None)`` if valid or protection disabled, ``Err(Exception)`` otherwise. 

131 

132 """ 

133 if not ssrf_protection: 

134 return Ok(None) 

135 

136 ssrf_result = guard_ssrf(url) 

137 if isinstance(ssrf_result, Err): 

138 return Err(ssrf_result.err_value) 

139 return Ok(None) 

140 

141 

142def _should_retry_status( 

143 response: httpx.Response, 

144 retry_config: RetryConfig | None, 

145 retryable_status_codes: frozenset[int], 

146 attempt: int, 

147 max_attempts: int, 

148) -> bool: 

149 """Determine if a request should be retried based on status code.""" 

150 return ( 

151 retry_config is not None 

152 and response.status_code in retryable_status_codes 

153 and attempt < max_attempts 

154 ) 

155 

156 

157async def _handle_http_exception( 

158 exc: Exception, 

159 attempt: int, 

160 max_attempts: int, 

161 retry_config: RetryConfig | None, 

162 circuit_breaker: CircuitBreaker | None, 

163) -> bool: 

164 """Handle exception and return True if we should retry, False otherwise.""" 

165 if circuit_breaker is not None: 

166 circuit_breaker._record_failure(exc) 

167 if retry_config is not None and attempt < max_attempts: 

168 delay = calculate_delay(attempt, retry_config) 

169 await asyncio.sleep(min(delay, 3600.0)) 

170 return True 

171 return False 

172 

173 

174async def _execute_single_attempt( 

175 request_func: Callable[[], Awaitable[httpx.Response]], 

176 retry_config: RetryConfig | None, 

177 circuit_breaker: CircuitBreaker | None, 

178 retryable_status_codes: frozenset[int], 

179 attempt: int, 

180 max_attempts: int, 

181) -> Result[httpx.Response, Exception] | Exception | bool: 

182 """Execute a single HTTP attempt. 

183 

184 Returns Result on success/final failure, Exception if it failed and we 

185 should record it, or True if we should just retry because of status code. 

186 """ 

187 try: 

188 response = await request_func() 

189 if _should_retry_status( 

190 response, 

191 retry_config, 

192 retryable_status_codes, 

193 attempt, 

194 max_attempts, 

195 ): 

196 # Config is not None if we reach here 

197 delay = calculate_delay(attempt, cast(RetryConfig, retry_config)) 

198 await asyncio.sleep(min(delay, 3600.0)) 

199 return True 

200 return Ok(response) 

201 except Exception as exc: 

202 should_retry = await _handle_http_exception( 

203 exc, 

204 attempt, 

205 max_attempts, 

206 retry_config, 

207 circuit_breaker, 

208 ) 

209 if should_retry: 

210 return exc 

211 return Err(exc) 

212 

213 

214def _process_attempt_outcome( 

215 outcome: Result[httpx.Response, Exception] | Exception | bool, 

216 last_error: Exception, 

217) -> tuple[bool, Exception, Result[httpx.Response, Exception] | None]: 

218 if isinstance(outcome, (Ok, Err)): 

219 return True, last_error, outcome 

220 if isinstance(outcome, Exception): 

221 return False, outcome, None 

222 return False, last_error, None 

223 

224 

225def _get_max_attempts(retry_config: RetryConfig | None) -> int: 

226 return retry_config.max_attempts if retry_config is not None else 1 

227 

228 

229async def _execute_with_retries( 

230 request_func: Callable[[], Awaitable[httpx.Response]], 

231 retry_config: RetryConfig | None, 

232 circuit_breaker: CircuitBreaker | None, 

233 retryable_status_codes: frozenset[int], 

234) -> Result[httpx.Response, Exception]: 

235 """Execute a request function with optional retries and circuit breaker. 

236 

237 Args: 

238 request_func: Async callable that performs the request. 

239 retry_config: Optional retry configuration. 

240 circuit_breaker: Optional circuit breaker. 

241 retryable_status_codes: Status codes to retry on. 

242 

243 Returns: 

244 ``Ok(Response)`` on success, ``Err`` on failure. 

245 

246 """ 

247 max_attempts = _get_max_attempts(retry_config) 

248 last_error: Exception = RuntimeError("Request failed") 

249 

250 for attempt in range(1, max_attempts + 1): 

251 outcome = await _execute_single_attempt( 

252 request_func, 

253 retry_config, 

254 circuit_breaker, 

255 retryable_status_codes, 

256 attempt, 

257 max_attempts, 

258 ) 

259 is_finished, last_error, result = _process_attempt_outcome(outcome, last_error) 

260 if is_finished and result is not None: 

261 return result 

262 

263 return Err(last_error) 

264 

265 

266def _check_preconditions( 

267 url: str, 

268 ssrf_protection: bool, 

269 circuit_breaker: CircuitBreaker | None, 

270) -> Err[Exception] | None: 

271 """Check SSRF and circuit breaker preconditions.""" 

272 ssrf_check = _check_ssrf(url, ssrf_protection) 

273 if isinstance(ssrf_check, Err): 

274 return ssrf_check 

275 

276 if circuit_breaker is not None: 

277 cb_err: Exception | None = _check_circuit_breaker(circuit_breaker) 

278 if cb_err is not None: 

279 return Err(cb_err) 

280 

281 return None 

282 

283 

284async def safe_request( 

285 method: str, 

286 url: str, 

287 *, 

288 ssrf_protection: bool = True, 

289 retry_config: RetryConfig | None = None, 

290 circuit_breaker: CircuitBreaker | None = None, 

291 retryable_status_codes: frozenset[int] = _RETRYABLE_STATUS_CODES, 

292 timeout: float | None = 10.0, 

293 **kwargs: Unpack[HttpRequestKwargs], 

294) -> Result[httpx.Response, Exception]: 

295 """Perform a one-shot HTTP request with safety features. 

296 

297 Args: 

298 method: HTTP method (GET, POST, etc.). 

299 url: Target URL. 

300 ssrf_protection: Validate URL against SSRF. 

301 retry_config: Optional retry configuration. 

302 circuit_breaker: Optional circuit breaker. 

303 retryable_status_codes: Status codes that trigger retries. 

304 timeout: Explicit timeout in seconds (default: 10.0). 

305 **kwargs: Passed to ``httpx.AsyncClient.request``. 

306 

307 Returns: 

308 ``Ok(Response)`` on success, ``Err`` on failure. 

309 

310 """ 

311 if not _HAS_HTTPX: 

312 return Err( 

313 ImportError( 

314 "httpx is required for HTTP bridge. " 

315 "Install with: pip install taipanstack[bridges-http]", 

316 ), 

317 ) 

318 

319 precondition_err = _check_preconditions(url, ssrf_protection, circuit_breaker) 

320 if precondition_err is not None: 

321 return precondition_err 

322 

323 async def _do_request() -> httpx.Response: 

324 async with httpx.AsyncClient(timeout=timeout) as client: # nosemgrep 

325 request_func = cast( # type: ignore[misc] 

326 Callable[..., Awaitable[httpx.Response]], 

327 client.request, 

328 ) 

329 response = await request_func(method, url, **kwargs) 

330 return response 

331 

332 return await _execute_with_retries( 

333 _do_request, 

334 retry_config, 

335 circuit_breaker, 

336 retryable_status_codes, 

337 ) 

338 

339 

340class SafeHttpClient: 

341 """Async context manager wrapping httpx with TaipanStack safety. 

342 

343 Args: 

344 ssrf_protection: Enable SSRF validation on all requests. 

345 retry_config: Retry configuration for transient failures. 

346 circuit_breaker: Optional circuit breaker for all requests. 

347 retryable_status_codes: HTTP status codes to retry on. 

348 **client_kwargs: Passed to ``httpx.AsyncClient``. 

349 

350 Example: 

351 >>> async with SafeHttpClient() as client: 

352 ... result = await client.get("https://api.example.com/data") 

353 ... if isinstance(result, Ok): print(result.unwrap().json()) 

354 ... else: print(f"Error: {result.unwrap_err()}") 

355 

356 """ 

357 

358 def __init__( 

359 self, 

360 *, 

361 ssrf_protection: bool = True, 

362 retry_config: RetryConfig | None = None, 

363 circuit_breaker: CircuitBreaker | None = None, 

364 retryable_status_codes: frozenset[int] = _RETRYABLE_STATUS_CODES, 

365 timeout: float = 10.0, 

366 **client_kwargs: Unpack[HttpClientKwargs], 

367 ) -> None: 

368 """Initialize the safe HTTP client. 

369 

370 Args: 

371 ssrf_protection: Enable SSRF validation. 

372 retry_config: Retry configuration. 

373 circuit_breaker: Circuit breaker instance. 

374 retryable_status_codes: Status codes to retry. 

375 **client_kwargs: Keyword args for httpx.AsyncClient. 

376 Default timeout is 10.0 seconds if not provided. 

377 

378 """ 

379 self._ssrf_protection = ssrf_protection 

380 self._retry_config = retry_config 

381 self._circuit_breaker = circuit_breaker 

382 self._retryable_status_codes = retryable_status_codes 

383 self._client_kwargs = client_kwargs # type: ignore[misc] 

384 self._timeout = timeout 

385 self._client: httpx.AsyncClient | None = None 

386 

387 async def __aenter__(self) -> SafeHttpClient: 

388 """Enter the async context manager.""" 

389 if not _HAS_HTTPX: 

390 msg = ( 

391 "httpx is required for SafeHttpClient. " 

392 "Install with: pip install taipanstack[bridges-http]" 

393 ) 

394 raise ImportError(msg) 

395 self._client = httpx.AsyncClient( 

396 timeout=self._timeout, 

397 **self._client_kwargs, # type: ignore[misc] 

398 ) # nosemgrep 

399 return self 

400 

401 async def __aexit__( 

402 self, 

403 _exc_type: type[BaseException] | None, 

404 _exc_val: BaseException | None, 

405 _exc_tb: object, 

406 ) -> None: 

407 """Exit the async context manager.""" 

408 if self._client is not None: 

409 await self._client.aclose() 

410 self._client = None 

411 

412 async def request( 

413 self, 

414 method: str, 

415 url: str, 

416 **kwargs: Unpack[HttpRequestKwargs], 

417 ) -> Result[httpx.Response, Exception]: 

418 """Send an HTTP request with safety features. 

419 

420 Args: 

421 method: HTTP method. 

422 url: Target URL. 

423 **kwargs: Passed to the underlying client. 

424 

425 Returns: 

426 ``Ok(Response)`` on success, ``Err`` on failure. 

427 

428 """ 

429 if self._client is None: 

430 return Err(RuntimeError("Client not initialised. Use 'async with'.")) 

431 

432 precondition_err = _check_preconditions( 

433 url, self._ssrf_protection, self._circuit_breaker 

434 ) 

435 if precondition_err is not None: 

436 return precondition_err 

437 

438 async def _do_request() -> httpx.Response: 

439 # We explicitly verified client is not None above 

440 client: httpx.AsyncClient = cast(httpx.AsyncClient, self._client) 

441 request_func = cast( # type: ignore[misc] 

442 Callable[..., Awaitable[httpx.Response]], 

443 client.request, 

444 ) 

445 response = await request_func(method, url, **kwargs) 

446 return response 

447 

448 return await _execute_with_retries( 

449 _do_request, 

450 self._retry_config, 

451 self._circuit_breaker, 

452 self._retryable_status_codes, 

453 ) 

454 

455 async def get( 

456 self, 

457 url: str, 

458 **kw: Unpack[HttpRequestKwargs], 

459 ) -> Result[httpx.Response, Exception]: 

460 """Send a GET request.""" 

461 return await self.request("GET", url, **kw) 

462 

463 async def post( 

464 self, 

465 url: str, 

466 **kw: Unpack[HttpRequestKwargs], 

467 ) -> Result[httpx.Response, Exception]: 

468 """Send a POST request.""" 

469 return await self.request("POST", url, **kw) 

470 

471 async def put( 

472 self, 

473 url: str, 

474 **kw: Unpack[HttpRequestKwargs], 

475 ) -> Result[httpx.Response, Exception]: 

476 """Send a PUT request.""" 

477 return await self.request("PUT", url, **kw) 

478 

479 async def delete( 

480 self, 

481 url: str, 

482 **kw: Unpack[HttpRequestKwargs], 

483 ) -> Result[httpx.Response, Exception]: 

484 """Send a DELETE request.""" 

485 return await self.request("DELETE", url, **kw) 

486 

487 async def patch( 

488 self, 

489 url: str, 

490 **kw: Unpack[HttpRequestKwargs], 

491 ) -> Result[httpx.Response, Exception]: 

492 """Send a PATCH request.""" 

493 return await self.request("PATCH", url, **kw)