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..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 @@ -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 = @@ -67,6 +72,10 @@ 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()) .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..14c6441 --- /dev/null +++ b/src/main/java/com/depromeet/piki/extractor/extraction/http/PreDeliveryRetryStrategy.java @@ -0,0 +1,78 @@ +package com.depromeet.piki.extractor.extraction.http; + +import java.io.IOException; +import org.apache.hc.client5.http.HttpRequestRetryStrategy; +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; + +/** + * "요청이 플랫폼 서버에 닿기 전"에 끊긴 경우에만 in-process 로 한 번 복구한다. + * + *

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

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

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

+ * + *

HttpClient5 기본 전략({@code DefaultHttpRequestRetryStrategy})을 그대로 쓸 수 없는 이유도 같은 규칙이다. + * 기본값은 429·503 응답과 {@code SocketException} 까지 재시도해, 서버가 봤거나 봤는지 모르는 요청을 서버의 + * 의사와 무관하게 되쏜다. + * + * @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 exception instanceof NoHttpResponseException; + } + + /** 응답까지 받은 요청은 여기서 재시도하지 않는다. 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 new file mode 100644 index 0000000..dbf47fc --- /dev/null +++ b/src/test/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientRetryTest.java @@ -0,0 +1,185 @@ +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.InputStream; +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.ConnectionClosedException; +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; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClient; + +/** + * 재시도 층 분리를 못 박는다. 경계는 플랫폼 서버가 우리 요청을 봤는가 하나이고, 재전송 안전이 예외 + * 이름만으로 보증되는 NoHttpResponseException(응답 0바이트 종료)만 여기서 한 번 복구한다. 나머지는 전부 호출자(core)의 + * 작업 큐가 소유한다. 두 축이 조용히 뒤집혀도 컴파일·기동은 멀쩡하므로 값 자체를 고정한다. 실제로 HttpClient5 + * 기본값(429·503·리셋까지 재시도)이 살아 있는 걸 아무도 못 본 기간이 있었다. + */ +class PageFetchHttpClientRetryTest { + + private final PreDeliveryRetryStrategy strategy = new PreDeliveryRetryStrategy(); + + // --- 닿기 전 끊김: 여기서 한 번 복구한다 ----------------------------------- + + @Test + @DisplayName("응답 0바이트로 연결이 닫히면 한 번 다시 붙는다 - 재전송 안전이 예외 이름만으로 보증되는 유일한 경우") + void recoversWhenConnectionDiesBeforeDelivery() { + NoHttpResponseException e = new NoHttpResponseException("target failed to respond"); + + assertTrue(strategy.retryRequest(get(), e, 1, null)); + assertFalse(strategy.retryRequest(get(), e, 2, null), "복구는 한 번뿐"); + } + + // --- 모호하거나 플랫폼 서버가 봤을 수 있는 실패: 전부 core 작업 큐 소관 -------- + + @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("연결 자체를 못 세우면 다시 붙지 않는다 - 즉시 재시도해도 같은 결과라 지연만 남는다") + 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("실제 클라이언트가 응답 0바이트 종료에 정확히 한 번 더 붙는다") + void wiredClientRetriesExactlyOnceOnCloseWithoutResponse() throws Exception { + AtomicInteger connections = new AtomicInteger(); + + try (ServerSocket server = new ServerSocket(0, 0, InetAddress.getLoopbackAddress())) { + serveEachConnection(server, socket -> { + connections.incrementAndGet(); + drainRequestHead(socket); // 요청을 다 읽은 뒤 응답 없이 닫는다(FIN) -> NoHttpResponseException + }); + + 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"); + } + + private static RequestScopedDnsResolver loopbackResolver() { + return new RequestScopedDnsResolver(host -> new InetAddress[] {InetAddress.getLoopbackAddress()}); + } +}