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();
|
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) {
|
public List<CommentCardDto> fetchComments(String urlOrId) {
|
||||||
String videoId = YoutubeVideoIdParser.parse(urlOrId);
|
String videoId = YoutubeVideoIdParser.parse(urlOrId);
|
||||||
if (videoId == null) {
|
if (videoId == null) {
|
||||||
@ -58,7 +62,7 @@ public class YoutubeCommentService {
|
|||||||
|
|
||||||
JsonNode root;
|
JsonNode root;
|
||||||
try {
|
try {
|
||||||
root = restTemplate.getForObject(b.build().encode().toUri(), JsonNode.class);
|
root = fetchPageWithRetry(b.build().encode().toUri());
|
||||||
} catch (RestClientResponseException e) {
|
} catch (RestClientResponseException e) {
|
||||||
// 일부라도 모았으면 그걸로 반환(다음 페이지 실패는 무시)
|
// 일부라도 모았으면 그걸로 반환(다음 페이지 실패는 무시)
|
||||||
if (!result.isEmpty()) break;
|
if (!result.isEmpty()) break;
|
||||||
@ -87,6 +91,43 @@ public class YoutubeCommentService {
|
|||||||
return result;
|
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. */
|
/** YouTube 오류 응답 본문에서 error.errors[0].reason 추출. 실패 시 null. */
|
||||||
private static String extractReason(String body) {
|
private static String extractReason(String body) {
|
||||||
if (body == null || body.isBlank()) return null;
|
if (body == null || body.isBlank()) return null;
|
||||||
|
|||||||
@ -25,9 +25,19 @@ class YoutubeCommentServiceTest {
|
|||||||
when(guard.tryConsume(anyLong())).thenReturn(true);
|
when(guard.tryConsume(anyLong())).thenReturn(true);
|
||||||
YoutubeCommentService svc = new YoutubeCommentService(rt, guard);
|
YoutubeCommentService svc = new YoutubeCommentService(rt, guard);
|
||||||
ReflectionTestUtils.setField(svc, "youtubeApiKey", "TEST_KEY");
|
ReflectionTestUtils.setField(svc, "youtubeApiKey", "TEST_KEY");
|
||||||
|
ReflectionTestUtils.setField(svc, "retryBackoffMs", 0L); // 테스트 속도
|
||||||
return svc;
|
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) {
|
private HttpClientErrorException apiError(HttpStatus status, String reason) {
|
||||||
String body = "{\"error\":{\"code\":" + status.value()
|
String body = "{\"error\":{\"code\":" + status.value()
|
||||||
+ ",\"message\":\"x\",\"errors\":[{\"reason\":\"" + reason + "\"}]}}";
|
+ ",\"message\":\"x\",\"errors\":[{\"reason\":\"" + reason + "\"}]}}";
|
||||||
@ -56,6 +66,27 @@ class YoutubeCommentServiceTest {
|
|||||||
assertFalse(ex.getMessage().contains("비활성화"), ex.getMessage());
|
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
|
@Test
|
||||||
void invalidKey_throwsMeaningfulMessage_notDisabled() {
|
void invalidKey_throwsMeaningfulMessage_notDisabled() {
|
||||||
RestTemplate rt = mock(RestTemplate.class);
|
RestTemplate rt = mock(RestTemplate.class);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user