Coverage for utils / github_client.py: 98.55%
321 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-06-17 01:43 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-06-17 01:43 +0000
1"""GitHub API非同期クライアント
3学習目標:
4- GitHub REST API v3の実務的活用(Rate Limit管理、Conditional Requests)
5- 非同期HTTP通信の最適化(ETag活用)
6- API制約への対応戦略(認証なし60 req/h → 認証あり5000 req/h拡張可能設計)
7"""
9import asyncio
10import hashlib
11import itertools
12import json
13import re
14from datetime import UTC, datetime
15from types import TracebackType
16from typing import Any, NoReturn, Self, cast
17from urllib.parse import quote, urlencode
19import httpx
21from utils.api_client import (
22 ASYNC_FATAL_EXCEPTIONS,
23 APIClientError,
24 _log_error_with_stderr_fallback,
25 exponential_backoff_with_jitter,
26)
27from utils.logger import get_logger
29# モジュールレベル logger: ``@staticmethod`` (例: ``_cache_key``) など
30# ``self.logger`` を参照できない経路で構造化ログを出力するために使用する
31# (structlog のため同名 logger を返し、インスタンス側 ``self.logger`` と一貫。PR#347 #2-6)。
32_module_logger = get_logger(__name__)
34# =============================================================================
35# 入力バリデーション(OWASP A03:2021 - Injection対策)
36# =============================================================================
38# GitHub username仕様: 1-39文字、英数字・ハイフン、先頭は英数字
39GITHUB_USERNAME_PATTERN = re.compile(r"^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$")
40# GitHub repository名仕様: 1-100文字、英数字・ドット・ハイフン・アンダースコア
41GITHUB_REPO_PATTERN = re.compile(r"^[a-zA-Z0-9._-]{1,100}$")
42# ETag形式バリデーション(RFC 7232準拠: W/"..." または "...")
43_ETAG_PATTERN: re.Pattern[str] = re.compile(r'^(?:W/)?"[^"\r\n\\]*"$')
46def validate_github_username(username: str) -> None:
47 """GitHubユーザー名のバリデーション
49 Args:
50 username: GitHubユーザー名
52 Raises:
53 ValueError: バリデーション失敗
55 Note:
56 GitHub username仕様:
57 - 1-39文字
58 - 英数字、ハイフン(連続不可、先頭・末尾不可)
59 - 先頭は英数字
61 """
62 if not username or not GITHUB_USERNAME_PATTERN.match(username):
63 raise ValueError(f"Invalid GitHub username: '{username}'")
66def validate_github_repo(repo: str) -> None:
67 """GitHubリポジトリ名のバリデーション
69 "." と ".." は予約名として拒否する。
70 ".github" のようなドット始まりの名前はGitHub上で有効なため許可する。
72 Args:
73 repo: リポジトリ名
75 Raises:
76 ValueError: バリデーション失敗
78 """
79 if not repo or repo in {".", ".."} or not GITHUB_REPO_PATTERN.match(repo):
80 raise ValueError(f"Invalid GitHub repository name: '{repo}'")
83# =============================================================================
84# Rate Limit定数(フォールバック値・閾値)
85# =============================================================================
86_RATE_LIMIT_FALLBACK_REMAINING = 999 # 監視パス: ヘッダー不正値時、残量十分とみなす
87_RATE_LIMIT_WARNING_THRESHOLD = 10 # 残量10未満で警告ログ出力
88_RATE_LIMIT_FORBIDDEN_FALLBACK = -1 # 403判定パス: 不正値時はRate Limit超過と判定しない
89_RATE_LIMIT_RESET_FALLBACK = 0 # リセット時刻不明時のフォールバック
90_MAX_403_ERROR_MESSAGE_CHARS = 200 # 403 JSON message の上限文字数: ログ肥大・漏洩防止
91# HTTPStatusError body_preview の上限バイト数: ログ肥大・漏洩防止
92_MAX_HTTP_ERROR_BODY_PREVIEW_BYTES = 200
93# cache_invariant_violation logger.error 出力時のキーリスト上限件数 (PII漏洩防止)
94_MAX_CACHE_INVARIANT_LOG_KEYS = 5
97# =============================================================================
98# 例外クラス
99# =============================================================================
102def _redact_body_preview(body_preview: str) -> str:
103 """HTTP error response body preview をリダクション
105 エラー応答がトークン、API キー、private repository 名を含む場合に
106 stdout/debug logs へ機密情報が漏れるのを防止する。
107 内容を完全にマスクし、ハッシュベースの指紋を保持して debug に利用。
109 Args:
110 body_preview: デコード済みの response body
111 (先頭 _MAX_HTTP_ERROR_BODY_PREVIEW_BYTES バイトで切り詰め済み)
113 Returns:
114 リダクション済み文字列 (形式: "[redacted:SHA256_16chars]")
115 """
117 body_hash = hashlib.sha256(body_preview.encode("utf-8", errors="replace")).hexdigest()[:16]
118 return f"[redacted:{body_hash}]"
121class GitHubAPIError(APIClientError):
122 """GitHub API基底例外(APIClientErrorを継承し統一的なエラーハンドリングを実現)"""
125class _SanitizedJSONDecodeError(Exception):
126 """レスポンスbodyを保持しない JSONDecodeError cause。
128 ``msg`` は ``json.JSONDecodeError.msg``("Expecting value" /
129 "Unterminated string starting at" 等のパーサ診断文字列)を保持する。
130 これらは静的なパーサメッセージで PII(レスポンス body 由来データ)を
131 含まないため、破損 JSON の種別識別に利用できる(PR#347 SF-1)。
132 """
134 def __init__(self, error_type: str, msg: str, pos: int, lineno: int, colno: int) -> None:
135 self.error_type = error_type
136 self.msg = msg
137 self.pos = pos
138 self.lineno = lineno
139 self.colno = colno
140 super().__init__(f"{error_type}: {msg} pos={pos}, lineno={lineno}, colno={colno}")
142 def __reduce__(
143 self,
144 ) -> tuple[type[_SanitizedJSONDecodeError], tuple[str, str, int, int, int]]:
145 # pytest-xdist の worker→controller 例外転送や Sentry SDK のシリアライズで
146 # pickle される。非標準 __init__ シグネチャ(5 引数)は Exception 既定の
147 # __reduce__(args=単一メッセージ文字列で復元)では TypeError になるため、
148 # 全フィールドを渡す __reduce__ を明示する(PR#347 Q-2)。
149 return (self.__class__, (self.error_type, self.msg, self.pos, self.lineno, self.colno))
152class RateLimitError(GitHubAPIError):
153 """Rate Limit超過エラー(403/429)"""
155 def __init__(self, reset_time: int) -> None:
156 self.reset_time = reset_time
157 if reset_time > 0:
158 try:
159 reset_str = datetime.fromtimestamp(reset_time, tz=UTC).isoformat()
160 except (OverflowError, OSError): # fmt: skip
161 reset_str = f"unix:{reset_time}"
162 else:
163 reset_str = "unknown"
164 super().__init__(f"Rate limit exceeded. Reset at {reset_str}")
167class NotFoundError(GitHubAPIError):
168 """リソースが見つからない(404 Not Found)"""
171class GitHubServerError(GitHubAPIError):
172 """GitHub側のサーバーエラー(5xx)"""
175# =============================================================================
176# AsyncGitHubClient実装
177# =============================================================================
180class AsyncGitHubClient:
181 """GitHub API非同期クライアント
183 特徴:
184 - Rate Limit自動対応(X-RateLimit-Remaining監視)
185 - Conditional Requests対応(ETag活用)
186 - リトライロジック(5xx・timeout・NetworkError・RemoteProtocolError、指数バックオフ+ジッター)
187 - 例外チェーン(HTTPStatusError は URL+ステータスのみ保持した cause に再ラップ)
189 使用例:
190 >>> async with AsyncGitHubClient() as client:
191 ... user = await client.get_user("octocat")
192 ... print(user["name"]) # "The Octocat"
193 """
195 BASE_URL = "https://api.github.com"
197 def __init__(
198 self,
199 timeout: int = 30,
200 max_retries: int = 3,
201 user_agent: str = "AsyncGitHubClient/1.0",
202 max_cache_entries: int = 256,
203 ):
204 """AsyncGitHubClientの初期化
206 Args:
207 timeout: リクエストタイムアウト(秒)
208 max_retries: 最大試行回数
209 (5xx・timeout・NetworkError・RemoteProtocolError の再試行回数、初回含む)。
210 デフォルト設定(timeout=30, max_retries=3)での最悪ケース: 約96秒。
211 user_agent: User-Agentヘッダー(GitHub要求事項)
212 max_cache_entries: ETag/dataキャッシュの最大エントリ数(デフォルト256)
214 """
215 if max_cache_entries < 1:
216 raise ValueError("max_cache_entries must be >= 1")
217 self.timeout = timeout
218 self.max_retries = max_retries
219 self.user_agent = user_agent
220 self.max_cache_entries = max_cache_entries
221 self._client: httpx.AsyncClient | None = None
222 self._etag_cache: dict[str, str] = {} # cache_key (endpoint+sorted query) -> ETag
223 # cache_key -> response data(304レスポンス時のキャッシュ返却用)
224 self._data_cache: dict[str, dict[str, Any] | list[dict[str, Any]]] = {}
225 self.logger = get_logger(__name__)
227 async def _log_and_sleep_for_retry(
228 self,
229 *,
230 event: str,
231 error_context: str,
232 error: httpx.TimeoutException | httpx.NetworkError | httpx.RemoteProtocolError,
233 endpoint: str,
234 method: str,
235 attempt: int,
236 ) -> None:
237 """Retry 対象例外をログし、次の試行前に sleep する。
239 最終試行(attempt == max_retries - 1)では sleep を行わず error ログを出力して返る。
240 例外の raise は呼び出し元の責務。
242 Args:
243 event: structlog に渡すイベント名(例:"request_timeout")
244 error_context: ログの error_context フィールド値(例:"timeout")
245 error: キャッチした例外(TimeoutException、NetworkError、RemoteProtocolError)。
246 LocalProtocolError はクライアント側 protocol violation のため retry 対象外。
247 endpoint: リクエスト先エンドポイント(ログ用)
248 method: HTTP メソッド(ログ用)
249 attempt: 現在の試行インデックス(0-based)
250 """
251 self.logger.warning(
252 event,
253 endpoint=endpoint,
254 method=method,
255 error_type=type(error).__qualname__,
256 error_module=type(error).__module__,
257 error_context=error_context,
258 )
259 if attempt < self.max_retries - 1:
260 delay = exponential_backoff_with_jitter(attempt, base_delay=2.0)
261 await asyncio.sleep(delay)
262 return
263 self.logger.error(
264 "github_retry_failed",
265 endpoint=endpoint,
266 method=method,
267 error_type=type(error).__qualname__,
268 error_module=type(error).__module__,
269 error_context=error_context,
270 max_retries=self.max_retries,
271 status_code=None, # timeout/network error はステータスコードなし
272 )
274 async def __aenter__(self) -> Self:
275 """非同期コンテキストマネージャーのエントリー"""
276 self._client = httpx.AsyncClient(
277 base_url=self.BASE_URL,
278 timeout=self.timeout,
279 headers={
280 "Accept": "application/vnd.github+json",
281 "User-Agent": self.user_agent,
282 },
283 )
284 return self
286 async def _close_async_client(
287 self,
288 body_exc_type: type[BaseException] | None,
289 *,
290 suppress_unexpected: bool = False,
291 ) -> None:
292 """``__aexit__`` と ``aclose()`` で共有する close 処理.
294 AsyncAPIClient._close_async_client() と対称の集約ヘルパー。AsyncGitHubClient は
295 AsyncAPIClient のヘルパーを継承しない(別クラス階層)ため自前で定義し、
296 ``__aexit__`` / ``aclose()`` 間の close ロジック重複を解消する(DRY, PR#347)。
298 Args:
299 body_exc_type: ``__aexit__`` 経路では body 内で発生した例外型(無しなら ``None``)。
300 ``aclose()`` 直接呼び出し経路では常に ``None``。予期しない close 例外捕捉時、
301 ``has_body_exception = body_exc_type is not None`` を判定材料とし、body 例外の
302 上書き防止のため ``None`` の場合のみ実装バグとして bare ``raise`` で再送出する。
303 suppress_unexpected: ``True`` の場合、予期しない close 例外を error ログのみ記録して
304 握りつぶす(re-raise しない)。``aclose()`` で ``True`` を渡し finally ブロック等
305 での安全な呼び出しを保証する。``__aexit__`` 経路では ``False``(デフォルト)のまま
306 ``has_body_exception`` ロジックによる従来の re-raise 判定を維持する。
307 """
308 if self._client is None:
309 return
310 try:
311 await self._client.aclose()
312 except (httpx.CloseError, OSError) as close_exc:
313 # 既知のクローズ時例外 — warning のみ(body 例外を上書きしない)。
314 # error_type + error_module で third-party 例外の起点モジュールを識別可能にする。
315 self._client = None
316 self.logger.warning(
317 "async_github_client_aclose_failed",
318 error_type=type(close_exc).__name__,
319 error_module=type(close_exc).__module__,
320 )
321 except ASYNC_FATAL_EXCEPTIONS:
322 # システム致命例外(MemoryError/RecursionError/KeyboardInterrupt/SystemExit/
323 # asyncio.CancelledError)は再raise して fail-fast を維持する。MemoryError/
324 # RecursionError は Exception 派生のため、明示捕捉しないと下流の except Exception に
325 # (has_body_exception=True 時)捕捉されサイレント隠蔽される。KeyboardInterrupt/
326 # SystemExit/CancelledError は BaseException 直系で except Exception の境界外(素通り)
327 # だが、_client=None 設定の一貫性のため本句で先取りする。
328 # NOTE: 本クラスは _request パス(utils.api_client の ASYNC_FATAL_EXCEPTIONS 方針)と
329 # 同一の定数を close 経路でも使用する。一方 AsyncAPIClient._close_async_client は
330 # close 文脈で (MemoryError, RecursionError) のみを使う別方針(CancelledError 等は
331 # BaseException 直系として素通りさせる設計)であり、両クラスの close ヘルパーは
332 # この点で対称ではない(PR#347 Q-4)。
333 # 他の close 経路(CloseError/OSError 句・else 節)と対称に _client=None を設定し、
334 # 再呼び出し時のダブル aclose を防ぐ(PR#347 CQ-1)。
335 self._client = None
336 raise
337 except Exception as close_exc: # noqa: BLE001
338 # 予期しない例外(AttributeError, RuntimeError 等の実装バグ可能性)。
339 # RecursionError / MemoryError は上の ASYNC_FATAL_EXCEPTIONS 句で
340 # 先取り済み(fail-fast)。
341 # logger.error 記録 + 失敗時 stderr フォールバック。ロガー自体の例外が close_exc /
342 # body 例外を隠蔽するのを防ぐ(PR#347 B-3)。api_client と共通の module-level
343 # ヘルパーで stderr フォールバックの重複を解消する(PR#347 Q-8 DRY)。
344 if suppress_unexpected:
345 # aclose() 直接呼び出し経路: finally ブロック等での安全な呼び出しを保証するため、
346 # 伝播中の例外を上書きしないよう常に抑制し error ログで本番監視対象にする
347 # (PR#347 SF-3)。suppress_unexpected=True は has_body_exception より優先評価。
348 # suppress 経路でも状態一貫性のため _client=None を設定し、aclose 失敗後の
349 # 壊れたクライアント再利用を防止する(成功時 else 節と同一方針)。
350 self._client = None
351 _log_error_with_stderr_fallback(
352 self.logger,
353 "github_client",
354 "aclose",
355 close_exc,
356 "async_github_client_aclose_unexpected_error",
357 error_type=type(close_exc).__name__,
358 error_module=type(close_exc).__module__,
359 action="suppressed_standalone_aclose",
360 exc_info=True, # スタックトレースをログに残す
361 )
362 else:
363 # __aexit__ 経路(context manager): 従来の has_body_exception ロジックを維持。
364 # SF-2: close_exc は body 例外を上書きしないため __context__ チェーンは切断される。
365 # 代わりに body 例外の型名(body_exception_type)を同一ログイベント内に記録し、
366 # close 失敗と body 例外の対応関係を追跡可能にする(PII 非含: __qualname__ は
367 # クラス名のみ)。本経路は _client=None を設定しない(従来 __aexit__ 挙動を保持)。
368 has_body_exception = body_exc_type is not None
369 _log_error_with_stderr_fallback(
370 self.logger,
371 "github_client",
372 "aclose",
373 close_exc,
374 "async_github_client_aclose_unexpected_error",
375 error_type=type(close_exc).__name__,
376 error_module=type(close_exc).__module__,
377 has_body_exception=has_body_exception,
378 action=(
379 "suppressed_due_to_body_exception" if has_body_exception else "re_raised"
380 ),
381 body_exception_type=(
382 body_exc_type.__qualname__ if body_exc_type is not None else None
383 ),
384 exc_info=True, # スタックトレースをログに残す
385 )
386 # body 例外がない場合のみ実装バグとして re-raise。body 例外がある場合は本質的
387 # 原因の上書きを防ぐため raise しない。bare ``raise`` で active exception の
388 # traceback を完全保持(``raise close_exc`` への回帰防止: 余分な frame 不追加)。
389 if not has_body_exception:
390 raise
391 else:
392 # ダブルクローズ防止: logger.info より先に None をセットする。logger.info が例外を
393 # 投げても _client=None が確定済みのため再呼び出し時のガードで空振りし aclose 二重
394 # 実行を防ぐ(AsyncAPIClient._close_async_client と同一順序, PR#347 B-2)。
395 self._client = None
396 self.logger.info("async_github_client_closed")
398 async def __aexit__(
399 self,
400 exc_type: type[BaseException] | None,
401 exc_val: BaseException | None,
402 exc_tb: TracebackType | None,
403 ) -> None:
404 """非同期コンテキストマネージャーの終了処理。"""
405 # close ロジックは _close_async_client() ヘルパーに集約(AsyncAPIClient と対称)。
406 # body 例外型を渡し、has_body_exception ロジックで close 例外の re-raise を判定する。
407 await self._close_async_client(exc_type)
409 async def aclose(self) -> None:
410 """明示的な非同期クローズ(``async with`` を使わない finally 解放経路用).
412 ``AsyncAPIClient.aclose()`` と対称。``__aexit__`` と同一の close ロジックを
413 ``_close_async_client()`` ヘルパーに集約済み。body 例外コンテキストを持たないため
414 ``body_exc_type=None`` を渡し、予期しない close 例外は ``suppress_unexpected=True`` で
415 握りつぶす(finally ブロック内で他例外を上書きしないため)。全経路で ``_client=None``
416 を設定し、再呼び出し時のダブル aclose を防ぐ。
417 """
418 await self._close_async_client(None, suppress_unexpected=True)
420 async def get_user(self, username: str) -> dict[str, Any]:
421 """ユーザー情報取得
423 Args:
424 username: GitHubユーザー名
426 Returns:
427 ユーザー情報(name, bio, public_repos等)
429 Raises:
430 ValueError: 無効なユーザー名
431 NotFoundError: ユーザーが存在しない
432 RateLimitError: 403 Rate Limit超過 または 429 Too Many Requests
433 GitHubServerError: 5xxエラー(リトライ上限後)
434 GitHubAPIError: タイムアウト・NetworkError・RemoteProtocolError
435 リトライ上限後の最終失敗、または不正なレスポンス型
436 パラメータシリアライズ失敗(リトライなし)
438 Example:
439 >>> async with AsyncGitHubClient() as client:
440 ... user = await client.get_user("octocat")
441 ... print(user["name"]) # "The Octocat"
443 """
444 validate_github_username(username)
445 result = await self._request("GET", f"/users/{username}")
446 if not isinstance(result, dict):
447 raise GitHubAPIError(f"Expected dict response, got {type(result).__name__}")
448 return result
450 async def get_repos(
451 self,
452 username: str,
453 sort: str = "updated",
454 per_page: int = 30,
455 ) -> list[dict[str, Any]]:
456 """ユーザーのリポジトリ一覧取得
458 Args:
459 username: GitHubユーザー名
460 sort: ソート順(created, updated, pushed, full_name)
461 per_page: 1ページあたりの件数(最大100)
463 Returns:
464 リポジトリ情報リスト
466 Raises:
467 ValueError: 無効なユーザー名
468 RateLimitError: 403 Rate Limit超過 または 429 Too Many Requests
469 NotFoundError: リソースが見つからない場合
470 GitHubServerError: 5xxエラー(リトライ上限後)
471 GitHubAPIError: タイムアウト・NetworkError・RemoteProtocolError
472 リトライ上限後の最終失敗、または不正なレスポンス型
473 パラメータシリアライズ失敗(リトライなし)
475 Example:
476 >>> repos = await client.get_repos("octocat", sort="updated")
477 >>> print(repos[0]["name"]) # 最新更新のリポジトリ
479 """
480 validate_github_username(username)
481 if sort not in {"created", "updated", "pushed", "full_name"}:
482 raise ValueError("sort must be one of: created, updated, pushed, full_name")
483 if not 1 <= per_page <= 100:
484 raise ValueError("per_page must be between 1 and 100")
485 params: dict[str, str | int] = {"sort": sort, "per_page": per_page}
486 result = await self._request("GET", f"/users/{username}/repos", params=params)
487 if not isinstance(result, list):
488 raise GitHubAPIError(f"Expected list response, got {type(result).__name__}")
489 return result
491 async def get_repo(self, owner: str, repo: str) -> dict[str, Any]:
492 """リポジトリ詳細取得
494 Args:
495 owner: オーナー名
496 repo: リポジトリ名
498 Returns:
499 リポジトリ詳細(stars, forks, open_issues等)
501 Raises:
502 ValueError: 無効なオーナー名またはリポジトリ名
503 RateLimitError: 403 Rate Limit超過 または 429 Too Many Requests
504 NotFoundError: リソースが見つからない場合
505 GitHubServerError: 5xxエラー(リトライ上限後)
506 GitHubAPIError: タイムアウト・NetworkError・RemoteProtocolError
507 リトライ上限後の最終失敗、または不正なレスポンス型
509 Example:
510 >>> repo = await client.get_repo("octocat", "Hello-World")
511 >>> print(repo["stargazers_count"]) # スター数
513 """
514 validate_github_username(owner)
515 validate_github_repo(repo)
516 result = await self._request("GET", f"/repos/{owner}/{repo}")
517 if not isinstance(result, dict):
518 raise GitHubAPIError(f"Expected dict response, got {type(result).__name__}")
519 return result
521 def _parse_rate_limit_header(self, headers: httpx.Headers, name: str, default: int) -> int:
522 """Rate Limitヘッダーを安全にパースする。
524 Args:
525 headers: HTTPレスポンスヘッダー
526 name: ヘッダー名(例: "X-RateLimit-Remaining")
527 default: パース失敗時のフォールバック値
529 Returns:
530 パースされた整数値。ヘッダー未設定またはパース失敗時は default を返す。
532 Note:
533 ValueErrorをキャッチし、warningログを出力してフォールバック値を返す。
534 """
535 raw = headers.get(name)
536 if raw is None:
537 return default
538 try:
539 return int(raw)
540 except ValueError:
541 self.logger.warning(
542 "invalid_rate_limit_header",
543 header=name,
544 value=repr(raw)[:100],
545 )
546 return default
548 def _prepare_headers(self, cache_key: str) -> dict[str, str]:
549 """ETagキャッシュが存在する場合に If-None-Match ヘッダーを含む dict を返す。
551 Conditional Requests対応。
552 """
553 headers: dict[str, str] = {}
554 if cache_key in self._etag_cache:
555 headers["If-None-Match"] = self._etag_cache[cache_key]
556 return headers
558 def _check_rate_limit_warning(
559 self,
560 response_headers: httpx.Headers,
561 remaining: int,
562 ) -> int | None:
563 """RateLimit残量が閾値未満の場合に警告ログを出力する。
565 Args:
566 response_headers: HTTPレスポンスヘッダー(X-RateLimit-Reset を参照)
567 remaining: 残りAPIコール数
569 Returns:
570 remaining が閾値未満の場合は X-RateLimit-Reset のエポック秒(int)を返す。
571 閾値以上の場合は None を返す。
572 返却値は呼び出し元で Rate Limit エラー処理用 reset_time としても使用される。
573 """
574 if remaining < _RATE_LIMIT_WARNING_THRESHOLD:
575 # 戻り値 >= 1: 有効な Unix タイムスタンプ(rate limit warning 発生時)
576 reset_time = self._parse_rate_limit_header(
577 response_headers, "X-RateLimit-Reset", _RATE_LIMIT_RESET_FALLBACK
578 )
579 # 異常に大きい reset_time では OverflowError/OSError が発生する場合がある。
580 # RateLimitError.__init__(L127-130)と同じパターンで保護し、
581 # 警告ログの継続出力を保証する。
582 try:
583 reset_str = datetime.fromtimestamp(reset_time, tz=UTC).isoformat()
584 except (OverflowError, OSError): # fmt: skip
585 reset_str = f"unix:{reset_time}"
586 self.logger.warning(
587 "rate_limit_low",
588 remaining=remaining,
589 reset_time=reset_str,
590 )
591 return reset_time
592 return None
594 def _handle_304_response(self, cache_key: str) -> dict[str, Any] | list[dict[str, Any]]:
595 """304 Not Modified: キャッシュデータを返却する。キャッシュミス時はエラー。"""
596 if cache_key in self._data_cache:
597 return self._data_cache[cache_key]
598 # キャッシュミス時(理論上発生しない: ETagあり=キャッシュあり)
599 # Fail-fast: キャッシュ不整合は実装バグの証拠
600 # endpoint_only: クエリパラメータを除去してデバッグ可能性を確保しつつ機密パラメータを非露出
601 endpoint_only = cache_key.split("?")[0]
602 self.logger.error(
603 "cache_miss_on_304",
604 endpoint=endpoint_only,
605 hint="ETag存在時のキャッシュミスは実装バグ",
606 etag=self._etag_cache.get(cache_key),
607 )
608 raise GitHubAPIError(
609 f"Cache inconsistency: 304 response without cached data for {endpoint_only}"
610 )
612 def _handle_403_response(
613 self,
614 response: httpx.Response,
615 *,
616 rate_remaining: int | None = None,
617 reset_time: int | None = None,
618 ) -> NoReturn:
619 """403エラー処理: Rate Limit超過 vs その他の403を判別して raise する。"""
620 # フォールバック -1 = 不正値時はRate Limit超過と判定せずGitHubAPIErrorへ
621 if rate_remaining is None:
622 rate_remaining = self._parse_rate_limit_header(
623 response.headers, "X-RateLimit-Remaining", _RATE_LIMIT_FORBIDDEN_FALLBACK
624 )
625 if rate_remaining == 0:
626 # Rate Limit超過確定
627 if reset_time is None:
628 reset_time = self._parse_rate_limit_header(
629 response.headers, "X-RateLimit-Reset", _RATE_LIMIT_RESET_FALLBACK
630 )
631 raise RateLimitError(reset_time) from None
632 # その他の403エラー(IPブロック、アクセス権限不足等)
633 error_message = ""
634 try:
635 parsed = response.json()
636 if isinstance(parsed, dict): 636 ↛ 655line 636 didn't jump to line 655 because the condition on line 636 was always true
637 raw_message = parsed.get("message", "")
638 if isinstance(raw_message, str):
639 error_message = raw_message[:_MAX_403_ERROR_MESSAGE_CHARS]
640 except json.JSONDecodeError as parse_err:
641 # JSONパース失敗は想定内(GitHub APIが非JSON形式で403を返す場合がある)
642 # PII漏洩防止: parse_err.doc はレスポンスbody全体を保持するため、
643 # ログのみ記録し active exception context の外で raise して __context__ を None に保つ。
644 self.logger.warning(
645 "failed_to_parse_403_message",
646 error_type=type(parse_err).__qualname__,
647 error_module=type(parse_err).__module__,
648 error_pos=parse_err.pos,
649 error_lineno=parse_err.lineno,
650 )
652 # JSONDecodeError.doc はレスポンスbody全体を保持する。
653 # except 外で raise して __context__ を None に保つ(_parse_json_response と同方針)。
654 # JSONDecodeError パス(error_message="")も正常パスも同一の raise で処理。
655 raise GitHubAPIError(
656 f"Access forbidden: {error_message}" if error_message else "Access forbidden"
657 ) from None
659 async def _handle_5xx_response(
660 self,
661 response: httpx.Response,
662 attempt: int,
663 endpoint: str,
664 method: str,
665 ) -> None:
666 """5xxエラーのリトライ制御。最終試行なら raise、継続なら return する。
668 リトライ継続時は return し、呼び出し元が continue する。
670 Raises:
671 GitHubServerError: リトライ上限到達
672 """
673 if attempt < self.max_retries - 1:
674 delay = exponential_backoff_with_jitter(attempt, base_delay=2.0)
675 self.logger.warning(
676 "retrying_server_error",
677 attempt=attempt + 1,
678 max_retries=self.max_retries,
679 delay=delay,
680 status_code=response.status_code,
681 endpoint=endpoint,
682 method=method,
683 )
684 await asyncio.sleep(delay)
685 return
686 self.logger.error(
687 "github_retry_failed",
688 endpoint=endpoint,
689 method=method,
690 error_type=f"HTTP_{response.status_code}",
691 error_module="httpx",
692 error_context=f"{method} {endpoint}",
693 max_retries=self.max_retries,
694 status_code=response.status_code,
695 )
696 raise GitHubServerError(
697 f"Server error: {response.status_code} after {self.max_retries} attempts",
698 ) from None
700 def _parse_json_response(
701 self,
702 response: httpx.Response,
703 endpoint: str,
704 ) -> dict[str, Any] | list[dict[str, Any]]:
705 """JSONレスポンスをパースする。破損JSON時はGitHubAPIErrorを発生。"""
706 _sanitized_cause: _SanitizedJSONDecodeError | None = None
707 try:
708 return cast(
709 "dict[str, Any] | list[dict[str, Any]]",
710 response.json(),
711 )
712 except json.JSONDecodeError as e:
713 self.logger.error(
714 "json_decode_error",
715 endpoint=endpoint,
716 error_type=type(e).__qualname__,
717 error_module=type(e).__module__,
718 error_pos=e.pos,
719 error_lineno=e.lineno,
720 )
721 # JSONDecodeError.doc はレスポンスbody全体を保持する。
722 # doc を除外し、型情報と位置情報のみを保持する sanitized cause を作成。
723 # except 内で raise すると __context__ に元例外が残存して露出するため、
724 # active exception context の外で raise して __context__ を None に保つ。
725 _sanitized_cause = _SanitizedJSONDecodeError(
726 f"{type(e).__module__}.{type(e).__qualname__}",
727 e.msg,
728 e.pos,
729 e.lineno,
730 e.colno,
731 )
732 raise GitHubAPIError("Invalid JSON response") from _sanitized_cause
734 def _handle_http_status_error(
735 self,
736 response: httpx.Response,
737 endpoint: str,
738 method: str,
739 ) -> NoReturn:
740 """HTTPエラーレスポンスを適切な例外に変換して raise する。
742 404/429/403 は専用例外 (NotFoundError/RateLimitError/GitHubAPIError) に変換し、
743 それ以外の 4xx/5xx は GitHubAPIError に変換する。
745 通常フローでは 404/429/403/5xx は _request の main path で先行処理済みのため、
746 本メソッドは主に httpx.HTTPStatusError defensive path (except 外) から呼ばれる。
747 401/400/405 等の other 4xx はこのメソッドのみで処理される。
749 設計上の制約: `from None` を使用し __cause__ を None に設定する。
750 理由: 全 PII 回避パス(timeout/unexpected/NotFound/RateLimit/JSONDecode)と統一し、
751 呼び出し元が __cause__ を参照する際の分岐を不要とするため。
752 診断情報は構造化ログの endpoint フィールドで取得可能なため __cause__ への記録は不要。
753 コーディング規約 Section 5「from e でチェーン維持」の意図的例外(PII漏洩防止優先)。
755 response/endpoint/method を直接受け取る理由: 呼び出し元が except 外で呼ぶことで
756 __context__ に PII含有オブジェクトが残存しないよう設計(PII漏洩防止)。
757 """
758 status_code = response.status_code
760 # 404: NotFoundError に変換(通常パスと等価)
761 if status_code == 404:
762 raise NotFoundError(f"Resource not found: {endpoint}") from None
764 # 429: RateLimitError に変換(通常パスと等価)
765 if status_code == 429:
766 reset_time = self._parse_rate_limit_header(
767 response.headers, "X-RateLimit-Reset", _RATE_LIMIT_RESET_FALLBACK
768 )
769 raise RateLimitError(reset_time) from None
771 # 403: Rate Limit超過 vs その他 403 を詳細分析して raise(通常パスと等価)
772 if status_code == 403:
773 remaining = self._parse_rate_limit_header(
774 response.headers, "X-RateLimit-Remaining", _RATE_LIMIT_FORBIDDEN_FALLBACK
775 )
776 reset_time_403 = (
777 self._parse_rate_limit_header(
778 response.headers, "X-RateLimit-Reset", _RATE_LIMIT_RESET_FALLBACK
779 )
780 if remaining == 0
781 else None
782 )
783 # _handle_403_response は NoReturn(全経路で必ず raise)のため後続は到達不能。
784 # 防御 assert は置かず NoReturn 型契約に委ねる(mypy が非 raise 経路を静的に
785 # 検出するため契約違反は型検査で捕捉される)。同関数を呼ぶ警告経路と防御
786 # パターンを統一する(PR#347 #2-7: 旧 `raise AssertionError("unreachable")` 削除)。
787 self._handle_403_response(response, rate_remaining=remaining, reset_time=reset_time_403)
789 # ログレベル別出力先
790 # warning(401) → LOG__LEVEL=ERROR未満の全設定でstdoutに出力(デフォルトINFO含む)
791 # debug(404等) → LOG__LEVEL=DEBUGの場合のみstdoutに出力
792 # いずれもSentry非送信: make_filtering_bound_logger によりDEBUG/WARNING は
793 # Sentry送信前にフィルタ済み(参照: utils/logger.py L160, _sentry_processor L52-54)
794 body_preview_raw = response.content[:_MAX_HTTP_ERROR_BODY_PREVIEW_BYTES].decode(
795 response.encoding or "utf-8",
796 errors="replace",
797 )
798 # 401 (Unauthorized) は認証エラーのため warning、その他の想定内エラーは debug に抑制
799 log_fn = self.logger.warning if status_code == 401 else self.logger.debug
800 log_fn(
801 "http_status_error",
802 status_code=status_code,
803 endpoint=endpoint,
804 method=method,
805 body_preview=_redact_body_preview(body_preview_raw),
806 )
807 raise GitHubAPIError(f"HTTP {status_code} error") from None
809 def _update_etag_cache(
810 self,
811 cache_key: str,
812 response: httpx.Response,
813 result_json: dict[str, Any] | list[dict[str, Any]],
814 ) -> None:
815 """ETagとデータキャッシュを同時更新する。
817 asyncio シングルスレッド環境のため競合は発生しない。
819 挿入順序(挿入前退避方式):
820 1. 既存キーを both dict から削除して挿入順を更新する。
821 2. _enforce_cache_limit(reserve=1) で挿入前に退避し、新規 1 件分の余地を確保する。
822 3. data→ETag の順で保存する(挿入後も上限を超えない, PR#347 review #9)。
824 例外発生時は「dataあり/ETagなし」の一時状態になりうるが、ETagなしなら次回は
825 通常リクエスト(304非使用)となり安全に回復する。
826 「ETagあり/dataなし」はETagが最後に書き込まれるため物理的に発生しない。
827 """
828 if "ETag" in response.headers:
829 etag = response.headers["ETag"]
830 # ETag形式バリデーション(RFC 7232準拠: W/"..." または "...")
831 if not _ETAG_PATTERN.match(etag):
832 self.logger.warning(
833 "invalid_etag_format",
834 endpoint=cache_key.split("?")[0],
835 etag_prefix=etag[:20] if len(etag) > 20 else etag,
836 )
837 # 無効ETag受信時は既存キャッシュを破棄(次回リクエストで304再利用を防止)
838 self._etag_cache.pop(cache_key, None)
839 self._data_cache.pop(cache_key, None)
840 return
841 self._etag_cache.pop(cache_key, None)
842 self._data_cache.pop(cache_key, None)
843 # 挿入前に reserve=1 で退避し、挿入後もエントリ数が max_cache_entries を
844 # 超えないようにする。挿入後 enforce では瞬間的に max+1 件になるため、
845 # 新規エントリ 1 件分の余地を空けてから挿入する (PR#347 review #9)。
846 self._enforce_cache_limit(reserve=1)
847 self._data_cache[cache_key] = result_json
848 self._etag_cache[cache_key] = etag
849 else:
850 if cache_key in self._etag_cache or cache_key in self._data_cache:
851 self.logger.info("etag_removed", endpoint=cache_key.split("?")[0])
852 self._etag_cache.pop(cache_key, None)
853 self._data_cache.pop(cache_key, None)
855 @staticmethod
856 def _cache_key(endpoint: str, params: dict[str, str | int] | None = None) -> str:
857 """エンドポイントとクエリパラメータからキャッシュキーを生成する。
859 params が None または空の場合は endpoint をそのまま返す。
860 params={} は httpx の仕様(空クエリ = クエリなし)に従い、
861 params=None と同一のキャッシュキーを生成する。
862 params がある場合は ``endpoint?key1=val1&key2=val2`` 形式で返す。
863 URLエンコードには ``quote_via=quote`` を使用する(スペースは ``%20``)。
864 パラメータはキーでソートされ決定論的なキーを生成する。
866 Args:
867 endpoint: APIエンドポイントパス(例: ``/users/octocat/repos``)。
868 params: クエリパラメータ辞書(None可)
870 Returns:
871 キャッシュキー文字列
873 """
874 if not params:
875 return endpoint
876 try:
877 sorted_params = sorted((k, str(v)) for k, v in params.items())
878 return f"{endpoint}?{urlencode(sorted_params, quote_via=quote)}"
879 except (TypeError, UnicodeEncodeError) as e:
880 # PR#347 review #4-[8]: 通常パスの try/except 外で実行されるため、
881 # 例外型のみ含む GitHubAPIError に変換して呼び出し元のリトライ/エラー
882 # ハンドリング体系に統合。params 値は PII 含有可能性があるため
883 # ``from None`` で例外チェーンを切断し、エラーメッセージに含めない。
884 # 観測可能性のため endpoint と例外型のみを構造化ログに記録する。
885 # params の値は記録しない(PII 非露出。PR#347 #2-6)。
886 _module_logger.warning(
887 "cache_key_build_failed",
888 endpoint=endpoint,
889 error_type=type(e).__name__,
890 )
891 raise GitHubAPIError(
892 f"cache_key build failed for endpoint={endpoint!r}: {type(e).__name__}"
893 ) from None
895 def _enforce_cache_limit(self, reserve: int = 0) -> None:
896 """ETag/dataキャッシュを ``max_cache_entries - reserve`` 以下に保つ。
898 Args:
899 reserve: 直後に挿入する新規エントリ数の予約枠(デフォルト 0)。
900 ``_update_etag_cache`` は挿入前に ``reserve=1`` で呼び出すことで、
901 挿入後もエントリ数が ``max_cache_entries`` を超えない(瞬間的な
902 max+1 を防止, PR#347 review #9)。``max_cache_entries >= 1``
903 かつ ``reserve in (0, 1)`` のため退避目標は常に 0 以上。
905 _update_etag_cache は _etag_cache と _data_cache を常にペアで書き込むため、
906 _etag_cache のみを基準に古いエントリを削除すれば両キャッシュの整合性が保たれる。
908 Invariant 違反検出時は logger.error + 両キャッシュ clear で safe-fallback する。
909 ``assert`` 文は ``python -O`` モードで silent disable されるため production では使わない。
911 Invariant 判定は ``dict.keys()`` の集合等価比較 (defense-in-depth)。
912 ``len`` だけでは「同件数だがキー集合が異なる」状態 (例: 1 件抜けて 1 件余分) を
913 検出できないため、set-equality でキー差異も検出する。
915 Note: After clearing both caches (invariant violation), all subsequent requests will
916 hit the API without cache, potentially causing rate limit spikes in the short term.
917 """
918 # O(1) fast path before O(n) set comparison:
919 # サイズが異なれば invariant 違反確定
920 # (同件数だがキー集合が異なる場合は下記 set-equality で検出)。
921 invariant_violated = (
922 len(self._etag_cache) != len(self._data_cache)
923 or self._etag_cache.keys() != self._data_cache.keys()
924 )
925 if invariant_violated:
926 # PII漏洩防止 (PR#347 review #3-2): キーパスは GitHub API endpoint
927 # (例: /users/octocat, /repos/owner/name) を含み、SENSITIVE_KEYS の
928 # redact 対象外。logger.error → Sentry 送信時のログ肥大・PII露出を
929 # 抑えるため _MAX_CACHE_INVARIANT_LOG_KEYS 件に制限する。
930 # query string は split("?")[0] で除去済み。
931 etag_only_cache_keys = self._etag_cache.keys() - self._data_cache.keys()
932 data_only_cache_keys = self._data_cache.keys() - self._etag_cache.keys()
933 etag_only_count = len(etag_only_cache_keys)
934 data_only_count = len(data_only_cache_keys)
935 etag_only_keys = sorted(k.split("?")[0] for k in etag_only_cache_keys)[
936 :_MAX_CACHE_INVARIANT_LOG_KEYS
937 ]
938 data_only_keys = sorted(k.split("?")[0] for k in data_only_cache_keys)[
939 :_MAX_CACHE_INVARIANT_LOG_KEYS
940 ]
941 # 通常フローでは発生しない。発生した場合は実装バグの兆候として
942 # Sentry に捕捉される logger.error を出力し、両キャッシュを clear して
943 # 次回リクエストの fresh fetch に倒す(user request flow は維持)。
944 try:
945 self.logger.error(
946 "cache_invariant_violation",
947 etag_cache_size=len(self._etag_cache),
948 data_cache_size=len(self._data_cache),
949 etag_only_keys=etag_only_keys,
950 data_only_keys=data_only_keys,
951 etag_only_keys_truncated=etag_only_count > _MAX_CACHE_INVARIANT_LOG_KEYS,
952 data_only_keys_truncated=data_only_count > _MAX_CACHE_INVARIANT_LOG_KEYS,
953 action="cleared_both_caches",
954 )
955 except Exception: # noqa: BLE001, S110
956 # logger 自体が例外を投げても両キャッシュ clear を必ず実行する。
957 # clear をスキップすると invariant 違反が継続し、外側 _update_etag_cache の
958 # except に etag_cache_update_failed として埋没する(PR#347 B-3 fail-closed)。
959 pass
960 self._etag_cache.clear()
961 self._data_cache.clear()
962 return # clear() によりサイズ 0 → max_cache_entries 制限は達成済み(while ループ不要)
963 excess = len(self._etag_cache) - (self.max_cache_entries - reserve)
964 if excess > 0:
965 # 削除件数を事前計算し islice でまとめて取得(毎反復 len() 再計算を回避, PR#347)。
966 keys_to_evict = list(itertools.islice(self._etag_cache, excess))
967 for key in keys_to_evict:
968 self._etag_cache.pop(key, None)
969 self._data_cache.pop(key, None)
970 self.logger.info(
971 "cache_entries_evicted",
972 evicted_count=excess,
973 current_size=len(self._etag_cache),
974 max_size=self.max_cache_entries,
975 )
977 async def _request( # noqa: C901 - HTTPプロトコル処理の最小必要分岐(4xxステータス, 5xxリトライ, タイムアウト, キャンセル等)のため許容 CC≈12
978 self,
979 method: str,
980 endpoint: str,
981 params: dict[str, str | int] | None = None,
982 ) -> dict[str, Any] | list[dict[str, Any]]:
983 """内部リクエストメソッド
985 機能:
986 - Rate Limit監視(X-RateLimit-Remaining < _RATE_LIMIT_WARNING_THRESHOLD で警告ログ)
987 - Conditional Requests(ETag活用、304 Not Modified対応)
988 - 5xx・timeout・NetworkError・RemoteProtocolError リトライ(指数バックオフ+ジッター)
989 - 4xxエラー即失敗(NotFoundError, RateLimitError例外)
990 - 例外情報の安全な保持(HTTPStatusError は URL+ステータスのみ保持した cause に再ラップ、response body 非露出)
992 Args:
993 method: HTTPメソッド(GET, POST等)
994 endpoint: APIエンドポイント
995 params: クエリパラメータ
997 Returns:
998 JSONレスポンス(dict or list[dict])
1000 Raises:
1001 RuntimeError: クライアント未初期化(`async with` 未使用)
1002 NotFoundError: 404エラー
1003 RateLimitError: 403 Rate Limit超過 または 429 Too Many Requests
1004 GitHubServerError: 5xxエラー(リトライ上限後)
1005 GitHubAPIError: タイムアウト・NetworkError・RemoteProtocolError は再試行後の最終失敗、
1006 予期しないエラーは即失敗
1008 Note:
1009 max_retries は 5xx エラーと timeout / NetworkError / RemoteProtocolError の試行回数(初回含む合計)を制御する。
1010 unexpected エラーは再試行せず、1回目で GitHubAPIError へ変換する。
1011 X-RateLimit-Remaining ヘッダーが不正値の場合:
1012 - 監視パス: _RATE_LIMIT_FALLBACK_REMAINING(残量十分と見なし、rate_limit_low警告なし)
1013 - 403判定パス: _RATE_LIMIT_FORBIDDEN_FALLBACK(Rate Limit超過と判定せず、GitHubAPIError発生)
1014 いずれのパスでも不正値(ValueError)検出時は
1015 invalid_rate_limit_header warningを出力する。
1016 なお、ヘッダー自体が未設定(None)の場合は
1017 warningを出力せずフォールバック値を返す。
1018 """ # noqa: E501
1019 if self._client is None:
1020 raise RuntimeError("Client not initialized. Use 'async with' context.")
1022 cache_key = self._cache_key(endpoint, params)
1023 headers = self._prepare_headers(cache_key)
1025 for attempt in range(self.max_retries): 1025 ↛ 1203line 1025 didn't jump to line 1203 because the loop on line 1025 didn't complete
1026 retry_error_message: str | None = None
1027 unexpected_error_type: str | None = None
1028 http_status_response: httpx.Response | None = None
1029 try:
1030 response = await self._client.request(
1031 method,
1032 endpoint,
1033 params=params,
1034 headers=headers,
1035 )
1037 # Rate Limit監視
1038 remaining = self._parse_rate_limit_header(
1039 response.headers, "X-RateLimit-Remaining", _RATE_LIMIT_FALLBACK_REMAINING
1040 )
1041 warning_reset_time = self._check_rate_limit_warning(response.headers, remaining)
1043 # ステータスコード処理
1044 if response.status_code == 304:
1045 return self._handle_304_response(cache_key)
1047 if response.status_code == 404:
1048 raise NotFoundError(f"Resource not found: {endpoint}") from None
1050 # 通常パス: raise_for_status()より前に429を検出してRateLimitErrorに変換
1051 if response.status_code == 429:
1052 reset_time = (
1053 warning_reset_time
1054 if warning_reset_time is not None
1055 else self._parse_rate_limit_header(
1056 response.headers, "X-RateLimit-Reset", _RATE_LIMIT_RESET_FALLBACK
1057 )
1058 )
1059 # PII漏洩防止: 例外チェーン経由の httpx URL/header 露出を抑制
1060 # (defensive path との等価性維持: 403→RateLimitError 変換も from None)
1061 raise RateLimitError(reset_time) from None
1063 if response.status_code == 403:
1064 # 注: warning_reset_time は _check_rate_limit_warning が
1065 # remaining < _RATE_LIMIT_WARNING_THRESHOLD (=10) のときのみ
1066 # 非 None を返す (utils/github_client.py L75, L440-473)。
1067 # 閾値変更時はこの reset_time が常に None になり _handle_403_response
1068 # 側の Retry-After ヘッダー fallback パスに倒れる挙動になる。
1069 # debug-only ログのため動作影響は限定的だが、依存関係を明示する。
1070 self._handle_403_response(
1071 response,
1072 rate_remaining=remaining,
1073 reset_time=warning_reset_time,
1074 )
1075 elif response.status_code >= 500:
1076 await self._handle_5xx_response(response, attempt, endpoint, method)
1077 continue
1078 else:
1079 response.raise_for_status()
1081 result_json = self._parse_json_response(response, endpoint)
1082 # PR#347 review #4-[9]: ETag cache 更新失敗を HTTP 層 unexpected_error
1083 # と分離。cache update 失敗はレスポンス返却を阻害してはならず (cache の
1084 # 副作用) かつ専用イベントで観測性を確保する。HTTP 層 unexpected_error
1085 # は retry/エラー判定の対象だが、本イベントは error ログで監視対象にする。
1086 try:
1087 self._update_etag_cache(cache_key, response, result_json)
1088 except (MemoryError, RecursionError): # fmt: skip
1089 # MemoryError / RecursionError も Exception 派生のため、再raise しないと
1090 # 下流の except Exception に捕捉されサイレント隠蔽される(sentry_init と
1091 # 同一方針)。致命的エラーとして必ず再raise(fail-fast, PR#347 #1)。
1092 raise
1093 except Exception as cache_exc: # noqa: BLE001
1094 # logger.error 記録 + 失敗時 stderr フォールバック(PR#347 Q-12)。
1095 # cache 更新失敗はレスポンス返却を阻害しないが、ロガー自体の失敗で
1096 # 観測性が完全に失われるのを防ぐ。api_client と共通の DRY ヘルパー使用。
1097 _log_error_with_stderr_fallback(
1098 self.logger,
1099 "github_client",
1100 "etag_cache_update",
1101 cache_exc,
1102 "etag_cache_update_failed",
1103 endpoint=endpoint,
1104 method=method,
1105 error_type=type(cache_exc).__name__,
1106 error_module=type(cache_exc).__module__,
1107 )
1109 return result_json
1111 except GitHubAPIError:
1112 raise
1114 except httpx.HTTPStatusError as e:
1115 # except外raiseパターン: e.response(PII含有)が __context__ に残存するのを防止
1116 # e.response を退避してから except を抜け、except 外で処理・raise する
1117 http_status_response = e.response
1119 except httpx.TimeoutException as e:
1120 # PII漏洩防止: str(e)はURL/host:port等を含む可能性があるためログから除外
1121 # (unexpected_errorパスと同じ方針:
1122 # error_type + error_module + error_context で診断情報を提供)
1123 retry_error_message = f"Request timeout: {type(e).__qualname__}"
1124 await self._log_and_sleep_for_retry(
1125 event="request_timeout",
1126 error_context="timeout",
1127 error=e,
1128 endpoint=endpoint,
1129 method=method,
1130 attempt=attempt,
1131 )
1132 if attempt < self.max_retries - 1:
1133 continue
1135 except (httpx.NetworkError, httpx.RemoteProtocolError) as e: # fmt: skip
1136 # PII漏洩防止: str(e)はURL/host:port等を含む可能性があるためログから除外
1137 retry_error_message = f"Network error: {type(e).__qualname__}"
1138 await self._log_and_sleep_for_retry(
1139 event="request_network_error",
1140 error_context="network",
1141 error=e,
1142 endpoint=endpoint,
1143 method=method,
1144 attempt=attempt,
1145 )
1146 if attempt < self.max_retries - 1:
1147 continue
1149 except ASYNC_FATAL_EXCEPTIONS:
1150 # システム例外は再発生
1151 # - KeyboardInterrupt/SystemExit: graceful shutdown対応
1152 # - MemoryError: K8s OOMKilled等のリソース枯渇検知
1153 # - CancelledError: asyncioタスクキャンセル伝播
1154 raise
1156 except httpx.ResponseNotRead:
1157 # ResponseNotRead は response body 未読の httpx 正常系制御例外。
1158 # unexpected_error に包まず、そのまま伝播させる。
1159 raise
1161 except Exception as e:
1162 unexpected_error_type = type(e).__qualname__
1163 error_module = type(e).__module__
1164 self.logger.error(
1165 "unexpected_error",
1166 endpoint=endpoint,
1167 method=method,
1168 error_type=unexpected_error_type,
1169 error_module=error_module,
1170 error_context="unexpected",
1171 )
1172 # except外raiseパターン: 予期しない例外が__context__に残存するのを防止
1174 if http_status_response is not None:
1175 # PII漏洩防止: __context__ を None に保つため except 外で処理・raise
1176 # (httpx.HTTPStatusError.response は response body + request URL を保持するため)
1177 #
1178 # PR#347 review #4-[10]: 5xx のみ _handle_5xx_response でリトライ制御が必要なため
1179 # 個別分岐を維持。404/429/403/その他 は _handle_http_status_error に一本化。
1180 if http_status_response.status_code >= 500:
1181 # 防御的パス: 5xxをhttpx.HTTPStatusErrorとして受信した場合、通常パスと同等に処理
1182 await self._handle_5xx_response(http_status_response, attempt, endpoint, method)
1183 continue
1184 self._handle_http_status_error(http_status_response, endpoint, method)
1186 if retry_error_message is not None:
1187 # PII漏洩防止 (__context__): active exception context の外で raise して
1188 # httpx.TimeoutException / httpx.NetworkError / httpx.RemoteProtocolError の
1189 # URL/host:port等を例外チェーンに残さない
1190 raise GitHubAPIError(retry_error_message) from None
1192 if unexpected_error_type is not None: 1192 ↛ 1025line 1192 didn't jump to line 1025 because the condition on line 1192 was always true
1193 # PII漏洩防止 (__cause__): catch-all例外はURL/host:port等のPIIを含む可能性があるため
1194 # 例外チェーンを切断し、診断情報は非PIIのログフィールドに限定する。
1195 raise GitHubAPIError(f"Unexpected error: {unexpected_error_type}") from None
1197 # リトライ上限到達フォールバック: max_retries 回連続で
1198 # retry_error_message / unexpected_error_type が None のまま for ループを
1199 # 抜けた場合に到達する。通常は最後の attempt で上記いずれかが populate
1200 # されて先行 raise されるが、例外捕捉と raise の境界条件 (例: continue 後の
1201 # ループ終了タイミング) で到達しうるため、型チェッカー対策と防御的 fallback
1202 # を兼ねて常時 raise を維持する。`from None` で PII を含む例外チェーンを切断。
1203 raise GitHubServerError(f"Failed after {self.max_retries} attempts") from None