Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install -e ".[dev]"
- run: python -m ruff check .

typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install -e ".[dev]"
- run: python -m mypy src/kma

test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e ".[dev]"
- run: python -m pytest -q -m "not integration"
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@

### 수정

- 4인 전문 리뷰어 서브에이전트의 적대적 코드 리뷰로 발견·검증된 버그 수정: `iter_pages()`가 응답
body의 `pageNo`를 그대로 신뢰해 다음 페이지를 계산하다가 그 값이 없거나 항상 고정값이면 같은
페이지를 최대 `max_pages`번 중복 재요청하며 서로 다른 페이지인 것처럼 반환하던 문제, `pageNo`/
`numOfRows`/`totalCount`가 `"10.0"` 같은 소수점 형태로 오면 파싱에 실패해 `has_next_page()`가
더 가져올 페이지가 있는데도 조용히 순회를 멈추던 문제, `header: null` 같은 비정상 JSON envelope가
`KmaParseError` 대신 처리되지 않은 `AttributeError`를 던지던 문제, `ApiHubResponse.text`가 실제
응답 `Content-Type`의 charset이 아니라 httpx 기본 추정치로 디코딩되던 문제, `ApiHubClient`의
`open_api`/`aopen_api`가 결과 코드를 확인하지 않고 오류 응답을 성공으로 반환하던 문제,
`base_url`을 검증 없이 받아들이던 문제(APIHub 호스트 allowlist 추가) 등. `iter_pages`/`aiter_pages`
가 `max_pages`에 도달했는데 더 가져올 페이지가 남아있으면 `PaginationLimitWarning`을 발생시키도록
개선. GitHub Actions CI(`lint`/`typecheck`/`test`) 추가.
- data.go.kr 일일 quota 초과 `resultCode=22`를 즉시 재시도 불가한
`KmaRequestError(failure_kind="quota", retryable=False)`로 분류. JSON 응답뿐 아니라
HTTP 200 `OpenAPI_ServiceResponse` XML 오류 envelope도 같은 분류를 사용한다.
Expand Down
7 changes: 6 additions & 1 deletion src/kma/_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,12 @@ def _candidate_env_dirs(start: str | Path) -> tuple[Path, ...]:
path = Path(start).resolve()
if path.is_file():
path = path.parent
return tuple(reversed((path, *path.parents)))
candidates = [path]
for parent in path.parents:
if (parent / ".git").exists():
candidates.append(parent)
break
return tuple(reversed(candidates))


def _parse_env_file(path: Path) -> dict[str, str]:
Expand Down
60 changes: 55 additions & 5 deletions src/kma/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import random
import time
from collections.abc import Mapping
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from typing import Any, NoReturn
from xml.etree import ElementTree

Expand All @@ -16,6 +18,19 @@

RETRY_STATUS_CODES = frozenset({429, 500, 502, 503, 504})

#: httpx.RequestError subtypes worth retrying — transient connection/timeout
#: conditions. Non-transient errors (e.g. httpx.UnsupportedProtocol,
#: httpx.InvalidURL) are deliberately excluded so a permanently broken client
#: configuration fails on the first attempt instead of burning the retry budget.
TRANSIENT_REQUEST_ERRORS = (
httpx.ConnectError,
httpx.ConnectTimeout,
httpx.ReadTimeout,
httpx.WriteTimeout,
httpx.PoolTimeout,
httpx.RemoteProtocolError,
)

#: data.go.kr 표준 result code ``03``(NODATA_ERROR) — 조회 결과 없음.
#: 인증/서버 오류와 달리 정상적인 빈 결과이므로 예외 대신 빈 body로 정규화한다.
NO_DATA_RESULT_CODE = "03"
Expand Down Expand Up @@ -49,6 +64,29 @@ def _backoff_with_jitter(backoff_factor: float, attempt: int) -> float:
return float(half + random.uniform(0, half))


def _retry_after_seconds(response: httpx.Response) -> float | None:
"""Parse a ``Retry-After`` header (delay-seconds or HTTP-date) into seconds.

Returns ``None`` if the header is absent or unparseable.
"""

value = response.headers.get("Retry-After")
if value is None:
return None
value = value.strip()
try:
return max(0.0, float(value))
except ValueError:
pass
try:
retry_date = parsedate_to_datetime(value)
except (TypeError, ValueError, IndexError):
return None
if retry_date.tzinfo is None:
retry_date = retry_date.replace(tzinfo=timezone.utc)
return max(0.0, (retry_date - datetime.now(timezone.utc)).total_seconds())


def raise_for_kma_result_code(
code: str,
message: str,
Expand All @@ -69,7 +107,7 @@ def raise_for_kma_result_code(
"""

text = f"{label} API returned {code}: {redact_credentials_in_text(message)}"
if code in {"20", "30", "31"}:
if code in {"20", "21", "30", "31", "32", "33"}:
raise KmaAuthError(
text,
provider=provider,
Expand Down Expand Up @@ -280,6 +318,7 @@ def get_with_retries(
attempts = max(1, retries + 1)
last_exc: httpx.HTTPError | None = None
for attempt in range(attempts):
retry_after: float | None = None
try:
response = client.get(url, params=params, timeout=timeout)
response.raise_for_status()
Expand All @@ -288,11 +327,16 @@ def get_with_retries(
if not _should_retry_status(exc) or attempt >= attempts - 1:
raise
last_exc = exc
except httpx.RequestError as exc:
if exc.response.status_code == 429:
retry_after = _retry_after_seconds(exc.response)
except TRANSIENT_REQUEST_ERRORS as exc:
if attempt >= attempts - 1:
raise
last_exc = exc
time.sleep(_backoff_with_jitter(backoff_factor, attempt))
sleep_seconds = _backoff_with_jitter(backoff_factor, attempt)
if retry_after is not None:
sleep_seconds = max(sleep_seconds, retry_after)
time.sleep(sleep_seconds)
if last_exc is not None: # pragma: no cover - defensive fallback
raise last_exc
raise RuntimeError("HTTP request failed before it could be attempted")
Expand All @@ -312,6 +356,7 @@ async def async_get_with_retries(
attempts = max(1, retries + 1)
last_exc: httpx.HTTPError | None = None
for attempt in range(attempts):
retry_after: float | None = None
try:
response = await client.get(url, params=params, timeout=timeout)
response.raise_for_status()
Expand All @@ -320,11 +365,16 @@ async def async_get_with_retries(
if not _should_retry_status(exc) or attempt >= attempts - 1:
raise
last_exc = exc
except httpx.RequestError as exc:
if exc.response.status_code == 429:
retry_after = _retry_after_seconds(exc.response)
except TRANSIENT_REQUEST_ERRORS as exc:
if attempt >= attempts - 1:
raise
last_exc = exc
await asyncio.sleep(_backoff_with_jitter(backoff_factor, attempt))
sleep_seconds = _backoff_with_jitter(backoff_factor, attempt)
if retry_after is not None:
sleep_seconds = max(sleep_seconds, retry_after)
await asyncio.sleep(sleep_seconds)
if last_exc is not None: # pragma: no cover - defensive fallback
raise last_exc
raise RuntimeError("HTTP request failed before it could be attempted")
Expand Down
Loading
Loading