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()}); + } +}