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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# 변경 기록

## 2026-10-10 — 기본 User-Agent

- 라이브러리가 직접 만드는 HTTPX session이 `User-Agent: python-krex-api/<version>`을 보낸다
(버전은 설치된 package metadata, `krex._http.DEFAULT_USER_AGENT`). data.ex.co.kr WAF가 2026-10-08
13:35 KST 무렵부터 `python-httpx/*`·`curl/*` 기본 UA를 키·경로와 무관하게 `400 Request Blocked`
(`KrexBadRequestError`)로 막는다. 같은 IP에서 이 UA는 통과한다(n150 실측).
- `session=`으로 주입한 session은 바꾸지 않는다 — 헤더를 포함한 호출자 설정이 그대로 쓰인다.

## 2026-09-19 — 실시간 소통 조회 복구

- 인자 없는 `traffic.flow_all()`이 한 응답의 모든 행을 `Page[TrafficFlow]`로 반환한다.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -640,7 +640,9 @@ HTTPX 리다이렉트는 계측한다. Digest/custom Auth처럼 내부 추가
transport 내부의 연결 재시도는 라이브러리 밖의 동작이다.

내부 HTTP 세션은 첫 요청에서 생성하고 클라이언트 종료 시 닫는다. session에 주입한
비동기 세션은 호출자가 닫는다. 동기 get을 제공하는 세션은 거부한다.
비동기 세션은 호출자가 닫는다.
내부 세션은 `User-Agent: python-krex-api/<version>`을 보낸다(data.ex.co.kr WAF가 2026-10-08부터
`python-httpx/*` 같은 라이브러리 기본 UA를 `400 Request Blocked`로 막는다). 주입한 세션은 그 헤더를 그대로 쓴다. 동기 get을 제공하는 세션은 거부한다.
디버그 기록은 ContextVar로 작업마다 격리하며 성공·실패·취소 뒤 복구한다.
원문으로 오류 분류·모델 파싱을 마친 후 예외와 진단 출력의 키를 마스킹한다.

Expand Down
22 changes: 21 additions & 1 deletion src/krex/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass, field
from importlib.metadata import PackageNotFoundError, version
from typing import Any
from urllib.parse import quote, quote_plus

Expand All @@ -33,6 +34,21 @@
)

_MAX_BACKOFF_SECONDS = 30.0


def _default_user_agent() -> str:
try:
return f"python-krex-api/{version('python-krex-api')}"
except PackageNotFoundError: # 설치되지 않은 소스 트리에서 import한 경우
return "python-krex-api"


DEFAULT_USER_AGENT = _default_user_agent()
"""라이브러리가 직접 만드는 session의 User-Agent.

data.ex.co.kr WAF가 2026-10-08부터 흔한 HTTP 라이브러리 기본값(`python-httpx/*`, `curl/*`)을
`400 Request Blocked`로 막는다. 호출자가 넘긴 session은 그 설정(헤더 포함)을 그대로 쓴다.
"""
_KEY_QUERY = re.compile(r"(?i)([?&](?:key|servicekey)=)[^&\s\"<>]+")


Expand Down Expand Up @@ -165,7 +181,11 @@ async def _get(self, url: str, params: dict[str, Any], *, provider: str) -> Norm
self.last_request = {"method": "GET", "url": url, "query": _mask_params(params)}
self.last_response = None
if self.session is None:
self.session = httpx.AsyncClient(timeout=self.timeout, follow_redirects=True)
self.session = httpx.AsyncClient(
timeout=self.timeout,
follow_redirects=True,
headers={"User-Agent": DEFAULT_USER_AGENT},
)
secrets = (self.ex_api_key, self.go_api_key)
try:
for attempt in range(attempts):
Expand Down
46 changes: 46 additions & 0 deletions tests/test_async_tps.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,3 +248,49 @@ def handler(request: httpx.Request) -> httpx.Response:
with pytest.raises(TypeError, match="authentication"):
await task
assert not sent


async def test_owned_session_sends_library_user_agent(monkeypatch: pytest.MonkeyPatch) -> None:
"""라이브러리가 만든 session은 `python-krex-api/<version>`으로 자신을 밝힌다.

data.ex.co.kr WAF는 2026-10-08부터 `python-httpx/*` 기본 UA를 400 Request Blocked로 막는다.
"""
from importlib.metadata import version

expected = f"python-krex-api/{version('python-krex-api')}"
seen: list[str] = []

def handler(request: httpx.Request) -> httpx.Response:
user_agent = request.headers.get("user-agent", "")
seen.append(user_agent)
if user_agent.startswith("python-httpx/"):
return httpx.Response(400, text="<H1>Request Blocked</H1>")
return httpx.Response(200, json=payload())

factory = httpx.AsyncClient

class OwnedClient(factory):
def __init__(self, **kwargs: Any) -> None:
super().__init__(transport=httpx.MockTransport(handler), **kwargs)

monkeypatch.setattr(httpx, "AsyncClient", OwnedClient)
async with KrexClient(ex_api_key="fake-key", max_retries=0) as client:
await client.traffic.by_route(time_unit="1", route_no="0010")
assert seen == [expected]


async def test_caller_session_user_agent_is_kept() -> None:
seen: list[str] = []

def handler(request: httpx.Request) -> httpx.Response:
seen.append(request.headers.get("user-agent", ""))
return httpx.Response(200, json=payload())

async with httpx.AsyncClient(
transport=httpx.MockTransport(handler), headers={"User-Agent": "caller-app/2.0"}
) as session:
client = KrexClient(ex_api_key="fake-key", session=session)
await client.traffic.by_route(time_unit="1", route_no="0010")
await client.aclose()
assert not session.is_closed
assert seen == ["caller-app/2.0"]
Loading