From 6136eb83e948affe49df3459efb143efd9cf9506 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Mon, 29 Jun 2026 18:13:19 +0900 Subject: [PATCH] =?UTF-8?q?fix(comment-cards):=20=EC=9D=BC=EC=8B=9C?= =?UTF-8?q?=EC=A0=81=20YouTube=20403/5xx=20=EC=9E=90=EB=8F=99=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EB=8F=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commentThreads가 order=relevance에서 간헐적으로 403 forbidden을 반환하는 문제 대응. generic 403/rateLimit/5xx는 백오프 후 최대 2회 재시도, commentsDisabled/quotaExceeded는 재시도 없이 즉시 안내. 재시도 성공/비재시도 단위 테스트 추가. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../service/YoutubeCommentService.java | 43 ++++++++++++++++++- .../service/YoutubeCommentServiceTest.java | 31 +++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/hlab/yanalyst/service/YoutubeCommentService.java b/src/main/java/com/hlab/yanalyst/service/YoutubeCommentService.java index 7dcfea1..3578e23 100644 --- a/src/main/java/com/hlab/yanalyst/service/YoutubeCommentService.java +++ b/src/main/java/com/hlab/yanalyst/service/YoutubeCommentService.java @@ -33,6 +33,10 @@ public class YoutubeCommentService { private static final ObjectMapper MAPPER = new ObjectMapper(); + /** 일시적 오류(transient 403/5xx) 재시도 횟수와 백오프(ms). 테스트에서 0으로 낮춤. */ + private static final int MAX_RETRIES = 2; + private long retryBackoffMs = 600; + public List fetchComments(String urlOrId) { String videoId = YoutubeVideoIdParser.parse(urlOrId); if (videoId == null) { @@ -58,7 +62,7 @@ public class YoutubeCommentService { JsonNode root; try { - root = restTemplate.getForObject(b.build().encode().toUri(), JsonNode.class); + root = fetchPageWithRetry(b.build().encode().toUri()); } catch (RestClientResponseException e) { // 일부라도 모았으면 그걸로 반환(다음 페이지 실패는 무시) if (!result.isEmpty()) break; @@ -87,6 +91,43 @@ public class YoutubeCommentService { return result; } + /** + * 페이지 1개를 호출하되, 일시적 오류(generic 403 forbidden, rateLimitExceeded, 5xx 등)는 재시도한다. + * 회복 불가능한 오류(commentsDisabled/quotaExceeded 등)는 즉시 전파해 불필요한 대기를 막는다. + */ + private JsonNode fetchPageWithRetry(java.net.URI uri) { + RestClientResponseException last = null; + for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + return restTemplate.getForObject(uri, JsonNode.class); + } catch (RestClientResponseException e) { + if (isNonRetryable(extractReason(e.getResponseBodyAsString()))) { + throw e; + } + last = e; + if (attempt < MAX_RETRIES) { + sleepQuietly(retryBackoffMs * (attempt + 1)); + } + } + } + throw last; + } + + /** 재시도해도 회복되지 않는 reason(영구적 거부). */ + private static boolean isNonRetryable(String reason) { + return "commentsDisabled".equals(reason) + || "quotaExceeded".equals(reason) + || "dailyLimitExceeded".equals(reason); + } + + private static void sleepQuietly(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + /** YouTube 오류 응답 본문에서 error.errors[0].reason 추출. 실패 시 null. */ private static String extractReason(String body) { if (body == null || body.isBlank()) return null; diff --git a/src/test/java/com/hlab/yanalyst/service/YoutubeCommentServiceTest.java b/src/test/java/com/hlab/yanalyst/service/YoutubeCommentServiceTest.java index ff41e2a..e13f4d8 100644 --- a/src/test/java/com/hlab/yanalyst/service/YoutubeCommentServiceTest.java +++ b/src/test/java/com/hlab/yanalyst/service/YoutubeCommentServiceTest.java @@ -25,9 +25,19 @@ class YoutubeCommentServiceTest { when(guard.tryConsume(anyLong())).thenReturn(true); YoutubeCommentService svc = new YoutubeCommentService(rt, guard); ReflectionTestUtils.setField(svc, "youtubeApiKey", "TEST_KEY"); + ReflectionTestUtils.setField(svc, "retryBackoffMs", 0L); // 테스트 속도 return svc; } + private static final String OK_BODY = + "{\"items\":[{\"snippet\":{\"totalReplyCount\":2,\"topLevelComment\":{\"snippet\":{" + + "\"authorDisplayName\":\"a\",\"authorProfileImageUrl\":\"https://yt3.ggpht.com/x\"," + + "\"textDisplay\":\"hi\",\"likeCount\":5,\"publishedAt\":\"2020-01-01T00:00:00Z\"}}}}]}"; + + private com.fasterxml.jackson.databind.JsonNode okNode() throws Exception { + return new com.fasterxml.jackson.databind.ObjectMapper().readTree(OK_BODY); + } + private HttpClientErrorException apiError(HttpStatus status, String reason) { String body = "{\"error\":{\"code\":" + status.value() + ",\"message\":\"x\",\"errors\":[{\"reason\":\"" + reason + "\"}]}}"; @@ -56,6 +66,27 @@ class YoutubeCommentServiceTest { assertFalse(ex.getMessage().contains("비활성화"), ex.getMessage()); } + @Test + void transientForbidden_retriesThenSucceeds() throws Exception { + RestTemplate rt = mock(RestTemplate.class); + when(rt.getForObject(any(URI.class), eq(JsonNode.class))) + .thenThrow(apiError(HttpStatus.FORBIDDEN, "forbidden")) // 일시적 403 1회 + .thenReturn(okNode()); // 재시도 성공 + var out = newService(rt).fetchComments("dQw4w9WgXcQ"); + assertEquals(1, out.size()); + assertEquals("hi", out.get(0).getText()); + verify(rt, times(2)).getForObject(any(URI.class), eq(JsonNode.class)); + } + + @Test + void commentsDisabled_doesNotRetry() { + RestTemplate rt = mock(RestTemplate.class); + when(rt.getForObject(any(URI.class), eq(JsonNode.class))) + .thenThrow(apiError(HttpStatus.FORBIDDEN, "commentsDisabled")); + assertThrows(IllegalArgumentException.class, () -> newService(rt).fetchComments("dQw4w9WgXcQ")); + verify(rt, times(1)).getForObject(any(URI.class), eq(JsonNode.class)); // 재시도 없음 + } + @Test void invalidKey_throwsMeaningfulMessage_notDisabled() { RestTemplate rt = mock(RestTemplate.class);