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:
parent
6f59554b6d
commit
6136eb83e9
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user