Coverage for utils / api_client.py: 92.22%

435 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-06-17 01:43 +0000

1"""同期・非同期HTTPAPIクライアント""" 

2 

3import asyncio 

4import json 

5import random 

6import sys 

7import time 

8from types import TracebackType 

9from typing import Any, Final, Self 

10 

11import httpx 

12from structlog.typing import FilteringBoundLogger 

13 

14from config.settings import settings 

15from utils.logger import get_logger 

16 

17# httpx 例外(NetworkError, RemoteProtocolError 等)をここに追加してはならない。 

18# github_client.py の _request では except 句の順序で先行キャッチしており、 

19# この定数に httpx 例外を追加すると NetworkError のリトライが壊れる。 

20ASYNC_FATAL_EXCEPTIONS: tuple[type[BaseException], ...] = ( 

21 KeyboardInterrupt, 

22 SystemExit, 

23 MemoryError, 

24 RecursionError, 

25 asyncio.CancelledError, 

26) 

27SYNC_FATAL_EXCEPTIONS: tuple[type[BaseException], ...] = ( 

28 KeyboardInterrupt, 

29 SystemExit, 

30 MemoryError, 

31 RecursionError, 

32) 

33 

34 

35def exponential_backoff_with_jitter( 

36 attempt: int, 

37 base_delay: float = 1.0, 

38 max_delay: float = 60.0, 

39 jitter_percent: float = 0.3, 

40) -> float: 

41 """指数バックオフ + 30%ジッター計算 

42 

43 Args: 

44 attempt: 現在のリトライ回数(0始まり) 

45 base_delay: 基本遅延時間(秒) 

46 max_delay: 最大遅延時間(秒) 

47 jitter_percent: ジッター率(デフォルト30%) 

48 

49 Returns: 

50 計算された遅延時間(秒、最小0.1秒) 

51 

52 """ 

53 # 指数バックオフ計算 

54 delay = min(base_delay * (2**attempt), max_delay) 

55 # ±30%のジッター追加(リトライ間隔用、暗号用途ではない) 

56 jitter = delay * jitter_percent 

57 delay = delay + random.uniform(-jitter, jitter) # noqa: S311 

58 # 最小値保証 

59 return max(0.1, delay) 

60 

61 

62def _validate_optional_int(value: int | None, name: str, min_value: int) -> None: 

63 """オプショナルな整数パラメータの最小値バリデーション。 

64 

65 Args: 

66 value: 検証対象の値(Noneの場合はスキップ) 

67 name: パラメータ名(エラーメッセージ用) 

68 min_value: 最小許容値(含む) 

69 

70 Raises: 

71 ValueError: valueがmin_valueより小さい場合 

72 """ 

73 if value is not None and value < min_value: 

74 raise ValueError(f"{name} must be >= {min_value}") 

75 

76 

77# ============================================================================= 

78# 例外クラス 

79# ============================================================================= 

80 

81 

82class APIClientError(Exception): 

83 """APIクライアント基底例外""" 

84 

85 

86class APIConnectionError(APIClientError): 

87 """API接続エラー""" 

88 

89 

90class APITimeoutError(APIClientError): 

91 """APIタイムアウトエラー""" 

92 

93 

94class APIHTTPError(APIClientError): 

95 """HTTPステータスエラー""" 

96 

97 def __init__(self, message: str, status_code: int, response: httpx.Response | None = None): 

98 super().__init__(message) 

99 self.status_code = status_code 

100 self.response = response 

101 

102 

103class APIRetryError(APIClientError): 

104 """リトライ上限エラー""" 

105 

106 

107class APIJSONDecodeError(APIClientError): 

108 """JSONパースエラー""" 

109 

110 def __init__(self, message: str, response: httpx.Response | None = None): 

111 super().__init__(message) 

112 self.response = response 

113 

114 

115def _safe_parse_json(response: httpx.Response) -> Any: 

116 """レスポンスJSONを安全にパース 

117 

118 Args: 

119 response: HTTPレスポンスオブジェクト 

120 

121 Returns: 

122 パースされたJSONデータ 

123 

124 Raises: 

125 APIJSONDecodeError: JSONパース失敗時 

126 

127 Notes: 

128 ``json.JSONDecodeError`` の ``str(e)`` にはパース位置情報(行番号・ 

129 文字位置)が含まれるが、httpx例外と異なりホスト名・プロキシ設定等の 

130 機密情報は含まれない。デバッグに有用な診断情報を保持するため 

131 ``str(e)`` をそのまま使用する。 

132 また、``APIJSONDecodeError.response`` に元の ``httpx.Response`` 

133 オブジェクトを保持するが、レスポンスボディには機密データが含まれる 

134 可能性があるためログには出力しない。デバッグ時は呼び出し元で 

135 ``e.response`` を通じてアクセス可能。 

136 

137 """ 

138 try: 

139 return response.json() 

140 except json.JSONDecodeError as e: 

141 raise APIJSONDecodeError( 

142 f"Failed to parse JSON response: {e}", 

143 response=response, 

144 ) from e 

145 

146 

147def _map_request_error(e: httpx.RequestError | httpx.InvalidURL) -> APIClientError: 

148 """httpxネットワーク例外をカスタム例外にマッピング 

149 

150 Args: 

151 e: httpx.RequestError または httpx.InvalidURL(またはそのサブクラス) 

152 

153 Returns: 

154 APIClientErrorサブクラス(リトライ可能エラーの場合のみ。 

155 非リトライ時(TooManyRedirects / InvalidURL)は 

156 APIClientError 基底クラスを raise するため返らない)。 

157 

158 Raises: 

159 APIClientError: 非リトライ可能エラー(TooManyRedirects, InvalidURL) 

160 

161 Note: 

162 httpx例外の扱い: 

163 - RequestError サブクラス(リトライ可能): 

164 TimeoutException (ConnectTimeout, ReadTimeout), 

165 NetworkError (ConnectError, ReadError, WriteError 等のサブクラスを含む) 

166 ※ ConnectError は NetworkError のサブクラスだが個別分岐で処理 

167 - RequestError サブクラス(非リトライ): 

168 TooManyRedirects → 即座にraise 

169 - 独立例外(RequestError のサブクラスではない、非リトライ): 

170 InvalidURL → 即座にraise 

171 生成される例外メッセージは固定プレフィックスと ``type(e).__name__`` 

172 (例外クラス名)のみで構成され、``str(e)`` は含めない。 

173 非リトライエラーは ``raise ... from e`` により即座にスローされる。 

174 リトライ可能エラーは ``__cause__ = e`` 手動設定後に返され、 

175 呼び出し元で ``raise`` された際にトレースバックで確認できる。 

176 なお、``__cause__`` に保持される httpx 例外の文字列には 

177 ホスト名・プロキシ設定等の機密情報が含まれることがあり、 

178 ``traceback.print_exception(chain=True)``(デフォルト)では 

179 この ``__cause__`` チェーンが展開されるため、表示用途では 

180 ``chain=False`` を指定して機密漏洩を防ぐこと。 

181 ``main()`` で受け取る ``e`` は ``APIClientError`` なので、 

182 ``chain=False`` が抑止するのは ``__cause__`` 側の httpx 例外チェーンであり、 

183 ``APIClientError`` 本体のスタックトレースは引き続き表示される。 

184 参照: ``main()`` の ``traceback.print_exception(e, chain=False)`` 実装。 

185 

186 """ 

187 # Non-retryable errors - raise immediately (no point in retrying) 

188 if isinstance(e, httpx.TooManyRedirects | httpx.InvalidURL): 

189 raise APIClientError(f"Non-retryable request error: {type(e).__name__}") from e 

190 

191 # Retryable errors: returnするため `raise ... from e` は使えず __cause__ を手動設定する。 

192 # PEP 3134: exc.__cause__ = e を設定すると __suppress_context__ が自動で True になり、 

193 # 呼び出し元が raise した際に `raise exc from e` と同じ例外チェーン表示になる。 

194 if isinstance(e, httpx.TimeoutException): 

195 timeout_exc = APITimeoutError(f"Request timeout: {type(e).__name__}") 

196 timeout_exc.__cause__ = e 

197 return timeout_exc 

198 if isinstance(e, httpx.ConnectError): 

199 connect_exc = APIConnectionError(f"Connection failed: {type(e).__name__}") 

200 connect_exc.__cause__ = e 

201 return connect_exc 

202 # NetworkError, etc. - retryable network issues 

203 network_exc = APIConnectionError(f"Network error: {type(e).__name__}") 

204 network_exc.__cause__ = e 

205 return network_exc 

206 

207 

208def _resolve_client_config( 

209 base_url: str | None, 

210 timeout: float | None, 

211 retry_count: int | None, 

212 retry_delay: float | None, 

213 headers: dict[str, str] | None, 

214) -> tuple[str, float, int, float, dict[str, str]]: 

215 """Sync/Async共通の設定解決ロジック。 

216 

217 引数またはsettingsから設定値を解決し、バリデーションを実行する。 

218 HTTPクライアント初期化・ロガー初期化は呼び出し元の責務。 

219 

220 Args: 

221 base_url: APIのベースURL(Noneの場合settings.api.base_urlを使用) 

222 timeout: タイムアウト秒数(Noneの場合settings.api.timeoutを使用) 

223 retry_count: リトライ回数(Noneの場合settings.api.retry_countを使用) 

224 retry_delay: リトライ間隔秒数(Noneの場合settings.api.retry_delayを使用) 

225 headers: 追加ヘッダー(デフォルトヘッダーにマージ) 

226 

227 Returns: 

228 (base_url, timeout, retry_count, retry_delay, default_headers) のタプル 

229 

230 Raises: 

231 ValueError: base_urlが空文字列またはホワイトスペース 

232 (str.strip() で除去される文字)のみの文字列の場合 

233 

234 """ 

235 base_url = base_url if base_url is not None else settings.api.base_url 

236 if not base_url.strip(): 

237 raise ValueError("base_url が空です。引数または API__BASE_URL 環境変数を確認してください。") 

238 timeout = timeout if timeout is not None else settings.api.timeout 

239 retry_count = retry_count if retry_count is not None else settings.api.retry_count 

240 retry_delay = retry_delay if retry_delay is not None else settings.api.retry_delay 

241 

242 default_headers = { 

243 "User-Agent": settings.api.user_agent, 

244 "Accept": "application/json", 

245 "Content-Type": "application/json", 

246 } 

247 # `if headers:` ではなく `is not None` を使用: 空辞書({})を渡した場合も 

248 # update()を実行する(no-opだが、Noneと空辞書の意味論を明確に区別するため) 

249 if headers is not None: 

250 default_headers.update(headers) 

251 

252 return ( 

253 base_url, 

254 timeout, 

255 retry_count, 

256 retry_delay, 

257 default_headers, 

258 ) 

259 

260 

261def _classify_error( 

262 e: httpx.RequestError | httpx.InvalidURL, 

263 logger: FilteringBoundLogger, 

264 *, 

265 is_async: bool, 

266 method: str, 

267 endpoint: str, 

268) -> APIClientError: 

269 """Sync/Async共通のネットワークエラー分類・ログ出力。 

270 

271 エラー種別に応じてERROR/WARNINGログを出力し、_map_request_error()を呼び出す。 

272 非リトライエラー(TooManyRedirects/InvalidURL)は_map_request_error()内で即座にraiseされる。 

273 

274 Args: 

275 e: httpxのリクエストエラーまたはInvalidURL 

276 logger: structlogロガーインスタンス 

277 is_async: 非同期クライアントからの呼び出しかどうか 

278 method: HTTPメソッド名 

279 endpoint: APIエンドポイント 

280 

281 Returns: 

282 APIClientErrorサブクラス(リトライ可能エラーの場合)。 

283 TooManyRedirects / InvalidURL の場合は _map_request_error() 内で 

284 raise されるため、呼び出し元には値が返らない。 

285 

286 Raises: 

287 APIClientError: TooManyRedirects または InvalidURL の場合 

288 (logger.error でログ出力後、_map_request_error() を経由して raise される)。 

289 注: サブクラスではなく APIClientError 基底クラスが raise される。 

290 リトライ可能エラーは logger.warning でログ出力し、raise されない。 

291 

292 Notes: 

293 本関数のログ出力(``request_error_non_retryable`` / ``request_error`` イベント)では 

294 ``error`` フィールドを省略している。httpx 例外の文字列には 

295 ホスト名、プロキシ設定等の機密情報が含まれるため、``error_type`` 

296 (例外クラス名)のみ記録してエラー分類に必須情報を確保する。 

297 非リトライエラー(``request_error_non_retryable``)は ``logger.error``、 

298 リトライ可能エラー(``request_error``)は ``logger.warning`` でログ出力される。 

299 例外の生成・チェーン設定の詳細は ``_map_request_error()`` 参照。 

300 

301 """ 

302 if isinstance(e, httpx.TooManyRedirects | httpx.InvalidURL): 

303 logger.error( 

304 "request_error_non_retryable", 

305 is_async=is_async, 

306 method=method, 

307 endpoint=endpoint, 

308 error_type=type(e).__name__, 

309 ) 

310 else: 

311 logger.warning( 

312 "request_error", 

313 is_async=is_async, 

314 method=method, 

315 endpoint=endpoint, 

316 error_type=type(e).__name__, 

317 ) 

318 return _map_request_error(e) 

319 

320 

321# ============================================================================= 

322# 基本HTTPクライアント 

323# ============================================================================= 

324 

325 

326class SyncAPIClient: 

327 """基本的な同期HTTPクライアント""" 

328 

329 def __init__( 

330 self, 

331 base_url: str | None = None, 

332 timeout: float | None = None, 

333 retry_count: int | None = None, 

334 retry_delay: float | None = None, 

335 headers: dict[str, str] | None = None, 

336 ): 

337 """Args: 

338 base_url: APIのベースURL(設定から自動取得可能) 

339 timeout: リクエストタイムアウト(秒) 

340 retry_count: リトライ回数 

341 retry_delay: リトライ間隔(秒) 

342 headers: 追加HTTPヘッダー 

343 

344 Raises: 

345 ValueError: base_urlが空文字列またはホワイトスペース 

346 (スペース・タブ・改行等)のみの文字列の場合 

347 

348 """ 

349 # 設定解決・バリデーション(Sync/Async共通ロジック) 

350 # NOTE: retry_count=0, retry_delay=0.0, timeout=0.0 は有効な設定値のため is not None で判定 

351 # timeout=0.0: 即座にタイムアウト(無効化は timeout=None) 

352 ( 

353 self.base_url, 

354 self.timeout, 

355 self.retry_count, 

356 self.retry_delay, 

357 self.default_headers, 

358 ) = _resolve_client_config(base_url, timeout, retry_count, retry_delay, headers) 

359 

360 # ロガーの初期化(structlog統合) 

361 self.logger = get_logger(__name__) 

362 

363 # HTTPクライアントの初期化 

364 # close() 後に None を代入するため Optional 宣言 

365 # (AsyncAPIClient._client と対称, PR#347 CQ-6)。 

366 self._client: httpx.Client | None = httpx.Client( 

367 base_url=self.base_url, 

368 timeout=self.timeout, 

369 headers=self.default_headers, 

370 limits=httpx.Limits(max_connections=settings.api.max_connections), 

371 ) 

372 

373 self.logger.info("api_client_initialized", base_url=self.base_url) 

374 

375 def __enter__(self) -> Self: 

376 """コンテキストマネージャーのエントリー""" 

377 return self 

378 

379 def __exit__( 

380 self, 

381 exc_type: type[BaseException] | None, 

382 exc_val: BaseException | None, 

383 exc_tb: TracebackType | None, 

384 ) -> None: 

385 """コンテキストマネージャーの終了処理""" 

386 self.close() 

387 

388 def close(self) -> None: 

389 """クライアントのクローズ 

390 

391 ``AsyncAPIClient`` / ``AsyncGitHubClient.__aexit__`` と同様に、close 後は 

392 ``self._client = None`` を設定してダブルクローズを防止する(PR#347 CQ-6)。 

393 truthy チェックではなく ``is not None`` で他箇所の規約と統一する。 

394 

395 ``self._client.close()`` が例外(``httpx.CloseError`` / ``OSError`` 等)を投げても、 

396 ``finally`` 節で ``self._client = None`` を必ず設定する。これにより ``_request`` 冒頭の 

397 use-after-close ガード(``_client is None`` 判定)の前提が保たれ、close 失敗後に壊れた 

398 クライアントへリクエストが発行される状態不整合を防ぐ。``AsyncAPIClient._close_async_client`` 

399 が全 except 経路で ``_client=None`` を設定するのと対称(PR#347 review)。例外は従来通り 

400 呼び出し元へ伝播させる(``finally`` は抑制しない)。``api_client_closed`` info ログは 

401 close 成功時のみ出力する(``finally`` の外に置くため、例外時はスキップされる)。 

402 """ 

403 if self._client is not None: 403 ↛ exitline 403 didn't return from function 'close' because the condition on line 403 was always true

404 try: 

405 self._client.close() 

406 finally: 

407 self._client = None 

408 self.logger.info("api_client_closed") 

409 

410 def _make_request_with_retry(self, method: str, endpoint: str, **kwargs: Any) -> httpx.Response: 

411 """リトライ機能付きHTTPリクエスト実行 

412 

413 Args: 

414 method: HTTPメソッド 

415 endpoint: APIエンドポイント 

416 **kwargs: httpxに渡す追加パラメータ 

417 

418 Returns: 

419 httpx.Response: APIレスポンス 

420 

421 Raises: 

422 APIConnectionError: 接続エラー 

423 APITimeoutError: タイムアウトエラー 

424 APIHTTPError: HTTPステータスエラー 

425 APIRetryError: リトライ上限エラー 

426 APIClientError: 非リトライエラー(TooManyRedirects / InvalidURL) 

427 

428 Note: 

429 TooManyRedirects/InvalidURL は _map_request_error() 内で即 raise されるため、 

430 APIRetryError ではなく APIClientError として呼び出し元に届く。 

431 呼び出し元は APIClientError で捕捉すること。 

432 

433 """ 

434 # close 後の use-after-close を明示エラー化(AsyncAPIClient._request と同一パターン)。 

435 # 型注釈 _client: httpx.Client | None に対する None 絞り込みも兼ねる(PR#347 CQ-6)。 

436 if self._client is None: 436 ↛ 437line 436 didn't jump to line 437 because the condition on line 436 was never true

437 raise RuntimeError("Client not initialized or already closed.") 

438 

439 last_exception: APIClientError | None = None 

440 

441 for attempt in range(self.retry_count + 1): 

442 # HTTPリクエスト実行(ネットワーク層) 

443 try: 

444 # structlogでログ出力(DRY原則: 重複ログ削除) 

445 if attempt > 0: 

446 self.logger.warning( 

447 "request_retry", 

448 attempt=attempt + 1, 

449 max_attempts=self.retry_count + 1, 

450 method=method, 

451 endpoint=endpoint, 

452 ) 

453 else: 

454 self.logger.debug("request_start", method=method, endpoint=endpoint) 

455 

456 # HTTPリクエスト実行 

457 response = self._client.request(method, endpoint, **kwargs) 

458 except (httpx.RequestError, httpx.InvalidURL) as e: 

459 # 全ネットワーク層エラーをキャッチ(TimeoutException, ConnectError, etc.) 

460 # TooManyRedirects/InvalidURL は _classify_error → _map_request_error 内で即 raise 

461 last_exception = _classify_error( 

462 e, 

463 self.logger, 

464 is_async=False, 

465 method=method, 

466 endpoint=endpoint, 

467 ) 

468 else: 

469 # ネットワーク成功時のみHTTPステータス処理 

470 try: 

471 response.raise_for_status() 

472 self.logger.debug( 

473 "request_success", 

474 method=method, 

475 endpoint=endpoint, 

476 status_code=response.status_code, 

477 ) 

478 return response 

479 except httpx.HTTPStatusError as e: 

480 # 4xxエラーはリトライしない(クライアントエラー) 

481 if e.response.is_client_error: 

482 self.logger.error( 

483 "client_error", 

484 status_code=e.response.status_code, 

485 method=method, 

486 endpoint=endpoint, 

487 ) 

488 raise APIHTTPError( 

489 f"HTTP {e.response.status_code} Client Error", 

490 e.response.status_code, 

491 e.response, 

492 ) from e 

493 

494 # 5xxエラーはリトライ対象 

495 self.logger.warning( 

496 "server_error", 

497 status_code=e.response.status_code, 

498 method=method, 

499 endpoint=endpoint, 

500 ) 

501 last_exception = APIHTTPError( 

502 f"HTTP {e.response.status_code} Server Error", 

503 e.response.status_code, 

504 e.response, 

505 ) 

506 

507 # 最後の試行でなければ指数バックオフ + 30%ジッターで待機 

508 if attempt < self.retry_count: 

509 delay = exponential_backoff_with_jitter( 

510 attempt=attempt, 

511 base_delay=self.retry_delay, 

512 jitter_percent=0.3, 

513 ) 

514 self.logger.debug( 

515 "retry_backoff", 

516 delay_seconds=round(delay, 2), 

517 attempt=attempt + 1, 

518 strategy="exponential_backoff_with_jitter", 

519 ) 

520 time.sleep(delay) 

521 

522 # すべてのリトライが失敗 

523 self.logger.error("all_retries_failed", method=method, endpoint=endpoint) 

524 raise APIRetryError( 

525 f"Request failed after {self.retry_count + 1} attempts", 

526 ) from last_exception 

527 

528 def get( 

529 self, 

530 endpoint: str, 

531 params: dict[str, Any] | None = None, 

532 headers: dict[str, str] | None = None, 

533 ) -> httpx.Response: 

534 """GETリクエスト実行""" 

535 return self._make_request_with_retry("GET", endpoint, params=params, headers=headers) 

536 

537 def post( 

538 self, 

539 endpoint: str, 

540 json: dict[str, Any] | None = None, 

541 data: dict[str, Any] | None = None, 

542 headers: dict[str, str] | None = None, 

543 ) -> httpx.Response: 

544 """POSTリクエスト実行""" 

545 return self._make_request_with_retry( 

546 "POST", 

547 endpoint, 

548 json=json, 

549 data=data, 

550 headers=headers, 

551 ) 

552 

553 def put( 

554 self, 

555 endpoint: str, 

556 json: dict[str, Any] | None = None, 

557 data: dict[str, Any] | None = None, 

558 headers: dict[str, str] | None = None, 

559 ) -> httpx.Response: 

560 """PUTリクエスト実行""" 

561 return self._make_request_with_retry("PUT", endpoint, json=json, data=data, headers=headers) 

562 

563 def delete(self, endpoint: str, headers: dict[str, str] | None = None) -> httpx.Response: 

564 """DELETEリクエスト実行""" 

565 return self._make_request_with_retry("DELETE", endpoint, headers=headers) 

566 

567 def patch( 

568 self, 

569 endpoint: str, 

570 json: dict[str, Any] | None = None, 

571 data: dict[str, Any] | None = None, 

572 headers: dict[str, str] | None = None, 

573 ) -> httpx.Response: 

574 """PATCHリクエスト実行""" 

575 return self._make_request_with_retry( 

576 "PATCH", 

577 endpoint, 

578 json=json, 

579 data=data, 

580 headers=headers, 

581 ) 

582 

583 

584# ============================================================================= 

585# JSONPlaceholder API 専用クライアント 

586# ============================================================================= 

587 

588 

589class SyncJSONPlaceholderClient(SyncAPIClient): 

590 """JSONPlaceholder API専用クライアント""" 

591 

592 # Posts API 

593 def get_posts( 

594 self, limit: int | None = None, user_id: int | None = None 

595 ) -> list[dict[str, Any]]: 

596 """投稿一覧の取得 

597 

598 Args: 

599 limit: 取得件数上限(0以上) 

600 user_id: ユーザーIDでフィルタリング(API側フィルタ、1以上) 

601 

602 Raises: 

603 ValueError: limit < 0 または user_id < 1 の場合 

604 """ 

605 _validate_optional_int(limit, "limit", 0) 

606 _validate_optional_int(user_id, "user_id", 1) 

607 

608 params = {} 

609 if limit is not None: 

610 params["_limit"] = limit 

611 if user_id is not None: 

612 params["userId"] = user_id 

613 

614 response = self.get("/posts", params=params) 

615 return _safe_parse_json(response) 

616 

617 def get_post(self, post_id: int) -> dict[str, Any]: 

618 """特定投稿の取得""" 

619 response = self.get(f"/posts/{post_id}") 

620 return _safe_parse_json(response) 

621 

622 def create_post(self, title: str, body: str, user_id: int) -> dict[str, Any]: 

623 """新規投稿の作成""" 

624 data = {"title": title, "body": body, "userId": user_id} 

625 response = self.post("/posts", json=data) 

626 return _safe_parse_json(response) 

627 

628 # Users API 

629 def get_users(self) -> list[dict[str, Any]]: 

630 """ユーザー一覧の取得""" 

631 response = self.get("/users") 

632 return _safe_parse_json(response) 

633 

634 def get_user(self, user_id: int) -> dict[str, Any]: 

635 """特定ユーザーの取得""" 

636 response = self.get(f"/users/{user_id}") 

637 return _safe_parse_json(response) 

638 

639 # Todos API 

640 def get_todos( 

641 self, 

642 user_id: int | None = None, 

643 completed: bool | None = None, 

644 limit: int | None = None, 

645 ) -> list[dict[str, Any]]: 

646 """TODO一覧の取得 

647 

648 Args: 

649 user_id: ユーザーIDでフィルタリング(API側フィルタ、1以上) 

650 completed: 完了状態でフィルタリング 

651 limit: 取得件数上限(0以上) 

652 

653 Raises: 

654 ValueError: limit < 0 または user_id < 1 の場合 

655 """ 

656 _validate_optional_int(limit, "limit", 0) 

657 _validate_optional_int(user_id, "user_id", 1) 

658 

659 params = {} 

660 if user_id is not None: 

661 params["userId"] = user_id 

662 if completed is not None: 

663 params["completed"] = completed 

664 if limit is not None: 

665 params["_limit"] = limit 

666 

667 response = self.get("/todos", params=params) 

668 return _safe_parse_json(response) 

669 

670 def get_todo(self, todo_id: int) -> dict[str, Any]: 

671 """特定TODOの取得""" 

672 response = self.get(f"/todos/{todo_id}") 

673 return _safe_parse_json(response) 

674 

675 def create_todo(self, title: str, user_id: int, completed: bool = False) -> dict[str, Any]: 

676 """新規TODOの作成""" 

677 data = {"title": title, "userId": user_id, "completed": completed} 

678 response = self.post("/todos", json=data) 

679 return _safe_parse_json(response) 

680 

681 def update_todo(self, todo_id: int, **kwargs: Any) -> dict[str, Any]: 

682 """TODOの更新""" 

683 response = self.patch(f"/todos/{todo_id}", json=kwargs) 

684 return _safe_parse_json(response) 

685 

686 # Comments API 

687 def get_comments(self, post_id: int | None = None) -> list[dict[str, Any]]: 

688 """コメント一覧の取得 

689 

690 Args: 

691 post_id: 投稿IDでフィルタリング(1以上) 

692 

693 Raises: 

694 ValueError: post_id < 1 の場合 

695 """ 

696 _validate_optional_int(post_id, "post_id", 1) 

697 

698 if post_id is not None: 

699 response = self.get(f"/posts/{post_id}/comments") 

700 else: 

701 response = self.get("/comments") 

702 return _safe_parse_json(response) 

703 

704 # Albums & Photos API 

705 def get_albums(self, user_id: int | None = None) -> list[dict[str, Any]]: 

706 """アルバム一覧の取得 

707 

708 Args: 

709 user_id: ユーザーIDでフィルタリング(API側フィルタ、1以上) 

710 

711 Raises: 

712 ValueError: user_id < 1 の場合 

713 """ 

714 _validate_optional_int(user_id, "user_id", 1) 

715 

716 params = {} 

717 if user_id is not None: 

718 params["userId"] = user_id 

719 

720 response = self.get("/albums", params=params) 

721 return _safe_parse_json(response) 

722 

723 def get_photos(self, album_id: int | None = None) -> list[dict[str, Any]]: 

724 """写真一覧の取得 

725 

726 Args: 

727 album_id: アルバムIDでフィルタリング(1以上) 

728 

729 Raises: 

730 ValueError: album_id < 1 の場合 

731 """ 

732 _validate_optional_int(album_id, "album_id", 1) 

733 

734 if album_id is not None: 

735 response = self.get(f"/albums/{album_id}/photos") 

736 else: 

737 response = self.get("/photos") 

738 return _safe_parse_json(response) 

739 

740 # ヘルスチェック(DevOps/K8s readiness対応) 

741 def health_check(self) -> bool: 

742 """API接続の健全性チェック(同期版) 

743 

744 Docker/Kubernetes readiness probeとして使用可能。 

745 軽量なリクエスト(/users?_limit=1)でAPI到達性を確認。 

746 

747 Returns: 

748 bool: API到達可能ならTrue、エラー時はFalse 

749 

750 Note: 

751 Async版と同一インターフェースで統一。 

752 CLI、スクリプト、レガシーシステム統合時に使用。 

753 同期版はasyncioタスクキャンセル文脈を持たないため、 

754 Async版と異なりCancelledErrorの再発生処理は不要。 

755 ログ出力(``health_check_failed`` イベント)では ``error`` フィールドを省略し、 

756 ``error_type`` のみ記録する(``_classify_error()`` は経由せず 

757 直接 ``logger.warning`` を呼び出す)。 

758 

759 Example: 

760 >>> with SyncJSONPlaceholderClient() as client: 

761 ... if client.health_check(): 

762 ... print("API is healthy") 

763 

764 """ 

765 try: 

766 response = self.get("/users", params={"_limit": 1}) 

767 return response.status_code == 200 

768 except SYNC_FATAL_EXCEPTIONS: 

769 # システム例外は再発生(K8s OOMKilled検知、graceful shutdown対応) 

770 raise 

771 except APIClientError as e: 

772 # 予期されるAPI例外のみキャッチ 

773 self.logger.warning( 

774 "health_check_failed", 

775 error_type=type(e).__name__, 

776 endpoint="/users", # health_check は常に固定エンドポイント(非機密) 

777 ) 

778 return False 

779 

780 

781def _log_error_with_stderr_fallback( 

782 logger: FilteringBoundLogger, 

783 source: str, 

784 context: str, 

785 exc: BaseException, 

786 event: str, 

787 **fields: Any, 

788) -> None: 

789 """logger.error 記録 + 失敗時 stderr フォールバック (PR#347 B-3 / Q-8 DRY)。 

790 

791 ``api_client`` / ``github_client`` の close・cache 失敗ログで共通する 

792 「``logger.error`` → 失敗時 ``stderr``」パターンをモジュールレベルに集約する。 

793 ``AsyncGitHubClient`` は ``AsyncAPIClient`` を継承しないため、メソッドではなく 

794 モジュール関数として共有する (PR#347 Q-8: インライン重複による修正漏れを防ぐ)。 

795 

796 ロガー自体が致命例外 (``MemoryError`` / ``RecursionError``) を投げた場合は 

797 fail-fast で再 raise し、それ以外のロガー例外は握りつぶした ``exc`` の型名を 

798 ``stderr`` へ再露出させて監視可能性を保つ。 

799 

800 Args: 

801 logger: structlog ロガー。 

802 source: stderr メッセージのソース識別子 (例 ``"api_client"``)。 

803 context: 失敗箇所の短い識別子 (例 ``"aclose"`` / ``"etag_cache"``)。 

804 exc: 既に握りつぶされている元例外 (型名のみ stderr 出力, PII 非含)。 

805 event: ``logger.error`` へ渡すイベント名。 

806 **fields: ``logger.error`` へ渡す構造化フィールド。 

807 

808 Raises: 

809 MemoryError: ``logger`` が ``MemoryError`` を投げた場合に再 raise(fail-fast)。 

810 RecursionError: ``logger`` が ``RecursionError`` を投げた場合に再 raise(fail-fast)。 

811 """ 

812 try: 

813 logger.error(event, **fields) 

814 except (MemoryError, RecursionError): # fmt: skip 

815 # 致命例外は握りつぶさず再raise(fail-fast)。両者は Exception 派生のため、 

816 # 下の except Exception より先に明示的に先取りする 

817 # (sentry_init._safe_log_warning / _close_async_client と同一方針)。 

818 raise 

819 except Exception: # noqa: BLE001 

820 # ロガー例外が握りつぶした exc を再露出させない保険。 

821 try: 

822 print( 

823 f"[{source}] {context} logger failed: {type(exc).__name__}", 

824 file=sys.stderr, 

825 flush=True, 

826 ) 

827 except Exception: # noqa: BLE001, S110 

828 pass 

829 

830 

831# ============================================================================= 

832# 非同期APIクライアント 

833# ============================================================================= 

834 

835 

836class AsyncAPIClient: 

837 """非同期HTTPクライアント""" 

838 

839 _client: httpx.AsyncClient | None # aclose() 後に None を代入するため明示宣言 

840 

841 def __init__( 

842 self, 

843 base_url: str | None = None, 

844 timeout: float | None = None, 

845 retry_count: int | None = None, 

846 retry_delay: float | None = None, 

847 headers: dict[str, str] | None = None, 

848 ): 

849 """Args: 

850 base_url: APIのベースURL(設定から自動取得可能) 

851 timeout: リクエストタイムアウト(秒) 

852 retry_count: リトライ回数 

853 retry_delay: リトライ間隔(秒) 

854 headers: 追加HTTPヘッダー 

855 

856 Raises: 

857 ValueError: base_urlが空文字列またはホワイトスペース 

858 (スペース・タブ・改行等)のみの文字列の場合 

859 

860 """ 

861 # 設定解決・バリデーション(Sync/Async共通ロジック) 

862 # NOTE: retry_count=0, retry_delay=0.0, timeout=0.0 は有効な設定値のため is not None で判定 

863 # timeout=0.0: 即座にタイムアウト(無効化は timeout=None) 

864 ( 

865 self.base_url, 

866 self.timeout, 

867 self.retry_count, 

868 self.retry_delay, 

869 self.default_headers, 

870 ) = _resolve_client_config(base_url, timeout, retry_count, retry_delay, headers) 

871 

872 # ロガーの初期化(structlog統合) 

873 self.logger = get_logger(__name__) 

874 

875 # HTTPクライアントの初期化(非同期) 

876 self._client = httpx.AsyncClient( 

877 base_url=self.base_url, 

878 timeout=self.timeout, 

879 headers=self.default_headers, 

880 limits=httpx.Limits(max_connections=settings.api.max_connections), 

881 ) 

882 

883 self.logger.info("async_api_client_initialized", base_url=self.base_url) 

884 

885 async def __aenter__(self) -> Self: 

886 """非同期コンテキストマネージャーのエントリー""" 

887 return self 

888 

889 def _log_aclose_error_with_fallback( 

890 self, event: str, close_exc: BaseException, **fields: Any 

891 ) -> None: 

892 """aclose エラーを記録し、失敗時は stderr へフォールバック (PR#347 B-3 / Q-8)。 

893 

894 モジュールレベル ``_log_error_with_stderr_fallback`` への薄いラッパー。 

895 ``github_client`` とロジック (logger.error → 失敗時 stderr) を共有し、 

896 インライン重複による修正漏れを防ぐ (PR#347 Q-8 DRY)。 

897 """ 

898 _log_error_with_stderr_fallback( 

899 self.logger, "api_client", "aclose", close_exc, event, **fields 

900 ) 

901 

902 async def _close_async_client( 

903 self, 

904 body_exc_type: type[BaseException] | None, 

905 *, 

906 suppress_unexpected: bool = False, 

907 ) -> None: 

908 """``__aexit__`` と ``aclose()`` で共有する close 処理. 

909 

910 Args: 

911 body_exc_type: ``__aexit__`` 経路では context manager の body 内で発生した 

912 例外型を渡す (例外無しなら ``None``)。``aclose()`` 直接呼び出し経路では 

913 常に ``None`` を渡す。``aclose()`` 失敗時に予期しない close 例外 

914 (``Exception`` 派生) を捕捉した際、body 例外の上書き防止のため 

915 ``has_body_exception = body_exc_type is not None`` を判定材料として 

916 利用する。``None`` の場合のみ実装バグとして bare ``raise`` で再送出する。 

917 suppress_unexpected: ``True`` の場合、予期しない close 例外を error ログ 

918 のみ記録して握りつぶす (re-raise しない)。``aclose()`` 直接呼び出し経路 

919 で ``True`` を渡すことで finally ブロック等での安全な呼び出しを保証する。 

920 ``__aexit__`` 経路では ``False`` (デフォルト) のまま ``has_body_exception`` 

921 ロジックによる従来の re-raise 判定を維持する。 

922 """ 

923 if self._client is not None: 

924 try: 

925 await self._client.aclose() 

926 except (httpx.CloseError, OSError) as close_exc: 

927 # 既知のクローズ時例外 — 警告のみ(body 例外を上書きしない) 

928 self._client = None 

929 self.logger.warning( 

930 "async_api_client_aclose_failed", 

931 error_type=type(close_exc).__name__, 

932 error_module=type(close_exc).__module__, 

933 ) 

934 except (MemoryError, RecursionError): # fmt: skip 

935 # MemoryError/RecursionError も Exception 派生のため、再raise しないと 

936 # 下流の except Exception に捕捉されサイレント隠蔽される 

937 # (github_client / sentry_init と同一方針)。 

938 # 致命的エラーとして必ず再raise(fail-fast)。 

939 # ASYNC_FATAL_EXCEPTIONS は流用しない(close 文脈で asyncio.CancelledError 

940 # は捕捉対象外=BaseException 直系で素通りさせるのが正しいため)。 

941 raise 

942 except Exception as close_exc: # noqa: BLE001 

943 # 予期しない例外(AttributeError, RuntimeError, ValueError, TypeError 等の 

944 # 実装バグ可能性)を捕捉。以下は本句より先に処理済み / 境界外: 

945 # - RecursionError / MemoryError: 上の専用句で先取り捕捉し 

946 # 即時 re-raise(fail-fast)。 

947 # - KeyboardInterrupt / SystemExit / asyncio.CancelledError は 

948 # BaseException 直系で `except Exception` の境界外。 

949 # ユーザー停止/プロセス終了/cancellation を妨げない。 

950 has_body_exception = body_exc_type is not None 

951 if suppress_unexpected: 

952 # aclose() 直接呼び出し経路: finally ブロック等での安全な呼び出しを保証するため 

953 # 予期しない例外を握りつぶし、error ログで本番監視対象にする。 

954 # suppress_unexpected=True は has_body_exception より優先して評価する。 

955 # suppress 経路でも状態一貫性のため None セット。 

956 # aclose 失敗後の壊れたクライアント再利用を防止 

957 # (github_client __aexit__ L356 / 成功時 else 節と同一方針)。 

958 self._client = None 

959 self._log_aclose_error_with_fallback( 

960 "async_api_client_aclose_unexpected_error_suppressed", 

961 close_exc, 

962 error_type=type(close_exc).__name__, 

963 error_module=type(close_exc).__module__, 

964 exc_info=True, # スタックトレースをログに残す 

965 ) 

966 else: 

967 # __aexit__ 経路(context manager): 従来の has_body_exception ロジックを維持。 

968 # PR#347 review SF-2: close_exc が body 例外を上書きしないため 

969 # __context__ チェーンは切断される。代わりに body 例外の型名を 

970 # 同一ログイベント内に記録し、close 失敗と body 例外の対応関係を 

971 # 追跡可能にする (PII 非含: __qualname__ はクラス名のみ)。 

972 self._log_aclose_error_with_fallback( 

973 "async_api_client_aclose_unexpected_error", 

974 close_exc, 

975 error_type=type(close_exc).__name__, 

976 error_module=type(close_exc).__module__, 

977 has_body_exception=has_body_exception, 

978 action=( 

979 "suppressed_due_to_body_exception" 

980 if has_body_exception 

981 else "re_raised" 

982 ), 

983 body_exception_type=( 

984 body_exc_type.__qualname__ if body_exc_type is not None else None 

985 ), 

986 exc_info=True, # スタックトレースをログに残す 

987 ) 

988 # body 例外がない場合のみ実装バグとして re-raise。 

989 # body 例外がある場合は本質的原因の上書きを防ぐため raise しない。 

990 # bare ``raise`` で active exception の traceback を完全保持 

991 # (``raise close_exc`` への回帰防止: 余分な frame を追加せず Python idiom)。 

992 if not has_body_exception: 

993 raise 

994 else: 

995 # aclose() 成功時のみ closed ログを出す。logger.info を try 内に置くと 

996 # logger 自体の例外が aclose 失敗として誤検知されるため else 節に分離。 

997 # github_client.py / Sync close() と同一パターン (PR#347 Q-1 Codex fix)。 

998 self._client = None # double-close 防止(_client 型は | None 宣言済み) 

999 self.logger.info("async_api_client_closed") 

1000 

1001 async def __aexit__( 

1002 self, 

1003 exc_type: type[BaseException] | None, 

1004 exc_val: BaseException | None, 

1005 exc_tb: TracebackType | None, 

1006 ) -> None: 

1007 """非同期コンテキストマネージャーの終了処理""" 

1008 await self._close_async_client(exc_type) 

1009 

1010 async def aclose(self) -> None: 

1011 """クライアントのクローズ 

1012 

1013 async with パターンと aclose() 単独呼び出しの両経路で 

1014 ``async_api_client_closed`` ログを出力し、Sync (``SyncAPIClient.close()``) 

1015 との observability 対称性を保つ (PR#347 review Q-1)。 

1016 

1017 Note: 直接呼び出し時 (async context manager 経由でない場合)、予期しない close 例外は 

1018 error ログ記録の上で抑制される (re-raise しない)。これにより finally ブロックでの 

1019 安全な呼び出しを保証し、アプリクラッシュリスクを回避する。 

1020 async with 経由では body 例外保護ロジック (has_body_exception) が適用され、 

1021 body 例外がない場合のみ close 例外を re-raise する従来の挙動を維持する。 

1022 """ 

1023 await self._close_async_client(None, suppress_unexpected=True) 

1024 

1025 async def _make_request_with_retry( 

1026 self, 

1027 method: str, 

1028 endpoint: str, 

1029 **kwargs: Any, 

1030 ) -> httpx.Response: 

1031 """リトライ機能付き非同期HTTPリクエスト実行 

1032 

1033 Args: 

1034 method: HTTPメソッド 

1035 endpoint: APIエンドポイント 

1036 **kwargs: httpxに渡す追加パラメータ 

1037 

1038 Returns: 

1039 httpx.Response: APIレスポンス 

1040 

1041 Raises: 

1042 APIConnectionError: 接続エラー 

1043 APITimeoutError: タイムアウトエラー 

1044 APIHTTPError: HTTPステータスエラー 

1045 APIRetryError: リトライ上限エラー 

1046 APIClientError: 非リトライエラー(TooManyRedirects / InvalidURL) 

1047 

1048 Note: 

1049 TooManyRedirects/InvalidURL は _map_request_error() 内で即 raise されるため、 

1050 APIRetryError ではなく APIClientError として呼び出し元に届く。 

1051 呼び出し元は APIClientError で捕捉すること。 

1052 

1053 """ 

1054 # close 後の use-after-close を明示エラー化(github_client.py L878 と同一パターン)。 

1055 # 型注釈 _client: AsyncClient | None に対する None 絞り込みも兼ねる。 

1056 if self._client is None: 

1057 raise RuntimeError("Client not initialized. Use 'async with' context.") 

1058 

1059 last_exception: APIClientError | None = None 

1060 

1061 for attempt in range(self.retry_count + 1): 

1062 # 非同期HTTPリクエスト実行(ネットワーク層) 

1063 try: 

1064 # structlogでログ出力(DRY原則: 重複ログ削除) 

1065 if attempt > 0: 

1066 self.logger.warning( 

1067 "async_request_retry", 

1068 attempt=attempt + 1, 

1069 max_attempts=self.retry_count + 1, 

1070 method=method, 

1071 endpoint=endpoint, 

1072 ) 

1073 else: 

1074 self.logger.debug("async_request_start", method=method, endpoint=endpoint) 

1075 

1076 # 非同期HTTPリクエスト実行 

1077 response = await self._client.request(method, endpoint, **kwargs) 

1078 except (httpx.RequestError, httpx.InvalidURL) as e: 

1079 # 全ネットワーク層エラーをキャッチ(TimeoutException, ConnectError, etc.) 

1080 # TooManyRedirects/InvalidURL は _classify_error → _map_request_error 内で即 raise 

1081 last_exception = _classify_error( 

1082 e, 

1083 self.logger, 

1084 is_async=True, 

1085 method=method, 

1086 endpoint=endpoint, 

1087 ) 

1088 else: 

1089 # ネットワーク成功時のみHTTPステータス処理 

1090 try: 

1091 response.raise_for_status() 

1092 self.logger.debug( 

1093 "async_request_success", 

1094 method=method, 

1095 endpoint=endpoint, 

1096 status_code=response.status_code, 

1097 ) 

1098 return response 

1099 except httpx.HTTPStatusError as e: 

1100 # 4xxエラーはリトライしない(クライアントエラー) 

1101 if e.response.is_client_error: 

1102 self.logger.error( 

1103 "client_error", 

1104 status_code=e.response.status_code, 

1105 method=method, 

1106 endpoint=endpoint, 

1107 ) 

1108 raise APIHTTPError( 

1109 f"HTTP {e.response.status_code} Client Error", 

1110 e.response.status_code, 

1111 e.response, 

1112 ) from e 

1113 

1114 # 5xxエラーはリトライ対象 

1115 self.logger.warning( 

1116 "server_error", 

1117 status_code=e.response.status_code, 

1118 method=method, 

1119 endpoint=endpoint, 

1120 ) 

1121 last_exception = APIHTTPError( 

1122 f"HTTP {e.response.status_code} Server Error", 

1123 e.response.status_code, 

1124 e.response, 

1125 ) 

1126 

1127 # 最後の試行でなければ指数バックオフ + 30%ジッターで待機 

1128 if attempt < self.retry_count: 

1129 delay = exponential_backoff_with_jitter( 

1130 attempt=attempt, 

1131 base_delay=self.retry_delay, 

1132 jitter_percent=0.3, 

1133 ) 

1134 self.logger.debug( 

1135 "async_retry_backoff", 

1136 delay_seconds=round(delay, 2), 

1137 attempt=attempt + 1, 

1138 strategy="exponential_backoff_with_jitter", 

1139 ) 

1140 await asyncio.sleep(delay) 

1141 

1142 # すべてのリトライが失敗 

1143 self.logger.error("async_all_retries_failed", method=method, endpoint=endpoint) 

1144 raise APIRetryError( 

1145 f"Async request failed after {self.retry_count + 1} attempts", 

1146 ) from last_exception 

1147 

1148 async def get( 

1149 self, 

1150 endpoint: str, 

1151 params: dict[str, Any] | None = None, 

1152 headers: dict[str, str] | None = None, 

1153 ) -> httpx.Response: 

1154 """非同期GETリクエスト実行""" 

1155 return await self._make_request_with_retry("GET", endpoint, params=params, headers=headers) 

1156 

1157 async def post( 

1158 self, 

1159 endpoint: str, 

1160 json: dict[str, Any] | None = None, 

1161 data: dict[str, Any] | None = None, 

1162 headers: dict[str, str] | None = None, 

1163 ) -> httpx.Response: 

1164 """非同期POSTリクエスト実行""" 

1165 return await self._make_request_with_retry( 

1166 "POST", 

1167 endpoint, 

1168 json=json, 

1169 data=data, 

1170 headers=headers, 

1171 ) 

1172 

1173 async def put( 

1174 self, 

1175 endpoint: str, 

1176 json: dict[str, Any] | None = None, 

1177 data: dict[str, Any] | None = None, 

1178 headers: dict[str, str] | None = None, 

1179 ) -> httpx.Response: 

1180 """非同期PUTリクエスト実行""" 

1181 return await self._make_request_with_retry( 

1182 "PUT", 

1183 endpoint, 

1184 json=json, 

1185 data=data, 

1186 headers=headers, 

1187 ) 

1188 

1189 async def delete(self, endpoint: str, headers: dict[str, str] | None = None) -> httpx.Response: 

1190 """非同期DELETEリクエスト実行""" 

1191 return await self._make_request_with_retry("DELETE", endpoint, headers=headers) 

1192 

1193 async def patch( 

1194 self, 

1195 endpoint: str, 

1196 json: dict[str, Any] | None = None, 

1197 data: dict[str, Any] | None = None, 

1198 headers: dict[str, str] | None = None, 

1199 ) -> httpx.Response: 

1200 """非同期PATCHリクエスト実行""" 

1201 return await self._make_request_with_retry( 

1202 "PATCH", 

1203 endpoint, 

1204 json=json, 

1205 data=data, 

1206 headers=headers, 

1207 ) 

1208 

1209 

1210# 一括作成の部分失敗ログで記録する詳細の上限件数 

1211_MAX_LOGGED_FAILURE_DETAILS: Final[int] = 5 

1212 

1213 

1214class AsyncJSONPlaceholderClient(AsyncAPIClient): 

1215 """JSONPlaceholder API専用非同期クライアント""" 

1216 

1217 # Posts API 

1218 async def get_posts( 

1219 self, limit: int | None = None, user_id: int | None = None 

1220 ) -> list[dict[str, Any]]: 

1221 """投稿一覧の非同期取得 

1222 

1223 Args: 

1224 limit: 取得件数上限(0以上) 

1225 user_id: ユーザーIDでフィルタリング(API側フィルタ、1以上) 

1226 

1227 Raises: 

1228 ValueError: limit < 0 または user_id < 1 の場合 

1229 """ 

1230 _validate_optional_int(limit, "limit", 0) 

1231 _validate_optional_int(user_id, "user_id", 1) 

1232 

1233 params = {} 

1234 if limit is not None: 

1235 params["_limit"] = limit 

1236 if user_id is not None: 

1237 params["userId"] = user_id 

1238 

1239 response = await self.get("/posts", params=params) 

1240 return _safe_parse_json(response) 

1241 

1242 async def get_post(self, post_id: int) -> dict[str, Any]: 

1243 """特定投稿の非同期取得""" 

1244 response = await self.get(f"/posts/{post_id}") 

1245 return _safe_parse_json(response) 

1246 

1247 async def create_post(self, title: str, body: str, user_id: int) -> dict[str, Any]: 

1248 """新規投稿の非同期作成""" 

1249 data = {"title": title, "body": body, "userId": user_id} 

1250 response = await self.post("/posts", json=data) 

1251 return _safe_parse_json(response) 

1252 

1253 async def update_post(self, post_id: int, title: str, body: str) -> dict[str, Any]: 

1254 """投稿更新の非同期実行""" 

1255 data = {"title": title, "body": body} 

1256 response = await self.put(f"/posts/{post_id}", json=data) 

1257 return _safe_parse_json(response) 

1258 

1259 async def delete_post(self, post_id: int) -> None: 

1260 """投稿削除の非同期実行""" 

1261 await self.delete(f"/posts/{post_id}") 

1262 

1263 # Users API 

1264 async def get_users(self) -> list[dict[str, Any]]: 

1265 """ユーザー一覧の非同期取得""" 

1266 response = await self.get("/users") 

1267 return _safe_parse_json(response) 

1268 

1269 async def get_user(self, user_id: int) -> dict[str, Any]: 

1270 """特定ユーザーの非同期取得""" 

1271 response = await self.get(f"/users/{user_id}") 

1272 return _safe_parse_json(response) 

1273 

1274 # Todos API 

1275 async def get_todos( 

1276 self, 

1277 user_id: int | None = None, 

1278 completed: bool | None = None, 

1279 limit: int | None = None, 

1280 ) -> list[dict[str, Any]]: 

1281 """TODO一覧の非同期取得 

1282 

1283 Args: 

1284 user_id: ユーザーIDでフィルタリング(API側フィルタ、1以上) 

1285 completed: 完了状態でフィルタリング 

1286 limit: 取得件数上限(0以上) 

1287 

1288 Raises: 

1289 ValueError: limit < 0 または user_id < 1 の場合 

1290 """ 

1291 _validate_optional_int(limit, "limit", 0) 

1292 _validate_optional_int(user_id, "user_id", 1) 

1293 

1294 params = {} 

1295 if user_id is not None: 

1296 params["userId"] = user_id 

1297 if completed is not None: 

1298 params["completed"] = completed 

1299 if limit is not None: 

1300 params["_limit"] = limit 

1301 

1302 response = await self.get("/todos", params=params) 

1303 return _safe_parse_json(response) 

1304 

1305 async def get_todo(self, todo_id: int) -> dict[str, Any]: 

1306 """特定TODOの非同期取得""" 

1307 response = await self.get(f"/todos/{todo_id}") 

1308 return _safe_parse_json(response) 

1309 

1310 async def create_todo( 

1311 self, 

1312 title: str, 

1313 user_id: int, 

1314 completed: bool = False, 

1315 ) -> dict[str, Any]: 

1316 """新規TODOの非同期作成""" 

1317 data = {"title": title, "userId": user_id, "completed": completed} 

1318 response = await self.post("/todos", json=data) 

1319 return _safe_parse_json(response) 

1320 

1321 async def update_todo(self, todo_id: int, **kwargs: Any) -> dict[str, Any]: 

1322 """TODOの非同期更新""" 

1323 response = await self.patch(f"/todos/{todo_id}", json=kwargs) 

1324 return _safe_parse_json(response) 

1325 

1326 # Users API 追加メソッド 

1327 async def create_user(self, user_data: dict[str, Any]) -> dict[str, Any]: 

1328 """新規ユーザーの非同期作成""" 

1329 response = await self.post("/users", json=user_data) 

1330 return _safe_parse_json(response) 

1331 

1332 async def bulk_create_users(self, users_data: list[dict[str, Any]]) -> list[dict[str, Any]]: 

1333 """複数ユーザーの非同期一括作成 

1334 

1335 個別失敗を許容し、成功したユーザーのみ返却。 

1336 失敗時はwarningログを出力(詳細は _MAX_LOGGED_FAILURE_DETAILS 件まで記録)。 

1337 K8s SIGTERM等で複数タスクが同時キャンセルされた場合はerrorログを出力後、 

1338 CancelledError等のfatal例外を再発生させる(graceful shutdown保護)。 

1339 

1340 Args: 

1341 users_data: 作成するユーザーデータのリスト(各要素はname/emailを含むdict) 

1342 

1343 Returns: 

1344 成功したユーザーデータのリスト(失敗した分は除外される)。 

1345 部分失敗時は入力件数より短いリストを返す。 

1346 

1347 Raises: 

1348 asyncio.CancelledError: 単一タスクがキャンセルされた場合(K8s graceful shutdown等) 

1349 BaseExceptionGroup: 複数タスクが同時にfatal例外を発生させた場合(Python convention準拠) 

1350 KeyboardInterrupt: Ctrl+C等の割り込みシグナルを受けた場合 

1351 SystemExit: sys.exit()が呼ばれた場合 

1352 MemoryError: メモリ不足が発生した場合 

1353 """ 

1354 # 並行してユーザー作成(個別失敗許容) 

1355 tasks = [self.create_user(user_data) for user_data in users_data] 

1356 results = await asyncio.gather(*tasks, return_exceptions=True) 

1357 

1358 # システム例外はgather後に再発生させる(graceful shutdown保護) 

1359 # asyncio.CancelledError(Python 3.8+ は BaseException サブクラス)を吸収しない 

1360 # 複数タスクが同時キャンセルされる場合(K8s SIGTERM等)に全件収集してログ出力 

1361 fatal_exceptions = [r for r in results if isinstance(r, ASYNC_FATAL_EXCEPTIONS)] 

1362 if fatal_exceptions: 

1363 if len(fatal_exceptions) > 1: 

1364 # Python convention: 複数同時例外はBaseExceptionGroupで伝播(TaskGroup同パターン) 

1365 # ログとraise件数の一貫性を保証(count=N → N件をBaseExceptionGroupで伝播) 

1366 # NOTE: CancelledError/KeyboardInterrupt/SystemExitはBaseExceptionサブクラスのため 

1367 # ExceptionGroup(Exception限定)ではなくBaseExceptionGroupを使用 

1368 self.logger.error( 

1369 "bulk_create_multiple_fatal_errors", 

1370 count=len(fatal_exceptions), 

1371 types=[type(e).__name__ for e in fatal_exceptions], 

1372 ) 

1373 raise BaseExceptionGroup( 

1374 "bulk_create_users: multiple fatal errors occurred", 

1375 fatal_exceptions, 

1376 ) 

1377 # 単一例外は直接raise(Python convention: asyncio.TaskGroupと同パターン) 

1378 exc = fatal_exceptions[0] 

1379 raise exc 

1380 

1381 # 成功・失敗を分離(型安全なフィルタリング) 

1382 successful: list[dict[str, Any]] = [r for r in results if isinstance(r, dict)] 

1383 failed: list[BaseException] = [r for r in results if isinstance(r, BaseException)] 

1384 

1385 # 失敗時はログ出力(A1: デバッグ改善) 

1386 if failed: 

1387 failed_details = [] 

1388 for i, result in enumerate(results): 

1389 if isinstance(result, BaseException): 

1390 failed_details.append( 

1391 { 

1392 "index": i, 

1393 "error_type": type(result).__name__, 

1394 # 422 vs 503 を区別 

1395 **( 

1396 {"status_code": result.status_code} 

1397 if isinstance(result, APIHTTPError) 

1398 else {} 

1399 ), 

1400 } 

1401 ) 

1402 # PII除去設計: ログには index/error_type/status_code のみ記録。 

1403 # index はリクエスト配列内の元位置を示す(失敗行の特定・照合用)。 

1404 self.logger.warning( 

1405 "bulk_create_partial_failure", 

1406 failed_count=len(failed), 

1407 success_count=len(successful), 

1408 failed_details=failed_details[:_MAX_LOGGED_FAILURE_DETAILS], 

1409 details_truncated=len(failed_details) > _MAX_LOGGED_FAILURE_DETAILS, 

1410 ) 

1411 

1412 return successful 

1413 

1414 # Comments API 

1415 async def get_comments(self, post_id: int | None = None) -> list[dict[str, Any]]: 

1416 """コメント一覧の非同期取得 

1417 

1418 Args: 

1419 post_id: 投稿IDでフィルタリング(1以上) 

1420 

1421 Raises: 

1422 ValueError: post_id < 1 の場合 

1423 """ 

1424 _validate_optional_int(post_id, "post_id", 1) 

1425 

1426 if post_id is not None: 

1427 response = await self.get(f"/posts/{post_id}/comments") 

1428 else: 

1429 response = await self.get("/comments") 

1430 return _safe_parse_json(response) 

1431 

1432 # Albums & Photos API 

1433 async def get_albums(self, user_id: int | None = None) -> list[dict[str, Any]]: 

1434 """アルバム一覧の非同期取得 

1435 

1436 Args: 

1437 user_id: ユーザーIDでフィルタリング(API側フィルタ、1以上) 

1438 

1439 Raises: 

1440 ValueError: user_id < 1 の場合 

1441 """ 

1442 _validate_optional_int(user_id, "user_id", 1) 

1443 

1444 params = {} 

1445 if user_id is not None: 

1446 params["userId"] = user_id 

1447 

1448 response = await self.get("/albums", params=params) 

1449 return _safe_parse_json(response) 

1450 

1451 async def get_photos(self, album_id: int | None = None) -> list[dict[str, Any]]: 

1452 """写真一覧の非同期取得 

1453 

1454 Args: 

1455 album_id: アルバムIDでフィルタリング(1以上) 

1456 

1457 Raises: 

1458 ValueError: album_id < 1 の場合 

1459 """ 

1460 _validate_optional_int(album_id, "album_id", 1) 

1461 

1462 if album_id is not None: 

1463 response = await self.get(f"/albums/{album_id}/photos") 

1464 else: 

1465 response = await self.get("/photos") 

1466 return _safe_parse_json(response) 

1467 

1468 # 並行処理の例 

1469 async def get_user_data(self, user_id: int) -> dict[str, Any]: 

1470 """ユーザーに関連するデータを並行取得""" 

1471 # 並行してユーザー情報、投稿、TODO、アルバムを取得 

1472 user_task = self.get_user(user_id) 

1473 posts_task = self.get_posts(user_id=user_id) 

1474 todos_task = self.get_todos(user_id=user_id) 

1475 albums_task = self.get_albums(user_id=user_id) 

1476 

1477 # 全ての結果を待機 

1478 user, posts, todos, albums = await asyncio.gather( 

1479 user_task, 

1480 posts_task, 

1481 todos_task, 

1482 albums_task, 

1483 ) 

1484 

1485 return { 

1486 "user": user, 

1487 "posts": posts, 

1488 "todos": todos, 

1489 "albums": albums, 

1490 } 

1491 

1492 # ヘルスチェック(DevOps/K8s readiness対応) 

1493 async def health_check(self) -> bool: 

1494 """API接続の健全性チェック 

1495 

1496 Docker/Kubernetes readiness probeとして使用可能。 

1497 軽量なリクエスト(/users?_limit=1)でAPI到達性を確認。 

1498 

1499 Returns: 

1500 bool: API到達可能ならTrue、エラー時はFalse 

1501 

1502 Note: 

1503 ログ出力(``health_check_failed`` イベント)では ``error`` フィールドを省略し、 

1504 ``error_type`` のみ記録する(``_classify_error()`` は経由せず 

1505 直接 ``logger.warning`` を呼び出す)。 

1506 

1507 Example: 

1508 >>> async with AsyncJSONPlaceholderClient() as client: 

1509 ... if await client.health_check(): 

1510 ... print("API is healthy") 

1511 

1512 """ 

1513 try: 

1514 response = await self.get("/users", params={"_limit": 1}) 

1515 return response.status_code == 200 

1516 except ASYNC_FATAL_EXCEPTIONS: 

1517 # システム例外・タスクキャンセルは再発生(K8s対応、graceful shutdown) 

1518 raise 

1519 except APIClientError as e: 

1520 # 予期されるAPI例外のみキャッチ 

1521 self.logger.warning( 

1522 "health_check_failed", 

1523 error_type=type(e).__name__, 

1524 endpoint="/users", # health_check は常に固定エンドポイント(非機密) 

1525 ) 

1526 return False 

1527 

1528 # 複数ユーザー取得(Semaphore制御) 

1529 async def get_multiple_users( 

1530 self, 

1531 user_ids: list[int], 

1532 max_concurrent: int = 5, 

1533 ) -> list[dict[str, Any]]: 

1534 """複数ユーザーを並行取得(Semaphore制御付き) 

1535 

1536 asyncio.Semaphoreを使用してRate Limit対策。 

1537 GitHub APIなど制限のあるAPIでも安全に並行リクエスト可能。 

1538 

1539 Args: 

1540 user_ids: 取得対象のユーザーIDリスト 

1541 max_concurrent: 同時実行数の上限(デフォルト5) 

1542 

1543 Returns: 

1544 list[dict]: 取得成功したユーザー情報リスト 

1545 (取得失敗したIDはスキップ、warningログ出力) 

1546 

1547 Example: 

1548 >>> async with AsyncJSONPlaceholderClient() as client: 

1549 ... users = await client.get_multiple_users([1, 2, 3], max_concurrent=2) 

1550 ... print(f"Fetched {len(users)} users") 

1551 

1552 """ 

1553 semaphore = asyncio.Semaphore(max_concurrent) 

1554 

1555 async def fetch_with_semaphore(user_id: int) -> dict[str, Any] | None: 

1556 """Semaphore制御付きでユーザー取得""" 

1557 async with semaphore: 

1558 try: 

1559 return await self.get_user(user_id) 

1560 except ASYNC_FATAL_EXCEPTIONS: 

1561 # システム例外・タスクキャンセルは再発生(並行処理全体を停止) 

1562 raise 

1563 except APIClientError as e: 

1564 # 予期されるAPI例外のみキャッチ(graceful degradation) 

1565 self.logger.warning( 

1566 "get_user_failed", 

1567 user_id=user_id, 

1568 error_type=type(e).__name__, 

1569 ) 

1570 return None 

1571 

1572 # 並行実行(return_exceptions不要:内部でtry-catch済み) 

1573 results = await asyncio.gather(*[fetch_with_semaphore(uid) for uid in user_ids]) 

1574 

1575 # None除外(失敗分) 

1576 successful = [r for r in results if r is not None] 

1577 failed_count = len(user_ids) - len(successful) 

1578 if failed_count: 

1579 self.logger.warning( 

1580 "get_multiple_users_partial_failure_summary", 

1581 failed_count=failed_count, 

1582 success_count=len(successful), 

1583 requested_count=len(user_ids), 

1584 ) 

1585 return successful 

1586 

1587 

1588# ============================================================================= 

1589# 便利な関数 

1590# ============================================================================= 

1591 

1592 

1593def create_client() -> SyncJSONPlaceholderClient: 

1594 """設定に基づいたクライアントインスタンスの作成""" 

1595 return SyncJSONPlaceholderClient() 

1596 

1597 

1598# ============================================================================= 

1599# デモ実行(モジュール直接実行時) 

1600# ============================================================================= 

1601 

1602 

1603def main() -> None: 

1604 """デモ実行""" 

1605 print("=== JSONPlaceholder API Client Demo ===") 

1606 

1607 with create_client() as client: 

1608 try: 

1609 # 投稿一覧の取得 

1610 print("\n1. 投稿一覧取得(5件):") 

1611 posts = client.get_posts(limit=5) 

1612 for post in posts: 

1613 print(f" - Post {post['id']}: {post['title'][:50]}...") 

1614 

1615 # 特定ユーザーの取得 

1616 print("\n2. ユーザー情報取得(ID: 1):") 

1617 user = client.get_user(1) 

1618 print(f" - Name: {user['name']}") 

1619 print(f" - Email: {user['email']}") 

1620 print(f" - Company: {user['company']['name']}") 

1621 

1622 # TODOの取得 

1623 print("\n3. TODO取得(完了済み、3件):") 

1624 todos = client.get_todos(completed=True, limit=3) 

1625 for todo in todos: 

1626 status = "✓" if todo["completed"] else "✗" 

1627 print(f" {status} User {todo['userId']}: {todo['title']}") 

1628 

1629 # 新規投稿の作成(テスト用) 

1630 print("\n4. 新規投稿作成テスト:") 

1631 new_post = client.create_post( 

1632 title="Test Post from API Client", 

1633 body="This is a test post created by our API client.", 

1634 user_id=1, 

1635 ) 

1636 print(f" - Created post ID: {new_post.get('id', 'N/A')}") 

1637 print(f" - Title: {new_post.get('title', 'N/A')}") 

1638 

1639 except APIClientError as e: 

1640 # {e}: _map_request_error()経由の場合は固定プレフィックス+クラス名のみ。デモ用表示のみ 

1641 print(f"エラーが発生しました: {type(e).__name__}: {e}") 

1642 if settings.debug: 

1643 import traceback 

1644 

1645 # chain=False: __cause__のhttpx例外チェーンのみ非表示。本体のスタックトレースは表示される # noqa: E501 

1646 traceback.print_exception(e, chain=False) 

1647 

1648 print("\n=== Demo completed ===") 

1649 

1650 

1651if __name__ == "__main__": 

1652 # structlogはget_logger()初回呼び出し時に自動設定されるため、手動設定不要 

1653 main()