fix(comment-cards): 일시적 YouTube 403/5xx 자동 재시도

commentThreads가 order=relevance에서 간헐적으로 403 forbidden을 반환하는 문제 대응.
generic 403/rateLimit/5xx는 백오프 후 최대 2회 재시도, commentsDisabled/quotaExceeded는
재시도 없이 즉시 안내. 재시도 성공/비재시도 단위 테스트 추가.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-06-29 18:13:19 +09:00
parent 6f59554b6d
commit 6136eb83e9
2 changed files with 73 additions and 1 deletions

View File

@ -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<CommentCardDto> 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;

View File

@ -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);