From 321d99712334d00176d7e10edb024df009c8d0fb Mon Sep 17 00:00:00 2001 From: hyungi Date: Thu, 11 Jun 2026 07:54:13 +0900 Subject: [PATCH] =?UTF-8?q?fix(news):=20=EC=97=B0=EA=B2=B0=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=202=ED=9A=8C=EB=A1=9C=20=EB=B3=B4=EA=B0=95?= =?UTF-8?q?=20=E2=80=94=20=EB=93=9C=EB=9E=8D=EC=9D=B4=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=20=EB=8B=A8=EC=9C=84=20=EB=9E=9C=EB=8D=A4(=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=201=ED=9A=8C=EB=8F=84=20=EC=97=B0=EC=86=8D=20?= =?UTF-8?q?=ED=94=BC=EA=B2=A9=20=EC=8B=A4=EC=B8=A1)=20+=20=EB=B9=88=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EB=A1=9C=EA=B7=B8=20repr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- app/workers/news_collector.py | 31 +++++++++++++++++++------------ tests/test_crawl_cycle3_shapes.py | 12 ++++++++++-- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/app/workers/news_collector.py b/app/workers/news_collector.py index 8f501f0..1b3f8c4 100644 --- a/app/workers/news_collector.py +++ b/app/workers/news_collector.py @@ -231,7 +231,8 @@ async def _run_locked(): _record_success(health, count, status == "not_modified", now) total += count except Exception as e: - logger.error(f"[{source.name}] 수집 실패: {e}") + # str 이 빈 예외(httpx.ConnectError('')) 대비 — health 기록과 동일 규칙 + logger.error(f"[{source.name}] 수집 실패: {str(e) or repr(e)}") source.last_fetched_at = datetime.now(timezone.utc) _record_failure(health, str(e) or repr(e), now) @@ -244,19 +245,25 @@ ALLOWED_CONTENT_TYPES = ("application/rss+xml", "application/atom+xml", "application/xml", "text/xml") -async def _get_with_connect_retry(client, url: str): - """연결 계층(TCP/TLS) 오류만 1회 재시도 — HTTP 상태 오류는 비대상 (호출측 분기 보존). +# 연결 재시도 간격 — MOEL 추가 실측(2026-06-11): 드랍이 연결 단위 랜덤이라 +# 1.5s 후 재시도도 연속으로 걸리는 케이스 발생(직후 다른 연결은 즉시 성공) → 2회로 보강. +_CONNECT_RETRY_DELAYS = (2.0, 5.0) - MOEL 실측(2026-06-11): 정부 사이트 보안장비가 첫 TLS 핸드셰이크를 간헐 드랍 - (curl rc=35, 직후 재시도 성공) → 사이클당 1회 fetch 인 피드 수집이 ConnectError('') - 로 실패 누적·circuit open. 재시도 1회면 흡수됨 — 지속 장애는 그대로 circuit 몫. + +async def _get_with_connect_retry(client, url: str): + """연결 계층(TCP/TLS) 오류만 재시도(최대 2회) — HTTP 상태 오류는 비대상 (호출측 분기 보존). + + MOEL 실측(2026-06-11): 정부 사이트 보안장비가 TLS 핸드셰이크를 연결 단위로 간헐 드랍 + (curl rc=35, 직후 재시도는 성공) → 사이클당 1회 fetch 인 피드 수집이 ConnectError('') + 로 실패 누적·circuit open. 지속 장애는 그대로 circuit 몫. """ - try: - return await client.get(url) - except (httpx.ConnectError, httpx.ConnectTimeout) as e: - logger.info(f"연결 오류 1회 재시도 ({url.split('?')[0]}): {repr(e)}") - await asyncio.sleep(1.5) - return await client.get(url) + for delay in _CONNECT_RETRY_DELAYS: + try: + return await client.get(url) + except (httpx.ConnectError, httpx.ConnectTimeout) as e: + logger.info(f"연결 오류 {delay}s 후 재시도 ({url.split('?')[0]}): {repr(e)}") + await asyncio.sleep(delay) + return await client.get(url) async def _is_portal_duplicate(session, title: str) -> bool: diff --git a/tests/test_crawl_cycle3_shapes.py b/tests/test_crawl_cycle3_shapes.py index 68e254f..f2e6f11 100644 --- a/tests/test_crawl_cycle3_shapes.py +++ b/tests/test_crawl_cycle3_shapes.py @@ -139,12 +139,20 @@ class TestConnectRetry: assert resp == "OK" and client.calls == 2 @pytest.mark.asyncio - async def test_persistent_connect_error_propagates(self): + async def test_second_retry_absorbs_consecutive_drop(self): + """드랍이 연결 단위 랜덤이라 재시도 1회도 연속으로 걸림 (MOEL lawinfo 실측).""" import httpx client = self._Client([httpx.ConnectError(""), httpx.ConnectError("")]) + resp = await news_collector._get_with_connect_retry(client, "https://x/feed") + assert resp == "OK" and client.calls == 3 + + @pytest.mark.asyncio + async def test_persistent_connect_error_propagates(self): + import httpx + client = self._Client([httpx.ConnectError("")] * 3) with pytest.raises(httpx.ConnectError): await news_collector._get_with_connect_retry(client, "https://x/feed") - assert client.calls == 2 # 1회만 재시도 — 지속 장애는 circuit 몫 + assert client.calls == 3 # 최대 2회 재시도 — 지속 장애는 circuit 몫 @pytest.mark.asyncio async def test_non_connect_errors_not_retried(self):