diff --git a/src/main/java/roomescape/config/WebConfig.java b/src/main/java/roomescape/config/WebConfig.java index c1a1dfbabe..3ab3b46c8a 100644 --- a/src/main/java/roomescape/config/WebConfig.java +++ b/src/main/java/roomescape/config/WebConfig.java @@ -1,12 +1,26 @@ package roomescape.config; import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import roomescape.ratelimit.RateLimitInterceptor; @Configuration public class WebConfig implements WebMvcConfigurer { + private final RateLimitInterceptor rateLimitInterceptor; + + public WebConfig(RateLimitInterceptor rateLimitInterceptor) { + this.rateLimitInterceptor = rateLimitInterceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(rateLimitInterceptor) + .addPathPatterns("/reservations", "/payments/**"); + } + @Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController("/").setViewName("index"); diff --git a/src/main/java/roomescape/exception/GlobalExceptionHandler.java b/src/main/java/roomescape/exception/GlobalExceptionHandler.java index 8fe258a9d0..d26c0cd1bd 100644 --- a/src/main/java/roomescape/exception/GlobalExceptionHandler.java +++ b/src/main/java/roomescape/exception/GlobalExceptionHandler.java @@ -33,6 +33,7 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { private static final String DETAIL_INTERNAL_ERROR = "요청을 처리하는 중 알 수 없는 오류가 발생했습니다."; private static final String DETAIL_PAYMENT_FAILED = "결제 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요."; private static final String DETAIL_PAYMENT_UNCERTAIN = "결제 승인 결과를 확인하지 못했습니다. 잠시 후 주문 내역에서 결제 상태를 확인해주세요."; + private static final String DETAIL_OUTBOUND_RATE_LIMITED = "외부 결제 API 호출 한도를 초과했습니다. 잠시 후 다시 시도해주세요."; private static final String ERRORS_PROPERTY = "errors"; private static final String POINTER_PREFIX = "/"; @@ -81,6 +82,16 @@ public ProblemDetail handleTransient(TransientDataAccessException ex, WebRequest return buildProblem(HttpStatus.CONFLICT, ProblemType.CONCURRENCY_CONFLICT, DETAIL_TRANSIENT, Level.WARN, ex, request); } + @ExceptionHandler(OutboundRateLimitException.class) + public ResponseEntity handleOutboundRateLimit(OutboundRateLimitException ex, WebRequest request) { + ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE, DETAIL_OUTBOUND_RATE_LIMITED); + applyType(problem, ProblemType.OUTBOUND_RATE_LIMITED, request); + logException(Level.WARN, ex, HttpStatus.SERVICE_UNAVAILABLE, request); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .header(HttpHeaders.RETRY_AFTER, String.valueOf(ex.retryAfterSeconds())) + .body(problem); + } + @ExceptionHandler(Exception.class) public ProblemDetail handleUnexpected(Exception ex, WebRequest request) { return buildProblem(HttpStatus.INTERNAL_SERVER_ERROR, ProblemType.INTERNAL_ERROR, DETAIL_INTERNAL_ERROR, Level.ERROR, ex, request); diff --git a/src/main/java/roomescape/exception/OutboundRateLimitException.java b/src/main/java/roomescape/exception/OutboundRateLimitException.java new file mode 100644 index 0000000000..8fbab8b1d1 --- /dev/null +++ b/src/main/java/roomescape/exception/OutboundRateLimitException.java @@ -0,0 +1,15 @@ +package roomescape.exception; + +public class OutboundRateLimitException extends RoomescapeException { + + private final long retryAfterSeconds; + + public OutboundRateLimitException(long retryAfterSeconds) { + super("외부 결제 API 호출 한도를 초과했습니다. retryAfterSeconds=" + retryAfterSeconds); + this.retryAfterSeconds = retryAfterSeconds; + } + + public long retryAfterSeconds() { + return retryAfterSeconds; + } +} diff --git a/src/main/java/roomescape/exception/ProblemType.java b/src/main/java/roomescape/exception/ProblemType.java index 7b414448ec..892880efb6 100644 --- a/src/main/java/roomescape/exception/ProblemType.java +++ b/src/main/java/roomescape/exception/ProblemType.java @@ -18,6 +18,7 @@ public enum ProblemType { PAYMENT_AMOUNT_MISMATCH("payment-amount-mismatch", "결제 금액 불일치"), PAYMENT_FAILED("payment-failed", "결제 실패"), PAYMENT_UNCERTAIN("payment-uncertain", "결제 결과 확인 필요"), + OUTBOUND_RATE_LIMITED("outbound-rate-limited", "외부 호출 한도 초과"), INTERNAL_ERROR("internal-error", "서버 내부 오류"); private static final String TYPE_BASE = "https://roomescape.example/problems/"; diff --git a/src/main/java/roomescape/payment/toss/OutboundRateLimitInterceptor.java b/src/main/java/roomescape/payment/toss/OutboundRateLimitInterceptor.java new file mode 100644 index 0000000000..dbbf5794ca --- /dev/null +++ b/src/main/java/roomescape/payment/toss/OutboundRateLimitInterceptor.java @@ -0,0 +1,28 @@ +package roomescape.payment.toss; + +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; +import roomescape.exception.OutboundRateLimitException; +import roomescape.ratelimit.TokenBucketRateLimiter; + +import java.io.IOException; + +public class OutboundRateLimitInterceptor implements ClientHttpRequestInterceptor { + + private final TokenBucketRateLimiter rateLimiter; + + public OutboundRateLimitInterceptor(TokenBucketRateLimiter outboundRateLimiter) { + this.rateLimiter = outboundRateLimiter; + } + + @Override + public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) + throws IOException { + if (!rateLimiter.tryConsume()) { + throw new OutboundRateLimitException(rateLimiter.retryAfterSeconds()); + } + return execution.execute(request, body); + } +} diff --git a/src/main/java/roomescape/payment/toss/RetryAfterInterceptor.java b/src/main/java/roomescape/payment/toss/RetryAfterInterceptor.java new file mode 100644 index 0000000000..2bf18e9a09 --- /dev/null +++ b/src/main/java/roomescape/payment/toss/RetryAfterInterceptor.java @@ -0,0 +1,58 @@ +package roomescape.payment.toss; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; + +import java.io.IOException; +import java.time.Duration; +import java.util.Optional; + +public class RetryAfterInterceptor implements ClientHttpRequestInterceptor { + + private final int maxAttempts; + private final Duration defaultBackoff; + private final Sleeper sleeper; + + public RetryAfterInterceptor(int maxAttempts, Duration defaultBackoff, Sleeper sleeper) { + if (maxAttempts < 1) { + throw new IllegalArgumentException("maxAttempts는 1 이상이어야 합니다. maxAttempts=" + maxAttempts); + } + this.maxAttempts = maxAttempts; + this.defaultBackoff = defaultBackoff; + this.sleeper = sleeper; + } + + @Override + public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) + throws IOException { + for (int attempt = 1; ; attempt++) { + ClientHttpResponse response = execution.execute(request, body); + if (response.getStatusCode().value() != HttpStatus.TOO_MANY_REQUESTS.value()) { + return response; + } + if (attempt >= maxAttempts) { + response.close(); + throw new TossRateLimitException("토스 호출이 요청 한도(429)를 초과했습니다. attempts=" + attempt); + } + Duration backoff = retryAfter(response).orElse(defaultBackoff); + response.close(); + sleeper.sleep(backoff); + } + } + + private Optional retryAfter(ClientHttpResponse response) throws IOException { + String value = response.getHeaders().getFirst(HttpHeaders.RETRY_AFTER); + if (value == null || value.isBlank()) { + return Optional.empty(); + } + try { + return Optional.of(Duration.ofSeconds(Long.parseLong(value.trim()))); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } +} diff --git a/src/main/java/roomescape/payment/toss/Sleeper.java b/src/main/java/roomescape/payment/toss/Sleeper.java new file mode 100644 index 0000000000..1a613cd0df --- /dev/null +++ b/src/main/java/roomescape/payment/toss/Sleeper.java @@ -0,0 +1,9 @@ +package roomescape.payment.toss; + +import java.time.Duration; + +@FunctionalInterface +public interface Sleeper { + + void sleep(Duration duration); +} diff --git a/src/main/java/roomescape/payment/toss/ThreadSleeper.java b/src/main/java/roomescape/payment/toss/ThreadSleeper.java new file mode 100644 index 0000000000..3fcb1ee362 --- /dev/null +++ b/src/main/java/roomescape/payment/toss/ThreadSleeper.java @@ -0,0 +1,20 @@ +package roomescape.payment.toss; + +import java.time.Duration; + +public class ThreadSleeper implements Sleeper { + + @Override + public void sleep(Duration duration) { + long millis = duration.toMillis(); + if (millis <= 0) { + return; + } + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("재시도 대기 중 인터럽트되었습니다.", e); + } + } +} diff --git a/src/main/java/roomescape/payment/toss/TossClientConfig.java b/src/main/java/roomescape/payment/toss/TossClientConfig.java index fa0cc4b896..2c0cdfbfc7 100644 --- a/src/main/java/roomescape/payment/toss/TossClientConfig.java +++ b/src/main/java/roomescape/payment/toss/TossClientConfig.java @@ -1,11 +1,13 @@ package roomescape.payment.toss; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpHeaders; import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestClient; +import roomescape.ratelimit.TokenBucketRateLimiter; import java.nio.charset.StandardCharsets; import java.time.Duration; @@ -14,12 +16,35 @@ @Configuration public class TossClientConfig { + @Bean + public Sleeper tossBackoffSleeper() { + return new ThreadSleeper(); + } + + @Bean + public RetryAfterInterceptor tossRetryAfterInterceptor( + @Value("${toss.retry.max-attempts}") int maxAttempts, + @Value("${toss.retry.default-backoff}") Duration defaultBackoff, + Sleeper tossBackoffSleeper + ) { + return new RetryAfterInterceptor(maxAttempts, defaultBackoff, tossBackoffSleeper); + } + + @Bean + public OutboundRateLimitInterceptor tossOutboundRateLimitInterceptor( + @Qualifier("outboundRateLimiter") TokenBucketRateLimiter outboundRateLimiter + ) { + return new OutboundRateLimitInterceptor(outboundRateLimiter); + } + @Bean public RestClient tossRestClient( @Value("${toss.base-url}") String baseUrl, @Value("${toss.secret-key}") String secretKey, @Value("${toss.connect-timeout}") Duration connectTimeout, - @Value("${toss.read-timeout}") Duration readTimeout + @Value("${toss.read-timeout}") Duration readTimeout, + RetryAfterInterceptor tossRetryAfterInterceptor, + OutboundRateLimitInterceptor tossOutboundRateLimitInterceptor ) { String basic = Base64.getEncoder() .encodeToString((secretKey + ":").getBytes(StandardCharsets.UTF_8)); @@ -32,6 +57,8 @@ public RestClient tossRestClient( .baseUrl(baseUrl) .requestFactory(requestFactory) .defaultHeader(HttpHeaders.AUTHORIZATION, "Basic " + basic) + .requestInterceptor(tossRetryAfterInterceptor) + .requestInterceptor(tossOutboundRateLimitInterceptor) .build(); } } diff --git a/src/main/java/roomescape/payment/toss/TossRateLimitException.java b/src/main/java/roomescape/payment/toss/TossRateLimitException.java new file mode 100644 index 0000000000..9430a3dd61 --- /dev/null +++ b/src/main/java/roomescape/payment/toss/TossRateLimitException.java @@ -0,0 +1,8 @@ +package roomescape.payment.toss; + +public class TossRateLimitException extends TossPaymentException { + + public TossRateLimitException(String message) { + super(message); + } +} diff --git a/src/main/java/roomescape/ratelimit/RateLimitConfig.java b/src/main/java/roomescape/ratelimit/RateLimitConfig.java new file mode 100644 index 0000000000..7943c212cc --- /dev/null +++ b/src/main/java/roomescape/ratelimit/RateLimitConfig.java @@ -0,0 +1,25 @@ +package roomescape.ratelimit; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RateLimitConfig { + + @Bean + public TokenBucketRateLimiter inboundRateLimiter( + @Value("${rate-limit.capacity}") long capacity, + @Value("${rate-limit.refill-per-sec}") double refillPerSec + ) { + return new TokenBucketRateLimiter(capacity, refillPerSec, System::nanoTime); + } + + @Bean + public TokenBucketRateLimiter outboundRateLimiter( + @Value("${outbound-rate-limit.capacity}") long capacity, + @Value("${outbound-rate-limit.refill-per-sec}") double refillPerSec + ) { + return new TokenBucketRateLimiter(capacity, refillPerSec, System::nanoTime); + } +} diff --git a/src/main/java/roomescape/ratelimit/RateLimitInterceptor.java b/src/main/java/roomescape/ratelimit/RateLimitInterceptor.java new file mode 100644 index 0000000000..89c8d3e47e --- /dev/null +++ b/src/main/java/roomescape/ratelimit/RateLimitInterceptor.java @@ -0,0 +1,29 @@ +package roomescape.ratelimit; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +public class RateLimitInterceptor implements HandlerInterceptor { + + private final TokenBucketRateLimiter rateLimiter; + + public RateLimitInterceptor(@Qualifier("inboundRateLimiter") TokenBucketRateLimiter inboundRateLimiter) { + this.rateLimiter = inboundRateLimiter; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + if (rateLimiter.tryConsume()) { + return true; + } + response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + response.setHeader(HttpHeaders.RETRY_AFTER, String.valueOf(rateLimiter.retryAfterSeconds())); + return false; + } +} diff --git a/src/main/java/roomescape/ratelimit/TokenBucketRateLimiter.java b/src/main/java/roomescape/ratelimit/TokenBucketRateLimiter.java new file mode 100644 index 0000000000..53cee2b1ac --- /dev/null +++ b/src/main/java/roomescape/ratelimit/TokenBucketRateLimiter.java @@ -0,0 +1,58 @@ +package roomescape.ratelimit; + +import java.util.function.LongSupplier; + +public class TokenBucketRateLimiter { + + private static final double NANOS_PER_SECOND = 1_000_000_000.0; + + private final long capacity; + private final double refillPerSec; + private final LongSupplier nanoTimeSupplier; + + private double availableTokens; + private long lastRefillNanos; + + public TokenBucketRateLimiter(long capacity, double refillPerSec, LongSupplier nanoTimeSupplier) { + if (capacity < 1) { + throw new IllegalArgumentException("capacity는 1 이상이어야 합니다. capacity=" + capacity); + } + if (refillPerSec <= 0) { + throw new IllegalArgumentException("refillPerSec는 0보다 커야 합니다. refillPerSec=" + refillPerSec); + } + this.capacity = capacity; + this.refillPerSec = refillPerSec; + this.nanoTimeSupplier = nanoTimeSupplier; + this.availableTokens = capacity; + this.lastRefillNanos = nanoTimeSupplier.getAsLong(); + } + + public synchronized boolean tryConsume() { + refill(); + if (availableTokens >= 1.0) { + availableTokens -= 1.0; + return true; + } + return false; + } + + public synchronized long retryAfterSeconds() { + refill(); + if (availableTokens >= 1.0) { + return 0L; + } + double shortage = 1.0 - availableTokens; + return (long) Math.ceil(shortage / refillPerSec); + } + + private void refill() { + long now = nanoTimeSupplier.getAsLong(); + long elapsedNanos = now - lastRefillNanos; + if (elapsedNanos <= 0) { + return; + } + double refilled = (elapsedNanos / NANOS_PER_SECOND) * refillPerSec; + availableTokens = Math.min(capacity, availableTokens + refilled); + lastRefillNanos = now; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 11887dcf5a..a894f7db53 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -7,3 +7,12 @@ toss.base-url=https://api.tosspayments.com toss.secret-key=test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 toss.connect-timeout=2s toss.read-timeout=5s + +rate-limit.capacity=100 +rate-limit.refill-per-sec=50 + +toss.retry.max-attempts=3 +toss.retry.default-backoff=1s + +outbound-rate-limit.capacity=50 +outbound-rate-limit.refill-per-sec=25 diff --git a/src/test/java/roomescape/payment/toss/OutboundRateLimitInterceptorTest.java b/src/test/java/roomescape/payment/toss/OutboundRateLimitInterceptorTest.java new file mode 100644 index 0000000000..e074ecfac0 --- /dev/null +++ b/src/test/java/roomescape/payment/toss/OutboundRateLimitInterceptorTest.java @@ -0,0 +1,54 @@ +package roomescape.payment.toss; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpResponse; +import roomescape.exception.OutboundRateLimitException; +import roomescape.ratelimit.TokenBucketRateLimiter; + +import java.io.IOException; +import java.util.function.LongSupplier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class OutboundRateLimitInterceptorTest { + + private final LongSupplier frozenClock = () -> 0L; + + @Test + void 토큰이_있으면_실제_호출로_위임한다() throws IOException { + TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(1, 1, frozenClock); + OutboundRateLimitInterceptor interceptor = new OutboundRateLimitInterceptor(limiter); + HttpRequest request = mock(HttpRequest.class); + ClientHttpRequestExecution execution = mock(ClientHttpRequestExecution.class); + ClientHttpResponse response = mock(ClientHttpResponse.class); + byte[] body = new byte[0]; + when(execution.execute(any(), any())).thenReturn(response); + + ClientHttpResponse result = interceptor.intercept(request, body, execution); + + assertThat(result).isSameAs(response); + verify(execution).execute(request, body); + } + + @Test + void 토큰이_없으면_외부로_호출하지_않고_OutboundRateLimitException을_던진다() throws IOException { + TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(1, 1, frozenClock); + OutboundRateLimitInterceptor interceptor = new OutboundRateLimitInterceptor(limiter); + interceptor.intercept(mock(HttpRequest.class), new byte[0], mock(ClientHttpRequestExecution.class)); + + HttpRequest request = mock(HttpRequest.class); + ClientHttpRequestExecution execution = mock(ClientHttpRequestExecution.class); + + assertThatThrownBy(() -> interceptor.intercept(request, new byte[0], execution)) + .isInstanceOf(OutboundRateLimitException.class); + verify(execution, never()).execute(any(), any()); + } +} diff --git a/src/test/java/roomescape/payment/toss/RetryAfterInterceptorTest.java b/src/test/java/roomescape/payment/toss/RetryAfterInterceptorTest.java new file mode 100644 index 0000000000..2155f481aa --- /dev/null +++ b/src/test/java/roomescape/payment/toss/RetryAfterInterceptorTest.java @@ -0,0 +1,82 @@ +package roomescape.payment.toss; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class RetryAfterInterceptorTest { + + private static final String SUCCESS_BODY = "{\"result\":\"ok\"}"; + + private MockWebServer mockWebServer; + private List recordedWaits; + + @BeforeEach + void setUp() throws IOException { + mockWebServer = new MockWebServer(); + mockWebServer.start(); + recordedWaits = new ArrayList<>(); + } + + @AfterEach + void tearDown() throws IOException { + mockWebServer.shutdown(); + } + + private RestClient clientWith(int maxAttempts, Duration defaultBackoff) { + Sleeper recordingSleeper = recordedWaits::add; + return RestClient.builder() + .baseUrl(mockWebServer.url("/").toString()) + .requestFactory(new SimpleClientHttpRequestFactory()) + .requestInterceptor(new RetryAfterInterceptor(maxAttempts, defaultBackoff, recordingSleeper)) + .build(); + } + + @Test + void 토스가_429와_RetryAfter를_주면_그만큼_대기_후_재시도해_200을_받는다() { + mockWebServer.enqueue(new MockResponse().setResponseCode(429).setHeader("Retry-After", "2")); + mockWebServer.enqueue(new MockResponse() + .setHeader("Content-Type", "application/json") + .setBody(SUCCESS_BODY)); + + String body = clientWith(3, Duration.ofSeconds(1)).get().retrieve().body(String.class); + + assertThat(body).contains("ok"); + assertThat(mockWebServer.getRequestCount()).isEqualTo(2); + assertThat(recordedWaits).containsExactly(Duration.ofSeconds(2)); + } + + @Test + void RetryAfter가_없으면_기본_간격으로_폴백_재시도한다() { + mockWebServer.enqueue(new MockResponse().setResponseCode(429)); + mockWebServer.enqueue(new MockResponse() + .setHeader("Content-Type", "application/json") + .setBody(SUCCESS_BODY)); + + clientWith(3, Duration.ofSeconds(1)).get().retrieve().body(String.class); + + assertThat(recordedWaits).containsExactly(Duration.ofSeconds(1)); + } + + @Test + void 재시도가_maxAttempts를_넘어도_429면_도메인_예외를_던진다() { + mockWebServer.enqueue(new MockResponse().setResponseCode(429).setHeader("Retry-After", "1")); + mockWebServer.enqueue(new MockResponse().setResponseCode(429).setHeader("Retry-After", "1")); + + assertThatThrownBy(() -> clientWith(2, Duration.ofSeconds(1)).get().retrieve().body(String.class)) + .isInstanceOf(TossRateLimitException.class); + assertThat(mockWebServer.getRequestCount()).isEqualTo(2); + } +} diff --git a/src/test/java/roomescape/ratelimit/RateLimitInterceptorTest.java b/src/test/java/roomescape/ratelimit/RateLimitInterceptorTest.java new file mode 100644 index 0000000000..bd78cc5df9 --- /dev/null +++ b/src/test/java/roomescape/ratelimit/RateLimitInterceptorTest.java @@ -0,0 +1,42 @@ +package roomescape.ratelimit; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.util.function.LongSupplier; + +import static org.assertj.core.api.Assertions.assertThat; + +class RateLimitInterceptorTest { + + private final LongSupplier frozenClock = () -> 0L; + + @Test + void 토큰이_있으면_preHandle이_true를_반환하고_컨트롤러로_진행시킨다() { + TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(1, 1, frozenClock); + RateLimitInterceptor interceptor = new RateLimitInterceptor(limiter); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean result = interceptor.preHandle(new MockHttpServletRequest(), response, new Object()); + + assertThat(result).isTrue(); + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + } + + @Test + void 토큰이_없으면_컨트롤러를_호출하지_않고_429와_RetryAfter를_세팅한다() { + TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(1, 1, frozenClock); + RateLimitInterceptor interceptor = new RateLimitInterceptor(limiter); + interceptor.preHandle(new MockHttpServletRequest(), new MockHttpServletResponse(), new Object()); + + MockHttpServletResponse response = new MockHttpServletResponse(); + boolean result = interceptor.preHandle(new MockHttpServletRequest(), response, new Object()); + + assertThat(result).isFalse(); + assertThat(response.getStatus()).isEqualTo(HttpStatus.TOO_MANY_REQUESTS.value()); + assertThat(response.getHeader(HttpHeaders.RETRY_AFTER)).isEqualTo("1"); + } +} diff --git a/src/test/java/roomescape/ratelimit/TokenBucketRateLimiterTest.java b/src/test/java/roomescape/ratelimit/TokenBucketRateLimiterTest.java new file mode 100644 index 0000000000..33cee9a773 --- /dev/null +++ b/src/test/java/roomescape/ratelimit/TokenBucketRateLimiterTest.java @@ -0,0 +1,128 @@ +package roomescape.ratelimit; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.LongSupplier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class TokenBucketRateLimiterTest { + + private static final long ONE_SECOND_NANOS = 1_000_000_000L; + + private final AtomicLong fakeNanos = new AtomicLong(0L); + private final LongSupplier fakeClock = fakeNanos::get; + + private void advanceSeconds(double seconds) { + fakeNanos.addAndGet((long) (seconds * ONE_SECOND_NANOS)); + } + + @Test + void 초기에는_capacity개만큼_통과시키고_이후_거부한다() { + TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(3, 1, fakeClock); + + assertThat(limiter.tryConsume()).isTrue(); + assertThat(limiter.tryConsume()).isTrue(); + assertThat(limiter.tryConsume()).isTrue(); + assertThat(limiter.tryConsume()).isFalse(); + } + + @Test + void 시간이_지나_보충되면_다시_통과시킨다() { + TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(2, 2, fakeClock); + limiter.tryConsume(); + limiter.tryConsume(); + assertThat(limiter.tryConsume()).isFalse(); + + advanceSeconds(0.5); + + assertThat(limiter.tryConsume()).isTrue(); + assertThat(limiter.tryConsume()).isFalse(); + } + + @Test + void 보충량은_capacity를_넘지_않는다() { + TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(5, 10, fakeClock); + for (int i = 0; i < 5; i++) { + limiter.tryConsume(); + } + + advanceSeconds(100); + + for (int i = 0; i < 5; i++) { + assertThat(limiter.tryConsume()).isTrue(); + } + assertThat(limiter.tryConsume()).isFalse(); + } + + @Test + void 토큰이_없으면_retryAfterSeconds가_보충_대기_시간을_올림으로_반환한다() { + TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(1, 2, fakeClock); + limiter.tryConsume(); + + assertThat(limiter.retryAfterSeconds()).isEqualTo(1L); + } + + @Test + void 토큰이_남아_있으면_retryAfterSeconds가_0을_반환한다() { + TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(2, 1, fakeClock); + + assertThat(limiter.retryAfterSeconds()).isEqualTo(0L); + } + + @Test + void 생성자는_capacity가_1보다_작으면_예외를_던진다() { + assertThatThrownBy(() -> new TokenBucketRateLimiter(0, 1, fakeClock)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 생성자는_refillPerSec가_0이하면_예외를_던진다() { + assertThatThrownBy(() -> new TokenBucketRateLimiter(1, 0, fakeClock)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 동시_요청에서도_정확히_capacity개만_통과시킨다() throws InterruptedException { + int capacity = 100; + int threadCount = 1_000; + TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(capacity, 1, fakeClock); + + AtomicInteger passed = new AtomicInteger(); + CountDownLatch ready = new CountDownLatch(threadCount); + CountDownLatch start = new CountDownLatch(1); + ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + ready.countDown(); + await(start); + if (limiter.tryConsume()) { + passed.incrementAndGet(); + } + }); + } + + ready.await(); + start.countDown(); + executor.shutdown(); + assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + assertThat(passed.get()).isEqualTo(capacity); + } + + private void await(CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 643c85f269..59ec4380c7 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -8,3 +8,12 @@ toss.base-url=https://api.tosspayments.com toss.secret-key=test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 toss.connect-timeout=2s toss.read-timeout=5s + +rate-limit.capacity=10000 +rate-limit.refill-per-sec=10000 + +toss.retry.max-attempts=3 +toss.retry.default-backoff=1s + +outbound-rate-limit.capacity=10000 +outbound-rate-limit.refill-per-sec=10000