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

150 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-06 15:01 +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 

214async def _execute_with_retries( 

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

216 retry_config: RetryConfig | None, 

217 circuit_breaker: CircuitBreaker | None, 

218 retryable_status_codes: frozenset[int], 

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

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

221 

222 Args: 

223 request_func: Async callable that performs the request. 

224 retry_config: Optional retry configuration. 

225 circuit_breaker: Optional circuit breaker. 

226 retryable_status_codes: Status codes to retry on. 

227 

228 Returns: 

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

230 

231 """ 

232 max_attempts = retry_config.max_attempts if retry_config is not None else 1 

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

234 

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

236 outcome = await _execute_single_attempt( 

237 request_func, 

238 retry_config, 

239 circuit_breaker, 

240 retryable_status_codes, 

241 attempt, 

242 max_attempts, 

243 ) 

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

245 return outcome 

246 if isinstance(outcome, Exception): 

247 last_error = outcome 

248 

249 return Err(last_error) 

250 

251 

252def _check_preconditions( 

253 url: str, 

254 ssrf_protection: bool, 

255 circuit_breaker: CircuitBreaker | None, 

256) -> Err[Exception] | None: 

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

258 ssrf_check = _check_ssrf(url, ssrf_protection) 

259 if isinstance(ssrf_check, Err): 

260 return ssrf_check 

261 

262 if circuit_breaker is not None: 

263 cb_err: Exception | None = _check_circuit_breaker(circuit_breaker) 

264 if cb_err is not None: 

265 return Err(cb_err) 

266 

267 return None 

268 

269 

270async def safe_request( 

271 method: str, 

272 url: str, 

273 *, 

274 ssrf_protection: bool = True, 

275 retry_config: RetryConfig | None = None, 

276 circuit_breaker: CircuitBreaker | None = None, 

277 retryable_status_codes: frozenset[int] = _RETRYABLE_STATUS_CODES, 

278 timeout: float | None = 10.0, 

279 **kwargs: Unpack[HttpRequestKwargs], 

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

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

282 

283 Args: 

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

285 url: Target URL. 

286 ssrf_protection: Validate URL against SSRF. 

287 retry_config: Optional retry configuration. 

288 circuit_breaker: Optional circuit breaker. 

289 retryable_status_codes: Status codes that trigger retries. 

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

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

292 

293 Returns: 

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

295 

296 """ 

297 if not _HAS_HTTPX: 

298 return Err( 

299 ImportError( 

300 "httpx is required for HTTP bridge. " 

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

302 ), 

303 ) 

304 

305 precondition_err = _check_preconditions(url, ssrf_protection, circuit_breaker) 

306 if precondition_err is not None: 

307 return precondition_err 

308 

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

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

311 request_func = cast( 

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

313 client.request, 

314 ) 

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

316 return response 

317 

318 return await _execute_with_retries( 

319 _do_request, 

320 retry_config, 

321 circuit_breaker, 

322 retryable_status_codes, 

323 ) 

324 

325 

326class SafeHttpClient: 

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

328 

329 Args: 

330 ssrf_protection: Enable SSRF validation on all requests. 

331 retry_config: Retry configuration for transient failures. 

332 circuit_breaker: Optional circuit breaker for all requests. 

333 retryable_status_codes: HTTP status codes to retry on. 

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

335 

336 Example: 

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

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

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

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

341 

342 """ 

343 

344 def __init__( 

345 self, 

346 *, 

347 ssrf_protection: bool = True, 

348 retry_config: RetryConfig | None = None, 

349 circuit_breaker: CircuitBreaker | None = None, 

350 retryable_status_codes: frozenset[int] = _RETRYABLE_STATUS_CODES, 

351 timeout: float = 10.0, 

352 **client_kwargs: Unpack[HttpClientKwargs], 

353 ) -> None: 

354 """Initialize the safe HTTP client. 

355 

356 Args: 

357 ssrf_protection: Enable SSRF validation. 

358 retry_config: Retry configuration. 

359 circuit_breaker: Circuit breaker instance. 

360 retryable_status_codes: Status codes to retry. 

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

362 Default timeout is 10.0 seconds if not provided. 

363 

364 """ 

365 self._ssrf_protection = ssrf_protection 

366 self._retry_config = retry_config 

367 self._circuit_breaker = circuit_breaker 

368 self._retryable_status_codes = retryable_status_codes 

369 self._client_kwargs = client_kwargs 

370 self._timeout = timeout 

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

372 

373 async def __aenter__(self) -> SafeHttpClient: 

374 """Enter the async context manager.""" 

375 if not _HAS_HTTPX: 

376 msg = ( 

377 "httpx is required for SafeHttpClient. " 

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

379 ) 

380 raise ImportError(msg) 

381 self._client = httpx.AsyncClient( 

382 timeout=self._timeout, 

383 **self._client_kwargs, 

384 ) # nosemgrep 

385 return self 

386 

387 async def __aexit__( 

388 self, 

389 _exc_type: type[BaseException] | None, 

390 _exc_val: BaseException | None, 

391 _exc_tb: object, 

392 ) -> None: 

393 """Exit the async context manager.""" 

394 if self._client is not None: 

395 await self._client.aclose() 

396 self._client = None 

397 

398 async def request( 

399 self, 

400 method: str, 

401 url: str, 

402 **kwargs: Unpack[HttpRequestKwargs], 

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

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

405 

406 Args: 

407 method: HTTP method. 

408 url: Target URL. 

409 **kwargs: Passed to the underlying client. 

410 

411 Returns: 

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

413 

414 """ 

415 if self._client is None: 

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

417 

418 precondition_err = _check_preconditions( 

419 url, self._ssrf_protection, self._circuit_breaker 

420 ) 

421 if precondition_err is not None: 

422 return precondition_err 

423 

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

425 # We explicitly verified client is not None above 

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

427 request_func = cast( 

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

429 client.request, 

430 ) 

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

432 return response 

433 

434 return await _execute_with_retries( 

435 _do_request, 

436 self._retry_config, 

437 self._circuit_breaker, 

438 self._retryable_status_codes, 

439 ) 

440 

441 async def get( 

442 self, 

443 url: str, 

444 **kw: Unpack[HttpRequestKwargs], 

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

446 """Send a GET request.""" 

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

448 

449 async def post( 

450 self, 

451 url: str, 

452 **kw: Unpack[HttpRequestKwargs], 

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

454 """Send a POST request.""" 

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

456 

457 async def put( 

458 self, 

459 url: str, 

460 **kw: Unpack[HttpRequestKwargs], 

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

462 """Send a PUT request.""" 

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

464 

465 async def delete( 

466 self, 

467 url: str, 

468 **kw: Unpack[HttpRequestKwargs], 

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

470 """Send a DELETE request.""" 

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

472 

473 async def patch( 

474 self, 

475 url: str, 

476 **kw: Unpack[HttpRequestKwargs], 

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

478 """Send a PATCH request.""" 

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