From d607ee1185a99279d77e71e58f18fec1560abb0e Mon Sep 17 00:00:00 2001 From: ArcSolver Date: Thu, 4 Jun 2026 21:27:01 +0900 Subject: [PATCH] =?UTF-8?q?feat(core):=20HTTP=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84/=EB=B0=B1=EC=98=A4=ED=94=84=20+=20=EC=A0=84=EC=86=A1?= =?UTF-8?q?=EA=B3=84=EC=B8=B5=20=EC=97=90=EB=9F=AC=EB=B6=84=EB=A5=98=20(op?= =?UTF-8?q?t-in)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 감사(전수 논증)의 최대 레버리지 코어 약점 상환 — 행복경로 전용이던 http 핫패스에 견고성 원시기능을 코어 한 곳에 도입. 20개+ 서비스로 복붙되던 429/일시오류 처리를 옵트인으로 흡수할 토대. - _send() 단일 진입점으로 통합(_request_raw·get_text가 공유). 모든 동사에 retry 인자 추가. - Retry(attempts, statuses=(429,503), backoff, respect_retry_after): 지수 백오프 + 응답 Retry-After(delta-seconds) 우선 존중. - NetworkError: 전송계층 실패(연결/타임아웃) 분류. 원본 httpx 예외를 __cause__로 보존. 핵심 설계: **기본값(retry 미지정)은 동작 완전 무변경** — 재시도 0회, 전송 예외는 원본 httpx 그대로 전파. zotero가 9곳에서 쓰는 `except httpx.ConnectError`를 깨지 않기 위해 래핑·재시도를 모두 opt-in으로 둠. 서비스는 필요 시 retry=Retry(...)로 점진 채택. 864 passed(신규 5: 503 복구·Retry-After 존중·소진 시 UpstreamError·전송오류→NetworkError· 기본 무재시도 raw httpx 전파) · ruff clean. Co-Authored-By: Claude Opus 4.8 --- arcsolve/http.py | 147 +++++++++++++++++++++++----- changelog.d/core-http-resilience.md | 1 + tests/test_http.py | 64 ++++++++++++ 3 files changed, 190 insertions(+), 22 deletions(-) create mode 100644 changelog.d/core-http-resilience.md diff --git a/arcsolve/http.py b/arcsolve/http.py index 9133e86..5d951a0 100644 --- a/arcsolve/http.py +++ b/arcsolve/http.py @@ -3,11 +3,17 @@ 서비스는 여기 동사(post_form / get_json / get_text / get_with_headers / post_json / patch_json / delete_json / post_multipart)를 재사용하고, 직접 httpx 세션을 만들지 않는다. 새 인증 방식(Bearer/API-key 등)은 헤더로 주입한다. + +견고성(opt-in): 모든 동사는 `retry=Retry(...)`를 받는다. 지정하지 않으면 **기존 동작 그대로** +(재시도 없음, 전송 예외는 원본 httpx 그대로). 지정하면 429/503·전송오류를 지수 백오프(또는 +응답 Retry-After)로 재시도하고, 소진된 전송오류는 `NetworkError`로 분류해 던진다. """ from __future__ import annotations +import asyncio import re +from dataclasses import dataclass import httpx @@ -20,14 +26,6 @@ DEFAULT_USER_AGENT = f"arcsolve/{__version__} (+https://github.com/ArcSolver/ArcSolve-Kit)" -def _with_default_ua(headers: dict | None) -> dict: - """기본 User-Agent를 깔고 호출자 헤더로 덮어쓴다(호출자 UA가 우선).""" - merged = {"User-Agent": DEFAULT_USER_AGENT} - if headers: - merged.update(headers) - return merged - - class UpstreamError(RuntimeError): """상류 API가 4xx/5xx를 반환했을 때. payload에 원본 응답(JSON 또는 text)을 담는다.""" @@ -37,11 +35,96 @@ def __init__(self, status: int, payload: dict | str): super().__init__(f"upstream {status}: {payload}") +class NetworkError(RuntimeError): + """전송계층 실패(연결 실패·타임아웃 등)를 분류한 에러. 원본 httpx 예외를 __cause__로 보존. + + **opt-in 재시도가 소진된 뒤에만** raise된다. `retry`를 지정하지 않으면(기본) 원본 httpx + 예외(`httpx.ConnectError` 등)가 그대로 전파되어 기존 동작·기존 서비스의 catch와 호환된다. + """ + + +@dataclass(frozen=True) +class Retry: + """opt-in 재시도 정책. 지정하지 않으면 재시도 없음(기본 동작 무변경). + + attempts: 최초 1회 외 **추가** 재시도 횟수. + statuses: 재시도할 상태코드(레이트리밋·일시장애). + backoff: 지수 백오프 기준(초). 대기 = backoff * 2**시도횟수. + respect_retry_after: 응답 `Retry-After`(delta-seconds) 헤더를 백오프보다 우선 존중. + """ + + attempts: int = 2 + statuses: tuple[int, ...] = (429, 503) + backoff: float = 0.5 + respect_retry_after: bool = True + + def bearer(token: str) -> dict[str, str]: """Bearer 인증 헤더.""" return {"Authorization": f"Bearer {token}"} +def _with_default_ua(headers: dict | None) -> dict: + """기본 User-Agent를 깔고 호출자 헤더로 덮어쓴다(호출자 UA가 우선).""" + merged = {"User-Agent": DEFAULT_USER_AGENT} + if headers: + merged.update(headers) + return merged + + +def _retry_delay(attempt: int, retry: Retry, retry_after: str | None) -> float: + """다음 재시도까지 대기(초). Retry-After(delta-seconds)가 있으면 우선, 없으면 지수 백오프.""" + if retry.respect_retry_after and retry_after is not None: + try: + return max(0.0, float(retry_after)) + except ValueError: + pass # HTTP-date 형식은 파싱하지 않고 백오프로 폴백 + return retry.backoff * (2 ** attempt) + + +async def _send( + method: str, + url: str, + *, + headers: dict | None = None, + params: dict | None = None, + data: dict | None = None, + json: dict | None = None, + files: dict | None = None, + timeout: float = DEFAULT_TIMEOUT, + transport: httpx.BaseTransport | None = None, + retry: Retry | None = None, +) -> httpx.Response: + """단일 요청 + (opt-in) 재시도/백오프. 응답을 그대로 반환한다(상태 해석은 호출자 몫). + + retry=None이면 한 번만 보내고 전송 예외는 원본 httpx 그대로 전파(기존 동작). retry가 있으면 + retry.statuses(429/503 등)와 전송 예외를 지수 백오프(또는 Retry-After)로 재시도하고, + 소진 시 상태코드는 응답을 그대로 돌려주고(호출자가 UpstreamError로 매핑) 전송 예외는 + NetworkError로 감싸 raise한다. + """ + attempt = 0 + while True: + try: + async with httpx.AsyncClient(timeout=timeout, transport=transport) as client: + r = await client.request( + method, url, headers=_with_default_ua(headers), + params=params, data=data, json=json, files=files, + ) + except httpx.RequestError as e: + if retry is None: + raise # 후방호환: 원본 httpx 예외 그대로 전파 + if attempt < retry.attempts: + await asyncio.sleep(retry.backoff * (2 ** attempt)) + attempt += 1 + continue + raise NetworkError(f"{method} {url}: {type(e).__name__}: {e}") from e + if retry is not None and r.status_code in retry.statuses and attempt < retry.attempts: + await asyncio.sleep(_retry_delay(attempt, retry, r.headers.get("retry-after"))) + attempt += 1 + continue + return r + + async def _request_raw( method: str, url: str, @@ -53,16 +136,16 @@ async def _request_raw( files: dict | None = None, timeout: float = DEFAULT_TIMEOUT, transport: httpx.BaseTransport | None = None, + retry: Retry | None = None, ) -> tuple[dict | list, dict[str, str]]: """공통 호출. (JSON 본문, 응답 헤더 dict) 튜플 반환. 4xx/5xx면 UpstreamError. 헤더 dict의 키는 httpx가 소문자로 정규화한다(예: "total-results", "link"). """ - async with httpx.AsyncClient(timeout=timeout, transport=transport) as client: - r = await client.request( - method, url, headers=_with_default_ua(headers), - params=params, data=data, json=json, files=files, - ) + r = await _send( + method, url, headers=headers, params=params, data=data, json=json, + files=files, timeout=timeout, transport=transport, retry=retry, + ) if r.status_code >= 400: try: payload: dict | str = r.json() @@ -89,6 +172,7 @@ async def _request( files: dict | None = None, timeout: float = DEFAULT_TIMEOUT, transport: httpx.BaseTransport | None = None, + retry: Retry | None = None, ) -> dict: body, _ = await _request_raw( method, @@ -100,6 +184,7 @@ async def _request( files=files, timeout=timeout, transport=transport, + retry=retry, ) return body @@ -112,6 +197,7 @@ async def post_form( headers: dict | None = None, timeout: float = DEFAULT_TIMEOUT, transport: httpx.BaseTransport | None = None, + retry: Retry | None = None, ) -> dict: """application/x-www-form-urlencoded POST. token을 주면 Bearer 헤더를 붙인다.""" h = {"Content-Type": "application/x-www-form-urlencoded;charset=utf-8"} @@ -119,7 +205,9 @@ async def post_form( h.update(bearer(token)) if headers: h.update(headers) - return await _request("POST", url, headers=h, data=data, timeout=timeout, transport=transport) + return await _request( + "POST", url, headers=h, data=data, timeout=timeout, transport=transport, retry=retry + ) async def get_json( @@ -129,10 +217,12 @@ async def get_json( params: dict | None = None, timeout: float = DEFAULT_TIMEOUT, transport: httpx.BaseTransport | None = None, + retry: Retry | None = None, ) -> dict: """GET → JSON.""" return await _request( - "GET", url, headers=headers, params=params, timeout=timeout, transport=transport + "GET", url, headers=headers, params=params, timeout=timeout, + transport=transport, retry=retry, ) @@ -143,6 +233,7 @@ async def get_text( params: dict | None = None, timeout: float = DEFAULT_TIMEOUT, transport: httpx.BaseTransport | None = None, + retry: Retry | None = None, ) -> str: """GET → 응답 본문을 **raw `str`로** 반환(JSON 파싱 안 함). @@ -151,8 +242,10 @@ async def get_text( `get_json`과 동일(UpstreamError, payload는 JSON이면 dict, 아니면 text). 본문이 비어 있으면 빈 문자열을 돌려준다. transport 주입으로 네트워크 없이 테스트할 수 있다. """ - async with httpx.AsyncClient(timeout=timeout, transport=transport) as client: - r = await client.request("GET", url, headers=_with_default_ua(headers), params=params) + r = await _send( + "GET", url, headers=headers, params=params, timeout=timeout, + transport=transport, retry=retry, + ) if r.status_code >= 400: try: payload: dict | str = r.json() @@ -169,6 +262,7 @@ async def get_with_headers( params: dict | None = None, timeout: float = DEFAULT_TIMEOUT, transport: httpx.BaseTransport | None = None, + retry: Retry | None = None, ) -> tuple[dict | list, dict[str, str]]: """GET → (JSON 본문, 응답 헤더 dict). @@ -176,7 +270,8 @@ async def get_with_headers( `Total-Results`/`Link`/`Last-Modified-Version`/`Backoff`)에서 사용한다. 헤더 키는 소문자. """ return await _request_raw( - "GET", url, headers=headers, params=params, timeout=timeout, transport=transport + "GET", url, headers=headers, params=params, timeout=timeout, + transport=transport, retry=retry, ) @@ -203,10 +298,12 @@ async def post_json( json: dict | None = None, timeout: float = DEFAULT_TIMEOUT, transport: httpx.BaseTransport | None = None, + retry: Retry | None = None, ) -> dict: """application/json POST → JSON.""" return await _request( - "POST", url, headers=headers, json=json, timeout=timeout, transport=transport + "POST", url, headers=headers, json=json, timeout=timeout, + transport=transport, retry=retry, ) @@ -221,6 +318,7 @@ async def post_multipart( headers: dict | None = None, timeout: float = MULTIPART_TIMEOUT, transport: httpx.BaseTransport | None = None, + retry: Retry | None = None, ) -> dict: """multipart/form-data POST → JSON. (로컬 파일 업로드) @@ -229,7 +327,8 @@ async def post_multipart( 직접 지정하지 않는다. 업로드는 느릴 수 있어 기본 timeout을 늘렸다. """ return await _request( - "POST", url, headers=headers, data=data, files=files, timeout=timeout, transport=transport + "POST", url, headers=headers, data=data, files=files, timeout=timeout, + transport=transport, retry=retry, ) @@ -240,10 +339,12 @@ async def patch_json( json: dict | None = None, timeout: float = DEFAULT_TIMEOUT, transport: httpx.BaseTransport | None = None, + retry: Retry | None = None, ) -> dict: """application/json PATCH → JSON. (리소스 부분 수정, 예: 메시지 편집)""" return await _request( - "PATCH", url, headers=headers, json=json, timeout=timeout, transport=transport + "PATCH", url, headers=headers, json=json, timeout=timeout, + transport=transport, retry=retry, ) @@ -254,8 +355,10 @@ async def delete_json( params: dict | None = None, timeout: float = DEFAULT_TIMEOUT, transport: httpx.BaseTransport | None = None, + retry: Retry | None = None, ) -> dict: """DELETE → JSON(또는 본문 없으면 빈 dict). (리소스 삭제, 예: 메시지 삭제)""" return await _request( - "DELETE", url, headers=headers, params=params, timeout=timeout, transport=transport + "DELETE", url, headers=headers, params=params, timeout=timeout, + transport=transport, retry=retry, ) diff --git a/changelog.d/core-http-resilience.md b/changelog.d/core-http-resilience.md new file mode 100644 index 0000000..a04dc6c --- /dev/null +++ b/changelog.d/core-http-resilience.md @@ -0,0 +1 @@ +- core(http): opt-in 재시도/백오프 + 전송계층 에러분류를 코어에 도입. 모든 동사가 `retry=Retry(attempts, statuses=(429,503), backoff, respect_retry_after)`를 받아 레이트리밋·일시장애·전송오류를 지수 백오프(또는 응답 `Retry-After`)로 재시도하고, 소진된 전송오류는 `NetworkError`로 분류. **기본값(retry 미지정)은 기존 동작 그대로** — 재시도 없음, 전송 예외는 원본 httpx 그대로 전파(기존 서비스의 `except httpx.ConnectError` 호환). 20개+ 서비스가 손으로 메우던 429/일시오류 처리를 코어 한 곳에서 옵트인으로 해결할 토대. diff --git a/tests/test_http.py b/tests/test_http.py index e0d8375..fd8caf4 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -4,6 +4,8 @@ import pytest from arcsolve.http import ( + NetworkError, + Retry, UpstreamError, delete_json, get_json, @@ -215,3 +217,65 @@ async def handler(req): await get_json("https://x", transport=_t(handler)) assert ei.value.status == 401 assert ei.value.payload["code"] == -401 + + +# ── opt-in 재시도/백오프 + 전송계층 에러분류 ────────────────────────────────── + + +async def test_retry_recovers_after_transient_503(): + calls = {"n": 0} + + async def handler(req): + calls["n"] += 1 + if calls["n"] == 1: + return httpx.Response(503, text="busy") + return httpx.Response(200, json={"ok": True}) + + out = await get_json("https://x", transport=_t(handler), retry=Retry(attempts=2, backoff=0)) + assert out == {"ok": True} + assert calls["n"] == 2 # 503 한 번 + 재시도 성공 + + +async def test_retry_honors_retry_after_header(): + calls = {"n": 0} + + async def handler(req): + calls["n"] += 1 + if calls["n"] == 1: + return httpx.Response(429, headers={"Retry-After": "0"}, json={"e": "rate"}) + return httpx.Response(200, json={"ok": True}) + + # backoff=5초지만 Retry-After:0을 우선 존중 → 대기 없이 즉시 재시도(테스트가 빨리 끝남). + out = await get_json("https://x", transport=_t(handler), retry=Retry(attempts=3, backoff=5)) + assert out == {"ok": True} + assert calls["n"] == 2 + + +async def test_retry_exhausted_raises_upstream_error(): + async def handler(req): + return httpx.Response(503, text="always busy") + + with pytest.raises(UpstreamError) as ei: + await get_json("https://x", transport=_t(handler), retry=Retry(attempts=1, backoff=0)) + assert ei.value.status == 503 # 재시도 소진 후 평소처럼 UpstreamError + + +async def test_transport_error_retried_then_raises_network_error(): + calls = {"n": 0} + + async def handler(req): + calls["n"] += 1 + raise httpx.ConnectError("connection refused") + + with pytest.raises(NetworkError): + await get_json("https://x", transport=_t(handler), retry=Retry(attempts=2, backoff=0)) + assert calls["n"] == 3 # 최초 1 + 재시도 2 + + +async def test_no_retry_propagates_raw_httpx_error_by_default(): + async def handler(req): + raise httpx.ConnectError("connection refused") + + # 기본(retry 미지정)은 원본 httpx 예외 그대로 — 기존 서비스의 `except httpx.ConnectError` 호환. + with pytest.raises(httpx.ConnectError): + await get_json("https://x", transport=_t(handler))