From b499dab34e9f656076c1a8fe49f3605d3154b6fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:01:32 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=ED=8E=98=EC=9D=B4=EC=A7=80=20fetch?= =?UTF-8?q?=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=EC=9D=98=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=9E=AC=EC=8B=9C=EB=8F=84=EB=A5=BC=20=EB=81=88?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HttpClient5 는 I/O 오류를 기본으로 한 번 더 실행한다(maxRetries=1, 대기 0ms). 우리가 켠 적 없고 아무도 보고 있지 않던 재시도다 - 방침은 이미 코드에 적혀 있었다. application.yml 의 gemini.retry 주석이 "URL 파싱 재시도는 호출자의 recover 한 곳으로 모여 있다. 여기서 내부 재시도를 켜면 재시도가 이중으로 겹친다" 고 못박고 Gemini 쪽은 max-attempts=1 로 껐는데, fetch 쪽은 라이브러리 기본값이 살아 있었다 - 바로 윗줄 disableRedirectHandling() 과 같은 성격이다. 우리가 상위에서 관리하는 동작을 라이브러리가 몰래 또 하는 것인데, 리다이렉트는 껐고 재시도는 놓쳤다 - 성격이 다르다는 게 핵심이다. core 큐 재시도는 attempt 가 DB 에 남고 상한(MAX_ATTEMPTS=2)·메트릭·트레이스가 붙는데, 이쪽은 기록도 간격도 없이 방금 우리를 끊어낸 호스트를 즉시 다시 두드린다 prod 사고(2026-08-23)에서 드러났다. 에이블리 등록 1건이 실패했는데, 정적 fetch 한 번이 57초를 먹어 core 의 55초 예산을 통째로 소진했고 7초 만에 성공한 브라우저 렌더가 도착할 자리가 없었다. 그 57초의 절반이 이 재시도였다(12:27:04 SocketException -> 0ms 뒤 재실행 -> 24초 더 쓰고 403). 검증: 실제 소켓으로 못 박는 테스트를 넣었다. MockRestServiceServer 는 RestClient 위에 붙어 HttpClient 를 아예 안 타므로 이 설정을 못 잡는다. 연결을 받자마자 RST 로 끊는 로컬 소켓에 붙여 도착한 연결 수가 1인지 센다. 고친 줄을 빼면 이 테스트가 실패하는 것도 확인했다(negative control). --- .../http/PageFetchHttpClientConfig.java | 6 ++ .../http/PageFetchHttpClientRetryTest.java | 68 +++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 src/test/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientRetryTest.java diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientConfig.java b/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientConfig.java index 88ef527..b4e20bd 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientConfig.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientConfig.java @@ -67,6 +67,12 @@ public String resolveCanonicalHostname(String host) throws UnknownHostException // 따라가므로(JDK 의 instanceFollowRedirects=false 등가물), 라이브러리 자동 추적을 끈다. 끄지 않으면 // HttpPageFetcher.nextRedirect 의 cross-domain·다운그레이드 차단이 우회된다. .disableRedirectHandling() + // HttpClient5 는 I/O 오류를 기본으로 한 번 더 실행한다(maxRetries=1, 대기 0ms). 그것도 끈다 — + // 파싱 재시도는 호출자(core)의 작업 큐 한 곳에만 두는 것이 이 서비스의 방침이고(application.yml + // 의 gemini.retry 주석), 여기 재시도는 그 방침 밖에서 조용히 겹친다. 겹치는 값이 아니라 성격이 + // 다르다: 큐 재시도는 attempt 가 DB 에 남고 상한·관측이 붙는데, 이쪽은 기록도 간격도 없이 + // 방금 우리를 끊어낸 호스트를 즉시 다시 두드려 fetch 최악 시간만 두 배로 만든다. + .disableAutomaticRetries() .setDefaultRequestConfig( RequestConfig.custom() .setConnectionRequestTimeout(Timeout.ofMilliseconds(properties.connectionRequestTimeout().toMillis())) diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientRetryTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientRetryTest.java new file mode 100644 index 0000000..2173bd8 --- /dev/null +++ b/src/test/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientRetryTest.java @@ -0,0 +1,68 @@ +package com.depromeet.piki.extractor.extraction.http; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.micrometer.observation.ObservationRegistry; +import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClient; + +/** + * 페이지 fetch 클라이언트가 I/O 오류를 스스로 재시도하지 않는지 **실제 소켓으로** 검증한다. + * + *

HttpClient5 는 이 재시도를 기본으로 켜 둔다(maxRetries=1, 대기 0ms). 파싱 재시도는 호출자(core)의 작업 큐 + * 한 곳에만 두는 것이 이 서비스의 방침이라 그 기본값을 끄는데, 끄는 코드가 사라져도 컴파일·기동·대부분의 테스트는 + * 멀쩡하다. 실제로 이 기본값이 살아 있는 걸 아무도 못 본 채 fetch 최악 시간이 두 배로 돌던 기간이 있었다. + * + *

MockRestServiceServer 로는 못 잡는다 — 그건 RestClient 위에 붙어 HttpClient 를 아예 타지 않는다. + * 그래서 연결을 받자마자 끊는 로컬 소켓을 두고 **도착한 연결 수**를 센다. + */ +class PageFetchHttpClientRetryTest { + + @Test + @Timeout(value = 20, unit = TimeUnit.SECONDS) + @DisplayName("연결이 끊겨도 클라이언트가 스스로 다시 붙지 않는다 — 재시도는 호출자의 큐 한 곳에만 있다") + void doesNotRetryOnIoError() throws Exception { + AtomicInteger connections = new AtomicInteger(); + + try (ServerSocket server = new ServerSocket(0, 0, InetAddress.getLoopbackAddress())) { + Thread accepter = new Thread(() -> { + while (!server.isClosed()) { + try (Socket socket = server.accept()) { + connections.incrementAndGet(); + socket.setSoLinger(true, 0); // RST 로 끊어 클라이언트에 I/O 오류를 준다 + } catch (IOException e) { + return; // 서버 종료 + } + } + }); + accepter.setDaemon(true); + accepter.start(); + + RestClient client = new PageFetchHttpClientConfig() + .pageFetchRestClient(ObservationRegistry.NOOP, loopbackResolver(), FetchProperties.defaults()); + + assertThrows( + ResourceAccessException.class, + () -> client.get() + .uri("http://127.0.0.1:" + server.getLocalPort() + "/p") + .retrieve() + .body(String.class)); + + assertEquals(1, connections.get(), "재시도가 켜져 있으면 같은 곳에 2번 붙는다"); + } + } + + private static RequestScopedDnsResolver loopbackResolver() { + return new RequestScopedDnsResolver(host -> new InetAddress[] {InetAddress.getLoopbackAddress()}); + } +} From 362f9a08c229f9143b74968c54f2afb716a554af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:48:56 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?=EC=B8=B5=EC=9D=84=20"=EB=AA=B0=EC=9D=B4=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=9D=84=20=EB=B4=A4=EB=8A=94=EA=B0=80"=20=EB=A1=9C=20?= =?UTF-8?q?=EA=B0=80=EB=A5=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앞선 커밋은 fetch 재시도를 통째로 껐다. 문헌을 확인하니 그건 옳은 절반과 틀린 절반이 섞인 결정이었다. 경계를 하나로 다시 긋는다 — **대상 몰이 우리 요청을 봤는가.** - **못 봤으면 여기서 한 번 복구한다.** 몰 입장에서 아무 일도 없었으므로 부작용도 추가 부하도 없다. RFC 9110 §9.2.2 가 "응답을 읽기 전 통신 실패는 자동 반복해도 된다"고 명시하는 부류이고, gRPC 는 이걸 transparent retry 라 부르며 재시도 횟수·예산에 세지도 않는다(gRFC A6). Finagle 도 "bytes written to the wire" 이전을 별도 부류로 둔다 - **봤으면 전부 상위 큐가 소유한다.** 429·503·읽기 타임아웃이 여기 속한다. 특히 429/503 은 몰이 "과부하다"라고 말한 요청을 되쏘는 것이라, Google SRE 가 정반대를 권하는 동작이다("Return a specific status when overloaded so that clients and other layers back off and do not retry") HttpClient5 기본 전략을 못 쓰는 이유가 이것이다. DefaultHttpRequestRetryStrategy 는 두 부류를 섞어서, I/O 복구와 함께 429·503 응답까지 재시도한다. 앞 커밋에서 통째로 끈 것은 그 절반(429/503)에 대해서는 옳았지만, 나머지 절반(전송 중 끊김)까지 함께 죽여 커넥션 풀 idle-close 경합 같은 것이 core 큐의 stale 60초를 기다리게 만들었다. 사용자 체감 지연으로 돌아온다. 연결을 못 세운 경우(UnknownHost·Connect·NoRouteToHost·SSL)는 "닿기 전" 이지만 복구하지 않는다 — 즉시 다시 해도 같은 결과라 지연만 남는다. 멱등 메서드만 반복한다(RFC 9110 §9.2.2). 검증: 두 축을 각각 negative control 로 확인했다. 429/503 재시도를 되살리면 "응답을 받은 요청은 재시도하지 않는다" 가 실패하고, 복구를 죽이면 "응답 전 끊김에 한 번 더 붙는다" 와 실제 소켓 테스트가 실패한다. 실제 소켓 테스트는 연결 수가 정확히 2인지 센다 — 1이면 복구가 죽은 것이고 3 이상이면 상한이 풀린 것이다. 근거 문헌: RFC 9110 §9.2.2, gRPC gRFC A6, Google SRE Book Ch.21-22, Azure Architecture Center(transient faults), Apache HttpClient 튜토리얼 1.5.3. --- .../http/PageFetchHttpClientConfig.java | 10 +-- .../http/PreDeliveryRetryStrategy.java | 86 ++++++++++++++++++ .../http/PageFetchHttpClientRetryTest.java | 87 ++++++++++++++++--- 3 files changed, 165 insertions(+), 18 deletions(-) create mode 100644 src/main/java/com/depromeet/piki/extractor/extraction/http/PreDeliveryRetryStrategy.java diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientConfig.java b/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientConfig.java index b4e20bd..0ae089d 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientConfig.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientConfig.java @@ -67,12 +67,10 @@ public String resolveCanonicalHostname(String host) throws UnknownHostException // 따라가므로(JDK 의 instanceFollowRedirects=false 등가물), 라이브러리 자동 추적을 끈다. 끄지 않으면 // HttpPageFetcher.nextRedirect 의 cross-domain·다운그레이드 차단이 우회된다. .disableRedirectHandling() - // HttpClient5 는 I/O 오류를 기본으로 한 번 더 실행한다(maxRetries=1, 대기 0ms). 그것도 끈다 — - // 파싱 재시도는 호출자(core)의 작업 큐 한 곳에만 두는 것이 이 서비스의 방침이고(application.yml - // 의 gemini.retry 주석), 여기 재시도는 그 방침 밖에서 조용히 겹친다. 겹치는 값이 아니라 성격이 - // 다르다: 큐 재시도는 attempt 가 DB 에 남고 상한·관측이 붙는데, 이쪽은 기록도 간격도 없이 - // 방금 우리를 끊어낸 호스트를 즉시 다시 두드려 fetch 최악 시간만 두 배로 만든다. - .disableAutomaticRetries() + // 재시도는 "대상 몰이 우리 요청을 봤는가" 로 층을 가른다 — 못 봤으면 여기서 한 번 복구하고, + // 봤으면(429·503·느림) 전부 호출자(core)의 작업 큐가 소유한다. 근거는 PreDeliveryRetryStrategy. + // HttpClient5 기본 전략은 그 둘을 섞어(429·503 까지 재시도) 우리 방침 밖에서 겹치므로 쓰지 않는다. + .setRetryStrategy(new PreDeliveryRetryStrategy()) .setDefaultRequestConfig( RequestConfig.custom() .setConnectionRequestTimeout(Timeout.ofMilliseconds(properties.connectionRequestTimeout().toMillis())) diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/http/PreDeliveryRetryStrategy.java b/src/main/java/com/depromeet/piki/extractor/extraction/http/PreDeliveryRetryStrategy.java new file mode 100644 index 0000000..a6228d0 --- /dev/null +++ b/src/main/java/com/depromeet/piki/extractor/extraction/http/PreDeliveryRetryStrategy.java @@ -0,0 +1,86 @@ +package com.depromeet.piki.extractor.extraction.http; + +import java.io.IOException; +import java.net.ConnectException; +import java.net.NoRouteToHostException; +import java.net.UnknownHostException; +import javax.net.ssl.SSLException; +import org.apache.hc.client5.http.HttpRequestRetryStrategy; +import org.apache.hc.core5.http.ConnectionClosedException; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.Method; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.util.TimeValue; + +/** + * "요청이 대상 몰에 닿기 전"에 끊긴 경우에만 in-process 로 한 번 복구한다. + * + *

경계는 대상 몰이 우리 요청을 봤는가 하나다. 못 봤으면 몰 입장에서 아무 일도 없었으므로 다시 붙는 것이 + * 부작용도 추가 부하도 없다 — RFC 9110 §9.2.2 가 "응답을 읽기 전 통신 실패는 자동 반복해도 된다"고 명시하는 + * 부류이고, gRPC 는 이것을 transparent retry 라 부르며 재시도 횟수·예산에 세지도 않는다(gRFC A6). + * 반대로 몰이 이미 받아서 거부(429/503)하거나 느린 경우는 몰이 일을 한 것이라, 되쏘면 그 자원을 한 번 더 쓴다. + * 그쪽 재시도는 호출자(core)의 작업 큐가 단독으로 소유한다 — 거기에만 attempt 기록·상한·관측이 있다. + * + *

HttpClient5 기본 전략({@code DefaultHttpRequestRetryStrategy})을 그대로 쓸 수 없는 이유는 그것이 위 둘을 + * 섞기 때문이다: I/O 실패 복구와 함께 429·503 응답까지 재시도한다. 서버가 "과부하다"라고 말한 요청을 + * 클라이언트가 되쏘는 동작이라, 계층 중첩 재시도의 표준 경고에 정면으로 걸린다. + * + * @see RFC 9110 §9.2.2 + * @see gRPC gRFC A6 + */ +public class PreDeliveryRetryStrategy implements HttpRequestRetryStrategy { + + /** 복구는 딱 한 번. 즉시 재시도를 두 번 이상 하지 않는다는 것은 전송 오류 복구의 통상 상한이다. */ + private static final int MAX_RETRIES = 1; + + @Override + public boolean retryRequest(HttpRequest request, IOException exception, int execCount, HttpContext context) { + if (execCount > MAX_RETRIES) { + return false; + } + // 멱등 메서드만 자동 반복한다(RFC 9110 §9.2.2). 페이지 fetch 는 GET 뿐이지만, 이 전략이 다른 호출에 + // 재사용될 때 그 전제가 조용히 깨지지 않게 여기서 막는다. + if (!Method.isIdempotent(request.getMethod())) { + return false; + } + return isPreDeliveryFailure(exception); + } + + /** + * 대상에 닿기 전 실패인가. + * + *

연결을 못 세운 경우(주소 해석·라우팅·TCP·TLS 실패)는 요청 자체가 나가지 않았지만 재시도하지 않는다 — + * 즉시 다시 시도해도 같은 결과가 나오는 결정론적 실패라, 남는 것은 지연뿐이다. 우리가 되살리려는 것은 + * 연결은 섰는데 그 위에서 끊긴 경우다. 대표적으로 커넥션 풀에 남아 있던 연결을 몰이 유휴 타임아웃으로 + * 닫는 순간과 우리 요청 전송이 겹치는 경합인데, 이때 몰은 요청을 받지 못했고 다시 붙으면 대개 성공한다. + */ + private boolean isPreDeliveryFailure(IOException exception) { + if (exception instanceof UnknownHostException + || exception instanceof ConnectException + || exception instanceof NoRouteToHostException + || exception instanceof SSLException) { + return false; + } + // 읽기 타임아웃(InterruptedIOException)은 몰이 이미 요청을 받아 처리 중일 수 있어 "닿기 전"이 아니다. + // 우리 예산을 한 번 더 태우기만 하므로 상위 큐로 넘긴다. + if (exception instanceof java.io.InterruptedIOException) { + return false; + } + return exception instanceof ConnectionClosedException + || exception instanceof org.apache.hc.core5.http.NoHttpResponseException + || exception instanceof java.net.SocketException; + } + + /** 응답까지 받은 요청은 여기서 재시도하지 않는다 — 429·503 을 포함해 전부 상위 큐 소관이다. */ + @Override + public boolean retryRequest(HttpResponse response, int execCount, HttpContext context) { + return false; + } + + /** 닿기 전 실패는 상대가 일을 한 적이 없어 백오프로 배려할 대상이 없다. 즉시 다시 붙는다. */ + @Override + public TimeValue getRetryInterval(HttpResponse response, int execCount, HttpContext context) { + return TimeValue.ZERO_MILLISECONDS; + } +} diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientRetryTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientRetryTest.java index 2173bd8..eb7a09b 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientRetryTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientRetryTest.java @@ -1,15 +1,24 @@ package com.depromeet.piki.extractor.extraction.http; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import io.micrometer.observation.ObservationRegistry; import java.io.IOException; +import java.io.InterruptedIOException; +import java.net.ConnectException; import java.net.InetAddress; import java.net.ServerSocket; import java.net.Socket; +import java.net.SocketException; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.NoHttpResponseException; +import org.apache.hc.core5.http.message.BasicClassicHttpRequest; +import org.apache.hc.core5.http.message.BasicHttpResponse; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; @@ -17,21 +26,70 @@ import org.springframework.web.client.RestClient; /** - * 페이지 fetch 클라이언트가 I/O 오류를 스스로 재시도하지 않는지 **실제 소켓으로** 검증한다. + * 재시도 층 분리를 못 박는다 — 경계는 대상 몰이 우리 요청을 봤는가 하나다. * - *

HttpClient5 는 이 재시도를 기본으로 켜 둔다(maxRetries=1, 대기 0ms). 파싱 재시도는 호출자(core)의 작업 큐 - * 한 곳에만 두는 것이 이 서비스의 방침이라 그 기본값을 끄는데, 끄는 코드가 사라져도 컴파일·기동·대부분의 테스트는 - * 멀쩡하다. 실제로 이 기본값이 살아 있는 걸 아무도 못 본 채 fetch 최악 시간이 두 배로 돌던 기간이 있었다. - * - *

MockRestServiceServer 로는 못 잡는다 — 그건 RestClient 위에 붙어 HttpClient 를 아예 타지 않는다. - * 그래서 연결을 받자마자 끊는 로컬 소켓을 두고 **도착한 연결 수**를 센다. + *

"닿기 전 끊김" 은 몰 입장에서 아무 일도 없었으므로 여기서 한 번 복구하고, "받고 나서 거부·느림" 은 몰이 + * 일을 한 것이라 전부 호출자(core)의 작업 큐가 소유한다. 두 축이 조용히 뒤집혀도 컴파일·기동은 멀쩡하므로 + * 값 자체를 고정한다 — 실제로 HttpClient5 기본값(429·503 까지 재시도)이 살아 있는 걸 아무도 못 본 기간이 있었다. */ class PageFetchHttpClientRetryTest { + private final PreDeliveryRetryStrategy strategy = new PreDeliveryRetryStrategy(); + + // --- 닿기 전 끊김: 여기서 한 번 복구한다 ----------------------------------- + + @Test + @DisplayName("연결은 섰는데 응답 전 끊기면 한 번 다시 붙는다 — 몰은 그 요청을 받지 못했다") + void recoversWhenConnectionDiesBeforeDelivery() { + for (IOException e : new IOException[] { + new NoHttpResponseException("target failed to respond"), + new SocketException("Connection reset"), + }) { + assertTrue(strategy.retryRequest(get(), e, 1, null), e.getClass().getSimpleName() + " 는 복구 대상"); + assertFalse(strategy.retryRequest(get(), e, 2, null), "복구는 한 번뿐"); + } + } + + @Test + @DisplayName("연결 자체를 못 세우면 다시 붙지 않는다 — 즉시 재시도해도 같은 결과라 지연만 남는다") + void doesNotRecoverWhenConnectionCannotBeEstablished() { + assertFalse(strategy.retryRequest(get(), new ConnectException("refused"), 1, null)); + assertFalse(strategy.retryRequest(get(), new java.net.UnknownHostException("nx"), 1, null)); + assertFalse(strategy.retryRequest(get(), new javax.net.ssl.SSLException("handshake"), 1, null)); + } + + @Test + @DisplayName("읽기 타임아웃은 복구하지 않는다 — 몰이 이미 받아 처리 중일 수 있다") + void doesNotRecoverOnReadTimeout() { + assertFalse(strategy.retryRequest(get(), new InterruptedIOException("read timed out"), 1, null)); + } + + @Test + @DisplayName("비멱등 메서드는 자동 반복하지 않는다 (RFC 9110 9.2.2)") + void doesNotRetryNonIdempotentMethods() { + BasicClassicHttpRequest post = new BasicClassicHttpRequest("POST", "/p"); + + assertFalse(strategy.retryRequest(post, new NoHttpResponseException("x"), 1, null)); + } + + // --- 받고 나서: 전부 상위 큐 소관 ------------------------------------------- + + @Test + @DisplayName("응답을 받은 요청은 여기서 재시도하지 않는다 — 429·503 도 상위 큐가 소유한다") + void neverRetriesOnceTheMallResponded() { + for (int status : new int[] {429, 503, 500, 403}) { + HttpResponse response = new BasicHttpResponse(status); + + assertFalse(strategy.retryRequest(response, 1, null), status + " 는 상위 큐 소관"); + } + } + + // --- 실제 소켓: 설정이 클라이언트에 실제로 물렸는가 --------------------------- + @Test @Timeout(value = 20, unit = TimeUnit.SECONDS) - @DisplayName("연결이 끊겨도 클라이언트가 스스로 다시 붙지 않는다 — 재시도는 호출자의 큐 한 곳에만 있다") - void doesNotRetryOnIoError() throws Exception { + @DisplayName("실제 클라이언트가 응답 전 끊김에 정확히 한 번 더 붙는다") + void wiredClientRetriesExactlyOnce() throws Exception { AtomicInteger connections = new AtomicInteger(); try (ServerSocket server = new ServerSocket(0, 0, InetAddress.getLoopbackAddress())) { @@ -39,9 +97,9 @@ void doesNotRetryOnIoError() throws Exception { while (!server.isClosed()) { try (Socket socket = server.accept()) { connections.incrementAndGet(); - socket.setSoLinger(true, 0); // RST 로 끊어 클라이언트에 I/O 오류를 준다 + socket.setSoLinger(true, 0); // RST 로 끊어 응답 전 I/O 오류를 만든다 } catch (IOException e) { - return; // 서버 종료 + return; } } }); @@ -58,10 +116,15 @@ void doesNotRetryOnIoError() throws Exception { .retrieve() .body(String.class)); - assertEquals(1, connections.get(), "재시도가 켜져 있으면 같은 곳에 2번 붙는다"); + // 최초 1회 + 복구 1회. 이 숫자가 1이면 복구가 죽은 것이고, 3 이상이면 상한이 풀린 것이다. + assertEquals(2, connections.get()); } } + private static BasicClassicHttpRequest get() { + return new BasicClassicHttpRequest("GET", "/p"); + } + private static RequestScopedDnsResolver loopbackResolver() { return new RequestScopedDnsResolver(host -> new InetAddress[] {InetAddress.getLoopbackAddress()}); } From a2835821d382ae4d0ae11631169c67a4df59ba07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:20:18 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20=EC=A0=84=EC=86=A1=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC=20=EB=8C=80=EC=83=81=EC=9D=84=20NoHttpResponseExcepti?= =?UTF-8?q?on=20=ED=95=98=EB=82=98=EB=A1=9C=20=EC=A2=81=ED=9E=8C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 리셋(SocketException)을 복구 대상에서 뺀다. 유휴 keep-alive 경합의 리셋일 수도, 차단측이 능동적으로 보낸 RST 일 수도 있어 예외 이름만으로 "몰이 봤나"를 판정할 수 없다. 실제 사고에서 몰측이 33초를 잡아둔 뒤 리셋했고 이를 재시도해 24초를 더 태웠다 - ConnectionClosedException 도 뺀다. 응답 도중 끊김을 포함해 몰이 일한 뒤일 수 있고, HttpClient5 기본 전략(5.5.2)조차 이것은 재시도하지 않는다 - 유휴 경합 자체는 재시도가 아니라 예방이 담당한다. 커넥션 풀이 유휴 2초 넘은 연결을 재사용 전에 검증해 버리는 동작(validateAfterInactivity)이 5.5.2 에서 이미 암묵 기본임을 소스로 확인했고, 좁힌 복구 집합이 이 예방을 전제하므로 설정에 명시로 고정한다 - 판정 규칙을 주석으로 못박는다: 예외 이름이 "몰이 봤나"를 단독으로 판정하지 못하면 복구 대상이 아니다. 모호성은 재시도로 덮지 않고 예방으로 없앤다 - 실소켓 테스트를 negative control 쌍으로 분리: 요청을 다 읽고 응답 없이 FIN 으로 닫는 서버에는 정확히 한 번 더 붙고(연결 수 2), RST 로 끊는 서버에는 다시 붙지 않는다(연결 수 1) --- .../http/PageFetchHttpClientConfig.java | 5 + .../http/PreDeliveryRetryStrategy.java | 66 ++++----- .../http/PageFetchHttpClientRetryTest.java | 134 ++++++++++++------ 3 files changed, 127 insertions(+), 78 deletions(-) diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientConfig.java b/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientConfig.java index 0ae089d..c9416c6 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientConfig.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientConfig.java @@ -11,6 +11,7 @@ import org.apache.hc.client5.http.impl.classic.HttpClients; import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.core5.util.TimeValue; import org.apache.hc.core5.util.Timeout; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -58,6 +59,10 @@ public String resolveCanonicalHostname(String host) throws UnknownHostException .setDefaultConnectionConfig( ConnectionConfig.custom() .setConnectTimeout(Timeout.ofMilliseconds(properties.connectTimeout().toMillis())) + // 풀에 남은 연결은 유휴 2초가 지나면 재사용 전에 살아있는지 검증한다. 몰이 keep-alive 로 이미 닫은 + // 연결에 요청을 쓰는 경합을 여기서 1차로 예방한다. 2초는 httpclient5 5.5.2 의 암묵 기본값이지만, + // 좁힌 재시도 정책(PreDeliveryRetryStrategy)이 이 예방을 전제하므로 기본값 변화에 흔들리지 않게 명시한다. + .setValidateAfterInactivity(TimeValue.ofSeconds(2)) .build()) .build(); CloseableHttpClient httpClient = diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/http/PreDeliveryRetryStrategy.java b/src/main/java/com/depromeet/piki/extractor/extraction/http/PreDeliveryRetryStrategy.java index a6228d0..0f85399 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/http/PreDeliveryRetryStrategy.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/http/PreDeliveryRetryStrategy.java @@ -1,15 +1,11 @@ package com.depromeet.piki.extractor.extraction.http; import java.io.IOException; -import java.net.ConnectException; -import java.net.NoRouteToHostException; -import java.net.UnknownHostException; -import javax.net.ssl.SSLException; import org.apache.hc.client5.http.HttpRequestRetryStrategy; -import org.apache.hc.core5.http.ConnectionClosedException; import org.apache.hc.core5.http.HttpRequest; import org.apache.hc.core5.http.HttpResponse; import org.apache.hc.core5.http.Method; +import org.apache.hc.core5.http.NoHttpResponseException; import org.apache.hc.core5.http.protocol.HttpContext; import org.apache.hc.core5.util.TimeValue; @@ -17,14 +13,33 @@ * "요청이 대상 몰에 닿기 전"에 끊긴 경우에만 in-process 로 한 번 복구한다. * *

경계는 대상 몰이 우리 요청을 봤는가 하나다. 못 봤으면 몰 입장에서 아무 일도 없었으므로 다시 붙는 것이 - * 부작용도 추가 부하도 없다 — RFC 9110 §9.2.2 가 "응답을 읽기 전 통신 실패는 자동 반복해도 된다"고 명시하는 + * 부작용도 추가 부하도 없다. RFC 9110 §9.2.2 가 "응답을 읽기 전 통신 실패는 자동 반복해도 된다"고 명시하는 * 부류이고, gRPC 는 이것을 transparent retry 라 부르며 재시도 횟수·예산에 세지도 않는다(gRFC A6). * 반대로 몰이 이미 받아서 거부(429/503)하거나 느린 경우는 몰이 일을 한 것이라, 되쏘면 그 자원을 한 번 더 쓴다. - * 그쪽 재시도는 호출자(core)의 작업 큐가 단독으로 소유한다 — 거기에만 attempt 기록·상한·관측이 있다. + * 그쪽 재시도는 호출자(core)의 작업 큐가 단독으로 소유한다. 거기에만 attempt 기록·상한·관측이 있다. * - *

HttpClient5 기본 전략({@code DefaultHttpRequestRetryStrategy})을 그대로 쓸 수 없는 이유는 그것이 위 둘을 - * 섞기 때문이다: I/O 실패 복구와 함께 429·503 응답까지 재시도한다. 서버가 "과부하다"라고 말한 요청을 - * 클라이언트가 되쏘는 동작이라, 계층 중첩 재시도의 표준 경고에 정면으로 걸린다. + *

복구 대상은 {@link NoHttpResponseException} 하나뿐이다. 요청은 나갔는데 응답 바이트를 하나도 받지 + * 못한 채 연결이 닫혔다는 뜻이라, "몰이 못 봤다"를 예외 이름만으로 판정할 수 있는 유일한 경우다. 실전에서는 + * 커넥션 풀의 유휴 검증(PageFetchHttpClientConfig 의 validateAfterInactivity)이 못 거른 찰나의 keep-alive + * 경합이 이 모양으로 온다. + * + *

판정 규칙: 예외 이름이 "몰이 봤나"를 단독으로 판정하지 못하면 복구 대상이 아니다. 모호성은 재시도로 + * 덮지 않고 예방(풀의 유휴 검증)으로 없앤다. 이 규칙으로 제외한 것들: + * + *

+ * + *

HttpClient5 기본 전략({@code DefaultHttpRequestRetryStrategy})을 그대로 쓸 수 없는 이유도 같은 규칙이다. + * 기본값은 429·503 응답과 {@code SocketException} 까지 재시도해, 몰이 봤거나 봤는지 모르는 요청을 몰의 의사와 + * 무관하게 되쏜다. * * @see RFC 9110 §9.2.2 * @see gRPC gRFC A6 @@ -44,41 +59,16 @@ public boolean retryRequest(HttpRequest request, IOException exception, int exec if (!Method.isIdempotent(request.getMethod())) { return false; } - return isPreDeliveryFailure(exception); - } - - /** - * 대상에 닿기 전 실패인가. - * - *

연결을 못 세운 경우(주소 해석·라우팅·TCP·TLS 실패)는 요청 자체가 나가지 않았지만 재시도하지 않는다 — - * 즉시 다시 시도해도 같은 결과가 나오는 결정론적 실패라, 남는 것은 지연뿐이다. 우리가 되살리려는 것은 - * 연결은 섰는데 그 위에서 끊긴 경우다. 대표적으로 커넥션 풀에 남아 있던 연결을 몰이 유휴 타임아웃으로 - * 닫는 순간과 우리 요청 전송이 겹치는 경합인데, 이때 몰은 요청을 받지 못했고 다시 붙으면 대개 성공한다. - */ - private boolean isPreDeliveryFailure(IOException exception) { - if (exception instanceof UnknownHostException - || exception instanceof ConnectException - || exception instanceof NoRouteToHostException - || exception instanceof SSLException) { - return false; - } - // 읽기 타임아웃(InterruptedIOException)은 몰이 이미 요청을 받아 처리 중일 수 있어 "닿기 전"이 아니다. - // 우리 예산을 한 번 더 태우기만 하므로 상위 큐로 넘긴다. - if (exception instanceof java.io.InterruptedIOException) { - return false; - } - return exception instanceof ConnectionClosedException - || exception instanceof org.apache.hc.core5.http.NoHttpResponseException - || exception instanceof java.net.SocketException; + return exception instanceof NoHttpResponseException; } - /** 응답까지 받은 요청은 여기서 재시도하지 않는다 — 429·503 을 포함해 전부 상위 큐 소관이다. */ + /** 응답까지 받은 요청은 여기서 재시도하지 않는다. 429·503 을 포함해 전부 상위 큐 소관이다. */ @Override public boolean retryRequest(HttpResponse response, int execCount, HttpContext context) { return false; } - /** 닿기 전 실패는 상대가 일을 한 적이 없어 백오프로 배려할 대상이 없다. 즉시 다시 붙는다. */ + /** 복구 대상은 몰이 일을 한 적이 없는 경우라 백오프로 배려할 대상이 없다. 즉시 다시 붙는다. */ @Override public TimeValue getRetryInterval(HttpResponse response, int execCount, HttpContext context) { return TimeValue.ZERO_MILLISECONDS; diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientRetryTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientRetryTest.java index eb7a09b..60abf8a 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientRetryTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientRetryTest.java @@ -7,6 +7,7 @@ import io.micrometer.observation.ObservationRegistry; import java.io.IOException; +import java.io.InputStream; import java.io.InterruptedIOException; import java.net.ConnectException; import java.net.InetAddress; @@ -15,6 +16,7 @@ import java.net.SocketException; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import org.apache.hc.core5.http.ConnectionClosedException; import org.apache.hc.core5.http.HttpResponse; import org.apache.hc.core5.http.NoHttpResponseException; import org.apache.hc.core5.http.message.BasicClassicHttpRequest; @@ -26,11 +28,10 @@ import org.springframework.web.client.RestClient; /** - * 재시도 층 분리를 못 박는다 — 경계는 대상 몰이 우리 요청을 봤는가 하나다. - * - *

"닿기 전 끊김" 은 몰 입장에서 아무 일도 없었으므로 여기서 한 번 복구하고, "받고 나서 거부·느림" 은 몰이 - * 일을 한 것이라 전부 호출자(core)의 작업 큐가 소유한다. 두 축이 조용히 뒤집혀도 컴파일·기동은 멀쩡하므로 - * 값 자체를 고정한다 — 실제로 HttpClient5 기본값(429·503 까지 재시도)이 살아 있는 걸 아무도 못 본 기간이 있었다. + * 재시도 층 분리를 못 박는다. 경계는 대상 몰이 우리 요청을 봤는가 하나이고, 그것을 예외 이름만으로 판정할 + * 수 있는 NoHttpResponseException(응답 0바이트 종료)만 여기서 한 번 복구한다. 나머지는 전부 호출자(core)의 + * 작업 큐가 소유한다. 두 축이 조용히 뒤집혀도 컴파일·기동은 멀쩡하므로 값 자체를 고정한다. 실제로 HttpClient5 + * 기본값(429·503·리셋까지 재시도)이 살아 있는 걸 아무도 못 본 기간이 있었다. */ class PageFetchHttpClientRetryTest { @@ -39,19 +40,30 @@ class PageFetchHttpClientRetryTest { // --- 닿기 전 끊김: 여기서 한 번 복구한다 ----------------------------------- @Test - @DisplayName("연결은 섰는데 응답 전 끊기면 한 번 다시 붙는다 — 몰은 그 요청을 받지 못했다") + @DisplayName("응답 0바이트로 연결이 닫히면 한 번 다시 붙는다 - 몰이 못 봤다고 예외 이름으로 판정되는 유일한 경우") void recoversWhenConnectionDiesBeforeDelivery() { - for (IOException e : new IOException[] { - new NoHttpResponseException("target failed to respond"), - new SocketException("Connection reset"), - }) { - assertTrue(strategy.retryRequest(get(), e, 1, null), e.getClass().getSimpleName() + " 는 복구 대상"); - assertFalse(strategy.retryRequest(get(), e, 2, null), "복구는 한 번뿐"); - } + NoHttpResponseException e = new NoHttpResponseException("target failed to respond"); + + assertTrue(strategy.retryRequest(get(), e, 1, null)); + assertFalse(strategy.retryRequest(get(), e, 2, null), "복구는 한 번뿐"); + } + + // --- 모호하거나 몰이 봤을 수 있는 실패: 전부 상위 큐 소관 --------------------- + + @Test + @DisplayName("리셋은 복구하지 않는다 - 차단측이 능동적으로 보낸 RST 와 구분할 수 없다") + void doesNotRecoverOnConnectionReset() { + assertFalse(strategy.retryRequest(get(), new SocketException("Connection reset"), 1, null)); + } + + @Test + @DisplayName("응답 도중 끊김은 복구하지 않는다 - 몰이 일한 뒤일 수 있다") + void doesNotRecoverOnPrematureClose() { + assertFalse(strategy.retryRequest(get(), new ConnectionClosedException("premature end"), 1, null)); } @Test - @DisplayName("연결 자체를 못 세우면 다시 붙지 않는다 — 즉시 재시도해도 같은 결과라 지연만 남는다") + @DisplayName("연결 자체를 못 세우면 다시 붙지 않는다 - 즉시 재시도해도 같은 결과라 지연만 남는다") void doesNotRecoverWhenConnectionCannotBeEstablished() { assertFalse(strategy.retryRequest(get(), new ConnectException("refused"), 1, null)); assertFalse(strategy.retryRequest(get(), new java.net.UnknownHostException("nx"), 1, null)); @@ -59,7 +71,7 @@ void doesNotRecoverWhenConnectionCannotBeEstablished() { } @Test - @DisplayName("읽기 타임아웃은 복구하지 않는다 — 몰이 이미 받아 처리 중일 수 있다") + @DisplayName("읽기 타임아웃은 복구하지 않는다 - 몰이 이미 받아 처리 중일 수 있다") void doesNotRecoverOnReadTimeout() { assertFalse(strategy.retryRequest(get(), new InterruptedIOException("read timed out"), 1, null)); } @@ -72,10 +84,8 @@ void doesNotRetryNonIdempotentMethods() { assertFalse(strategy.retryRequest(post, new NoHttpResponseException("x"), 1, null)); } - // --- 받고 나서: 전부 상위 큐 소관 ------------------------------------------- - @Test - @DisplayName("응답을 받은 요청은 여기서 재시도하지 않는다 — 429·503 도 상위 큐가 소유한다") + @DisplayName("응답을 받은 요청은 여기서 재시도하지 않는다 - 429·503 도 상위 큐가 소유한다") void neverRetriesOnceTheMallResponded() { for (int status : new int[] {429, 503, 500, 403}) { HttpResponse response = new BasicHttpResponse(status); @@ -88,39 +98,83 @@ void neverRetriesOnceTheMallResponded() { @Test @Timeout(value = 20, unit = TimeUnit.SECONDS) - @DisplayName("실제 클라이언트가 응답 전 끊김에 정확히 한 번 더 붙는다") - void wiredClientRetriesExactlyOnce() throws Exception { + @DisplayName("실제 클라이언트가 응답 0바이트 종료에 정확히 한 번 더 붙는다") + void wiredClientRetriesExactlyOnceOnCloseWithoutResponse() throws Exception { AtomicInteger connections = new AtomicInteger(); try (ServerSocket server = new ServerSocket(0, 0, InetAddress.getLoopbackAddress())) { - Thread accepter = new Thread(() -> { - while (!server.isClosed()) { - try (Socket socket = server.accept()) { - connections.incrementAndGet(); - socket.setSoLinger(true, 0); // RST 로 끊어 응답 전 I/O 오류를 만든다 - } catch (IOException e) { - return; - } - } + serveEachConnection(server, socket -> { + connections.incrementAndGet(); + drainRequestHead(socket); // 요청을 다 읽은 뒤 응답 없이 닫는다(FIN) -> NoHttpResponseException }); - accepter.setDaemon(true); - accepter.start(); - RestClient client = new PageFetchHttpClientConfig() - .pageFetchRestClient(ObservationRegistry.NOOP, loopbackResolver(), FetchProperties.defaults()); - - assertThrows( - ResourceAccessException.class, - () -> client.get() - .uri("http://127.0.0.1:" + server.getLocalPort() + "/p") - .retrieve() - .body(String.class)); + assertThrows(ResourceAccessException.class, () -> fetch(server)); // 최초 1회 + 복구 1회. 이 숫자가 1이면 복구가 죽은 것이고, 3 이상이면 상한이 풀린 것이다. assertEquals(2, connections.get()); } } + @Test + @Timeout(value = 20, unit = TimeUnit.SECONDS) + @DisplayName("실제 클라이언트가 리셋(RST)에는 다시 붙지 않는다") + void wiredClientDoesNotRetryOnReset() throws Exception { + AtomicInteger connections = new AtomicInteger(); + + try (ServerSocket server = new ServerSocket(0, 0, InetAddress.getLoopbackAddress())) { + serveEachConnection(server, socket -> { + connections.incrementAndGet(); + socket.setSoLinger(true, 0); // close 가 RST 를 보내 차단측 리셋과 같은 모양을 만든다 + }); + + assertThrows(ResourceAccessException.class, () -> fetch(server)); + + assertEquals(1, connections.get()); + } + } + + private static void fetch(ServerSocket server) { + RestClient client = new PageFetchHttpClientConfig() + .pageFetchRestClient(ObservationRegistry.NOOP, loopbackResolver(), FetchProperties.defaults()); + client.get() + .uri("http://127.0.0.1:" + server.getLocalPort() + "/p") + .retrieve() + .body(String.class); + } + + /** 커넥션마다 handler 를 한 번 적용하고 닫는 단순 서버. accept 루프는 데몬 스레드로 돈다. */ + private static void serveEachConnection(ServerSocket server, SocketHandler handler) { + Thread accepter = new Thread(() -> { + while (!server.isClosed()) { + try (Socket socket = server.accept()) { + handler.handle(socket); + } catch (IOException e) { + return; + } + } + }); + accepter.setDaemon(true); + accepter.start(); + } + + /** 요청 헤더 끝(CRLFCRLF)까지 읽는다. GET 이라 본문은 없다. */ + private static void drainRequestHead(Socket socket) throws IOException { + InputStream in = socket.getInputStream(); + int tail = 0; + int b; + while ((b = in.read()) != -1) { + tail = (tail << 8) | b; + if (tail == 0x0D0A0D0A) { + return; + } + } + } + + @FunctionalInterface + private interface SocketHandler { + void handle(Socket socket) throws IOException; + } + private static BasicClassicHttpRequest get() { return new BasicClassicHttpRequest("GET", "/p"); } From d2a351db09b843e1614f29bbb887dc7fe41a7f40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:36:04 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=20=EC=84=9C=EC=88=A0=EC=9D=84=20"=ED=94=8C?= =?UTF-8?q?=EB=9E=AB=ED=8F=BC=20=EC=84=9C=EB=B2=84"=20=EC=9A=A9=EC=96=B4?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=EC=9D=BC=ED=95=98=EA=B3=A0=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC=20=EA=B7=BC=EA=B1=B0=EB=A5=BC=20=EC=A0=95=EB=B0=80?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 주석·테스트 표시명의 "몰"을 "플랫폼 서버"로 통일 (도메인 용어 결정) - NoHttpResponseException 복구 근거를 정확하게 고쳐 적는다. 이름이 보증하는 사실은 "서버가 못 봤다"가 아니라 "응답 바이트를 하나도 받기 전에 연결이 끝났다"이다. 쓰기 성공은 로컬 소켓 버퍼 도착일 뿐 상대 도달 보증이 아니라서 지배적 원인(유휴 keep-alive 경합)에선 서버가 요청을 읽은 적이 없고, 드물게 읽고도 응답 없이 닫은 경우라도 멱등 GET 재요청은 무해하다 - 판정 규칙도 같은 정밀도로 수정: 예외 이름만으로 "다시 보내도 안전"이 보증되지 않으면 복구 대상이 아니다 --- .../http/PageFetchHttpClientConfig.java | 4 +- .../http/PreDeliveryRetryStrategy.java | 38 ++++++++++--------- .../http/PageFetchHttpClientRetryTest.java | 12 +++--- 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientConfig.java b/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientConfig.java index c9416c6..86ff8b0 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientConfig.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientConfig.java @@ -59,7 +59,7 @@ public String resolveCanonicalHostname(String host) throws UnknownHostException .setDefaultConnectionConfig( ConnectionConfig.custom() .setConnectTimeout(Timeout.ofMilliseconds(properties.connectTimeout().toMillis())) - // 풀에 남은 연결은 유휴 2초가 지나면 재사용 전에 살아있는지 검증한다. 몰이 keep-alive 로 이미 닫은 + // 풀에 남은 연결은 유휴 2초가 지나면 재사용 전에 살아있는지 검증한다. 플랫폼 서버가 keep-alive 로 이미 닫은 // 연결에 요청을 쓰는 경합을 여기서 1차로 예방한다. 2초는 httpclient5 5.5.2 의 암묵 기본값이지만, // 좁힌 재시도 정책(PreDeliveryRetryStrategy)이 이 예방을 전제하므로 기본값 변화에 흔들리지 않게 명시한다. .setValidateAfterInactivity(TimeValue.ofSeconds(2)) @@ -72,7 +72,7 @@ public String resolveCanonicalHostname(String host) throws UnknownHostException // 따라가므로(JDK 의 instanceFollowRedirects=false 등가물), 라이브러리 자동 추적을 끈다. 끄지 않으면 // HttpPageFetcher.nextRedirect 의 cross-domain·다운그레이드 차단이 우회된다. .disableRedirectHandling() - // 재시도는 "대상 몰이 우리 요청을 봤는가" 로 층을 가른다 — 못 봤으면 여기서 한 번 복구하고, + // 재시도는 "플랫폼 서버가 우리 요청을 봤는가" 로 층을 가른다. 못 봤으면 여기서 한 번 복구하고, // 봤으면(429·503·느림) 전부 호출자(core)의 작업 큐가 소유한다. 근거는 PreDeliveryRetryStrategy. // HttpClient5 기본 전략은 그 둘을 섞어(429·503 까지 재시도) 우리 방침 밖에서 겹치므로 쓰지 않는다. .setRetryStrategy(new PreDeliveryRetryStrategy()) diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/http/PreDeliveryRetryStrategy.java b/src/main/java/com/depromeet/piki/extractor/extraction/http/PreDeliveryRetryStrategy.java index 0f85399..14c6441 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/http/PreDeliveryRetryStrategy.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/http/PreDeliveryRetryStrategy.java @@ -10,36 +10,38 @@ import org.apache.hc.core5.util.TimeValue; /** - * "요청이 대상 몰에 닿기 전"에 끊긴 경우에만 in-process 로 한 번 복구한다. + * "요청이 플랫폼 서버에 닿기 전"에 끊긴 경우에만 in-process 로 한 번 복구한다. * - *

경계는 대상 몰이 우리 요청을 봤는가 하나다. 못 봤으면 몰 입장에서 아무 일도 없었으므로 다시 붙는 것이 - * 부작용도 추가 부하도 없다. RFC 9110 §9.2.2 가 "응답을 읽기 전 통신 실패는 자동 반복해도 된다"고 명시하는 - * 부류이고, gRPC 는 이것을 transparent retry 라 부르며 재시도 횟수·예산에 세지도 않는다(gRFC A6). - * 반대로 몰이 이미 받아서 거부(429/503)하거나 느린 경우는 몰이 일을 한 것이라, 되쏘면 그 자원을 한 번 더 쓴다. - * 그쪽 재시도는 호출자(core)의 작업 큐가 단독으로 소유한다. 거기에만 attempt 기록·상한·관측이 있다. + *

경계는 플랫폼 서버가 우리 요청을 봤는가 하나다. 못 봤으면 서버 입장에서 아무 일도 없었으므로 다시 + * 붙는 것이 부작용도 추가 부하도 없다. RFC 9110 §9.2.2 가 "응답을 읽기 전 통신 실패는 자동 반복해도 된다"고 + * 명시하는 부류이고, gRPC 는 이것을 transparent retry 라 부르며 재시도 횟수·예산에 세지도 않는다(gRFC A6). + * 반대로 서버가 이미 받아서 거부(429/503)하거나 느린 경우는 서버가 일을 한 것이라, 되쏘면 그 자원을 한 번 더 + * 쓴다. 그쪽 재시도는 호출자(core)의 파싱 작업 큐가 단독으로 소유한다. 거기에만 attempt 기록·상한·관측이 있다. * - *

복구 대상은 {@link NoHttpResponseException} 하나뿐이다. 요청은 나갔는데 응답 바이트를 하나도 받지 - * 못한 채 연결이 닫혔다는 뜻이라, "몰이 못 봤다"를 예외 이름만으로 판정할 수 있는 유일한 경우다. 실전에서는 + *

복구 대상은 {@link NoHttpResponseException} 하나뿐이다. 이 이름이 보증하는 사실은 "요청을 쓴 뒤 응답 + * 바이트를 하나도 받기 전에 연결이 끝났다"이고, 정확히 RFC 9110 이 자동 반복을 허용하는 조건이다. 지배적 원인은 * 커넥션 풀의 유휴 검증(PageFetchHttpClientConfig 의 validateAfterInactivity)이 못 거른 찰나의 keep-alive - * 경합이 이 모양으로 온다. + * 경합인데, 이때 서버가 이미 닫은 연결에 우리가 쓴 것이라 서버는 요청을 읽은 적이 없다(쓰기 성공은 로컬 소켓 + * 버퍼 도착일 뿐 상대 도달의 보증이 아니다). 드물게 서버가 읽고도 응답 없이 닫았을 수 있으나, 그 경우에도 멱등 + * GET 의 재요청은 무해하다. * - *

판정 규칙: 예외 이름이 "몰이 봤나"를 단독으로 판정하지 못하면 복구 대상이 아니다. 모호성은 재시도로 - * 덮지 않고 예방(풀의 유휴 검증)으로 없앤다. 이 규칙으로 제외한 것들: + *

판정 규칙: 예외 이름만으로 "다시 보내도 안전하다"가 보증되지 않으면 복구 대상이 아니다. 모호성은 + * 재시도로 덮지 않고 예방(풀의 유휴 검증)으로 없앤다. 이 규칙으로 제외한 것들: * *

* *

HttpClient5 기본 전략({@code DefaultHttpRequestRetryStrategy})을 그대로 쓸 수 없는 이유도 같은 규칙이다. - * 기본값은 429·503 응답과 {@code SocketException} 까지 재시도해, 몰이 봤거나 봤는지 모르는 요청을 몰의 의사와 - * 무관하게 되쏜다. + * 기본값은 429·503 응답과 {@code SocketException} 까지 재시도해, 서버가 봤거나 봤는지 모르는 요청을 서버의 + * 의사와 무관하게 되쏜다. * * @see RFC 9110 §9.2.2 * @see gRPC gRFC A6 @@ -62,13 +64,13 @@ public boolean retryRequest(HttpRequest request, IOException exception, int exec return exception instanceof NoHttpResponseException; } - /** 응답까지 받은 요청은 여기서 재시도하지 않는다. 429·503 을 포함해 전부 상위 큐 소관이다. */ + /** 응답까지 받은 요청은 여기서 재시도하지 않는다. 429·503 을 포함해 전부 core 파싱 작업 큐 소관이다. */ @Override public boolean retryRequest(HttpResponse response, int execCount, HttpContext context) { return false; } - /** 복구 대상은 몰이 일을 한 적이 없는 경우라 백오프로 배려할 대상이 없다. 즉시 다시 붙는다. */ + /** 복구 대상은 서버가 일을 한 적이 없는 경우라 백오프로 배려할 대상이 없다. 즉시 다시 붙는다. */ @Override public TimeValue getRetryInterval(HttpResponse response, int execCount, HttpContext context) { return TimeValue.ZERO_MILLISECONDS; diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientRetryTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientRetryTest.java index 60abf8a..dbf47fc 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientRetryTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientRetryTest.java @@ -28,8 +28,8 @@ import org.springframework.web.client.RestClient; /** - * 재시도 층 분리를 못 박는다. 경계는 대상 몰이 우리 요청을 봤는가 하나이고, 그것을 예외 이름만으로 판정할 - * 수 있는 NoHttpResponseException(응답 0바이트 종료)만 여기서 한 번 복구한다. 나머지는 전부 호출자(core)의 + * 재시도 층 분리를 못 박는다. 경계는 플랫폼 서버가 우리 요청을 봤는가 하나이고, 재전송 안전이 예외 + * 이름만으로 보증되는 NoHttpResponseException(응답 0바이트 종료)만 여기서 한 번 복구한다. 나머지는 전부 호출자(core)의 * 작업 큐가 소유한다. 두 축이 조용히 뒤집혀도 컴파일·기동은 멀쩡하므로 값 자체를 고정한다. 실제로 HttpClient5 * 기본값(429·503·리셋까지 재시도)이 살아 있는 걸 아무도 못 본 기간이 있었다. */ @@ -40,7 +40,7 @@ class PageFetchHttpClientRetryTest { // --- 닿기 전 끊김: 여기서 한 번 복구한다 ----------------------------------- @Test - @DisplayName("응답 0바이트로 연결이 닫히면 한 번 다시 붙는다 - 몰이 못 봤다고 예외 이름으로 판정되는 유일한 경우") + @DisplayName("응답 0바이트로 연결이 닫히면 한 번 다시 붙는다 - 재전송 안전이 예외 이름만으로 보증되는 유일한 경우") void recoversWhenConnectionDiesBeforeDelivery() { NoHttpResponseException e = new NoHttpResponseException("target failed to respond"); @@ -48,7 +48,7 @@ void recoversWhenConnectionDiesBeforeDelivery() { assertFalse(strategy.retryRequest(get(), e, 2, null), "복구는 한 번뿐"); } - // --- 모호하거나 몰이 봤을 수 있는 실패: 전부 상위 큐 소관 --------------------- + // --- 모호하거나 플랫폼 서버가 봤을 수 있는 실패: 전부 core 작업 큐 소관 -------- @Test @DisplayName("리셋은 복구하지 않는다 - 차단측이 능동적으로 보낸 RST 와 구분할 수 없다") @@ -57,7 +57,7 @@ void doesNotRecoverOnConnectionReset() { } @Test - @DisplayName("응답 도중 끊김은 복구하지 않는다 - 몰이 일한 뒤일 수 있다") + @DisplayName("응답 도중 끊김은 복구하지 않는다 - 플랫폼 서버가 일한 뒤일 수 있다") void doesNotRecoverOnPrematureClose() { assertFalse(strategy.retryRequest(get(), new ConnectionClosedException("premature end"), 1, null)); } @@ -71,7 +71,7 @@ void doesNotRecoverWhenConnectionCannotBeEstablished() { } @Test - @DisplayName("읽기 타임아웃은 복구하지 않는다 - 몰이 이미 받아 처리 중일 수 있다") + @DisplayName("읽기 타임아웃은 복구하지 않는다 - 플랫폼 서버가 이미 받아 처리 중일 수 있다") void doesNotRecoverOnReadTimeout() { assertFalse(strategy.retryRequest(get(), new InterruptedIOException("read timed out"), 1, null)); }