Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
package com.back.web7_9_codecrete_be.domain.artists.service.spotifyService;

import com.back.web7_9_codecrete_be.global.spotify.SpotifyClient;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

// 429 에러 처리 및 전역 쿨다운 관리
// 401 에러 처리 (토큰 만료 시 자동 재발급)

@Slf4j
@Component
@RequiredArgsConstructor
public class SpotifyRateLimitHandler {

private final SpotifyClient spotifyClient;

private static final long SPOTIFY_RATE_LIMIT_INTERVAL_MS = 500; // 초당 2회
private static final long MUSICBRAINZ_RATE_LIMIT_INTERVAL_MS = 1000; // 초당 1회
private static final long GLOBAL_COOLDOWN_DURATION_MS = 60_000; // 60초
Expand Down Expand Up @@ -65,6 +71,22 @@ public <T> T callWithRateLimitRetry(java.util.function.Supplier<T> supplier, Str
globalConsecutive429Count = 0;
return result;
} catch (Exception e) {
// 401 에러 확인 (Unauthorized - 토큰 만료)
boolean is401 = is401Error(e);

if (is401) {
log.warn("401 Unauthorized 에러 감지 - 토큰 재발급 후 재시도: attempt={}/{}", attempt, maxRetry);
// 토큰 강제 재발급
spotifyClient.forceRefreshToken();

if (attempt < maxRetry) {
// 재시도
continue;
} else {
log.error("401 에러 재시도 횟수 초과 ({}회)", maxRetry);
throw new RuntimeException(context + ": 401 Unauthorized - 토큰 재발급 후에도 실패", e);
}
}
// 원본 예외 확인 (래핑된 경우 cause 확인)
Throwable originalException = e;
Throwable current = e;
Expand Down Expand Up @@ -203,5 +225,26 @@ public <T> T callWithRateLimitRetry(java.util.function.Supplier<T> supplier, Str

throw new RuntimeException(context + ": rate limit retry exhausted");
}

// 401 에러인지 확인
private boolean is401Error(Throwable e) {
Throwable current = e;
while (current != null) {
String className = current.getClass().getSimpleName();
String errorMsg = current.getMessage();

// 401 에러 확인
if (className.contains("Unauthorized") ||
className.contains("401") ||
(errorMsg != null && (errorMsg.contains("401") ||
errorMsg.contains("Unauthorized") ||
errorMsg.contains("Invalid access token")))) {
return true;
}

current = current.getCause();
}
return false;
}
}

Original file line number Diff line number Diff line change
@@ -1,32 +1,107 @@
package com.back.web7_9_codecrete_be.global.spotify;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import se.michaelthelin.spotify.SpotifyApi;
import se.michaelthelin.spotify.model_objects.credentials.ClientCredentials;
import se.michaelthelin.spotify.requests.authorization.client_credentials.ClientCredentialsRequest;

import java.util.concurrent.locks.ReentrantLock;

// Spotify AccessToken 중앙 관리 클래스

// 토큰 만료 시간 관리 (1시간 유효)
// 만료 전 자동 재발급 (5분 여유)
// 401 에러 발생 시 자동 재발급 및 재요청 지원

@Slf4j
@Component
@RequiredArgsConstructor
public class SpotifyClient {

private final SpotifyApi spotifyApi;

public String getAccessToken() {

// 토큰 관리 필드
private volatile String cachedAccessToken;
private volatile long tokenExpiresAt = 0; // 만료 시각 (밀리초)
private final ReentrantLock tokenLock = new ReentrantLock();

// 토큰 만료 여유 시간 (5분 = 300초)
private static final long TOKEN_BUFFER_SECONDS = 300;
private static final long TOKEN_BUFFER_MS = TOKEN_BUFFER_SECONDS * 1000;

// AccessToken 발급
private String refreshAccessToken() {
tokenLock.lock();
try {
// Double-check: 다른 스레드가 이미 토큰을 발급했는지 확인
if (cachedAccessToken != null && System.currentTimeMillis() < tokenExpiresAt) {
return cachedAccessToken;
}

log.info("Spotify AccessToken 발급 시작");
ClientCredentialsRequest request = spotifyApi.clientCredentials().build();
ClientCredentials credentials = request.execute();
spotifyApi.setAccessToken(credentials.getAccessToken());
return credentials.getAccessToken();

String newToken = credentials.getAccessToken();
// Spotify 토큰은 3600초(1시간) 유효
// 여유 시간을 빼서 실제 만료 시각 계산
long expiresInSeconds = credentials.getExpiresIn();
tokenExpiresAt = System.currentTimeMillis() + (expiresInSeconds * 1000) - TOKEN_BUFFER_MS;

cachedAccessToken = newToken;
spotifyApi.setAccessToken(newToken);

log.info("Spotify AccessToken 발급 완료. 만료 시각: {} ({}초 후)",
new java.util.Date(tokenExpiresAt), expiresInSeconds);

return newToken;
} catch (Exception e) {
log.error("Spotify 토큰 발급 실패", e);
// 토큰 발급 실패 시 캐시 초기화
cachedAccessToken = null;
tokenExpiresAt = 0;
throw new RuntimeException("Spotify 토큰 발급 실패", e);
} finally {
tokenLock.unlock();
}
}

public SpotifyApi getAuthorizedApi() {
if (spotifyApi.getAccessToken() == null) {
getAccessToken();

// AccessToken 조회 (만료 체크 포함) - 만료되었거나 곧 만료될 경우 자동 재발급
public String getAccessToken() {
long now = System.currentTimeMillis();

// 토큰이 없거나 만료되었거나 곧 만료될 경우 재발급
if (cachedAccessToken == null || now >= tokenExpiresAt) {
return refreshAccessToken();
}

return cachedAccessToken;
}

// 인증된 SpotifyApi 반환 - 토큰이 없거나 만료된 경우 자동으로 재발급
public SpotifyApi getAuthorizedApi() {
getAccessToken(); // 만료 체크 및 필요시 재발급
return spotifyApi;
}

// 401 에러 발생 시 토큰 강제 재발급 (SpotifyRateLimitHandler에서 자동 호출)
public void forceRefreshToken() {
log.warn("401 에러 감지 - Spotify AccessToken 강제 재발급");
tokenLock.lock();
try {
// 캐시 초기화 후 재발급
cachedAccessToken = null;
tokenExpiresAt = 0;
refreshAccessToken();
} finally {
tokenLock.unlock();
}
}

// 토큰 만료 여부 확인
public boolean isTokenExpired() {
return cachedAccessToken == null || System.currentTimeMillis() >= tokenExpiresAt;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package com.back.web7_9_codecrete_be.domain.artists.service.spotifyService;

import com.back.web7_9_codecrete_be.global.spotify.SpotifyClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.concurrent.atomic.AtomicInteger;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.BDDMockito.*;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
@DisplayName("SpotifyRateLimitHandler 401 에러 처리 테스트")
class SpotifyRateLimitHandlerTest {

@Mock
private SpotifyClient spotifyClient;

@InjectMocks
private SpotifyRateLimitHandler rateLimitHandler;

@BeforeEach
void setUp() {
// 각 테스트 전에 상태 초기화
}

@Test
@DisplayName("401 에러 발생 시 토큰 재발급 후 재시도 성공")
void callWithRateLimitRetry_when401Error_shouldRefreshTokenAndRetry() {
// given
RuntimeException unauthorizedException = new RuntimeException("401 Unauthorized");
String successResult = "success-result";

// forceRefreshToken은 void 메서드이므로 doNothing() 사용
doNothing().when(spotifyClient).forceRefreshToken();

// 첫 번째 호출은 401 에러, 두 번째 호출은 성공하도록 설정
AtomicInteger callCount = new AtomicInteger(0);

// when
String result = rateLimitHandler.callWithRateLimitRetry(
() -> {
int count = callCount.incrementAndGet();
if (count == 1) {
// 첫 번째 호출 시 401 에러
throw unauthorizedException;
}
// 두 번째 호출 시 성공
return successResult;
},
"test-context"
);

// then
assertThat(result).isEqualTo(successResult);
assertThat(callCount.get()).isEqualTo(2); // 재시도로 2번 호출됨
verify(spotifyClient, times(1)).forceRefreshToken();
}

@Test
@DisplayName("401 에러가 아닌 경우 토큰 재발급하지 않음")
void callWithRateLimitRetry_whenNot401Error_shouldNotRefreshToken() {
// given
RuntimeException otherException = new RuntimeException("500 Internal Server Error");
String successResult = "success-result";

// when & then
assertThatThrownBy(() -> {
rateLimitHandler.callWithRateLimitRetry(
() -> {
throw otherException;
},
"test-context"
);
}).isInstanceOf(RuntimeException.class)
.hasMessageContaining("500 Internal Server Error");

// 401이 아니면 토큰 재발급하지 않아야 함
verify(spotifyClient, never()).forceRefreshToken();
}

@Test
@DisplayName("정상 호출 시 토큰 재발급하지 않음")
void callWithRateLimitRetry_whenSuccess_shouldNotRefreshToken() {
// given
String successResult = "success-result";

// when
String result = rateLimitHandler.callWithRateLimitRetry(
() -> successResult,
"test-context"
);

// then
assertThat(result).isEqualTo(successResult);
verify(spotifyClient, never()).forceRefreshToken();
}

@Test
@DisplayName("401 에러가 예외 체인에 있는 경우 감지")
void is401Error_when401InExceptionChain_shouldDetect() {
// given
RuntimeException cause = new RuntimeException("401 Unauthorized");
RuntimeException wrapper = new RuntimeException("Wrapped exception", cause);

// when
// is401Error는 private 메서드이므로 callWithRateLimitRetry를 통해 간접 테스트
// 실제로는 401 에러가 발생하면 forceRefreshToken이 호출되어야 함
// 이 테스트는 통합 테스트에서 더 적합할 수 있음
}
}

Loading