From 40f291864948d1df10ddb714f3a3fcc4caca5867 Mon Sep 17 00:00:00 2001 From: Stefan Haun Date: Tue, 5 Nov 2024 15:49:11 +0100 Subject: [PATCH 1/4] Add a Rate Limit exception --- .../hareairis/ai/RateLimitException.java | 53 +++++++++++++++++++ .../hareairis/ai/RateLimitExceptionTest.java | 46 ++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 src/main/java/com/penguineering/hareairis/ai/RateLimitException.java create mode 100644 src/test/java/com/penguineering/hareairis/ai/RateLimitExceptionTest.java diff --git a/src/main/java/com/penguineering/hareairis/ai/RateLimitException.java b/src/main/java/com/penguineering/hareairis/ai/RateLimitException.java new file mode 100644 index 0000000..c6ee9a5 --- /dev/null +++ b/src/main/java/com/penguineering/hareairis/ai/RateLimitException.java @@ -0,0 +1,53 @@ +package com.penguineering.hareairis.ai; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; + +/** + * Exception thrown when the OpenAI service returns a rate-limiting error. + */ +public class RateLimitException extends RuntimeException { + private final Instant retryAfter; + + /** + * Constructs a new RateLimitException with the specified detail message. + * + * @param message The detail message. + */ + public RateLimitException(String message) { + super(message); + this.retryAfter = null; + } + + /** + * Constructs a new RateLimitException with the specified detail message and retry after time. + * + * @param message The detail message. + * @param retryAfter The optional point in time to hint when the service can be called again. + */ + public RateLimitException(String message, Instant retryAfter) { + super(message); + this.retryAfter = retryAfter; + } + + /** + * Constructs a new RateLimitException with the specified detail message and retry after duration. + * + * @param message The detail message. + * @param duration The duration after which the service can be called again. + */ + public RateLimitException(String message, Duration duration) { + super(message); + this.retryAfter = Instant.now().plus(duration); + } + + /** + * Returns the optional point in time to hint when the service can be called again. + * + * @return The optional retry after time. + */ + public Optional getRetryAfter() { + return Optional.ofNullable(retryAfter); + } +} \ No newline at end of file diff --git a/src/test/java/com/penguineering/hareairis/ai/RateLimitExceptionTest.java b/src/test/java/com/penguineering/hareairis/ai/RateLimitExceptionTest.java new file mode 100644 index 0000000..dd6e0f9 --- /dev/null +++ b/src/test/java/com/penguineering/hareairis/ai/RateLimitExceptionTest.java @@ -0,0 +1,46 @@ +package com.penguineering.hareairis.ai; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.*; + +class RateLimitExceptionTest { + + @Test + void testConstructorWithMessage() { + String message = "Rate limit exceeded"; + RateLimitException exception = new RateLimitException(message); + + assertEquals(message, exception.getMessage(), "The exception message should match the provided message."); + assertTrue(exception.getRetryAfter().isEmpty(), "The retryAfter should be empty when not provided."); + } + + @Test + void testConstructorWithMessageAndInstant() { + String message = "Rate limit exceeded"; + Instant retryAfter = Instant.now().plus(Duration.ofMinutes(1)); + RateLimitException exception = new RateLimitException(message, retryAfter); + + assertEquals(message, exception.getMessage(), "The exception message should match the provided message."); + assertTrue(exception.getRetryAfter().isPresent(), "The retryAfter should be present when provided."); + assertEquals(retryAfter, exception.getRetryAfter().get(), "The retryAfter should match the provided Instant."); + } + + @Test + void testConstructorWithMessageAndDuration() { + String message = "Rate limit exceeded"; + Duration duration = Duration.ofMinutes(1); + Instant beforeCreation = Instant.now(); + RateLimitException exception = new RateLimitException(message, duration); + Instant afterCreation = Instant.now(); + + assertEquals(message, exception.getMessage(), "The exception message should match the provided message."); + assertTrue(exception.getRetryAfter().isPresent(), "The retryAfter should be present when duration is provided."); + Instant retryAfter = exception.getRetryAfter().get(); + assertTrue(retryAfter.isAfter(beforeCreation), "The retryAfter should be after the time before creation."); + assertTrue(retryAfter.isBefore(afterCreation.plus(duration)), "The retryAfter should be within the expected duration range."); + } +} \ No newline at end of file From f901cc0acc6b8bd9c2849ff96ecf9e9e8f1e0b76 Mon Sep 17 00:00:00 2001 From: Stefan Haun Date: Tue, 5 Nov 2024 16:26:13 +0100 Subject: [PATCH 2/4] Add a static constructor for Http Responses --- .../hareairis/ai/RateLimitException.java | 42 +++++++++ .../hareairis/ai/RateLimitExceptionTest.java | 86 +++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/src/main/java/com/penguineering/hareairis/ai/RateLimitException.java b/src/main/java/com/penguineering/hareairis/ai/RateLimitException.java index c6ee9a5..245bc88 100644 --- a/src/main/java/com/penguineering/hareairis/ai/RateLimitException.java +++ b/src/main/java/com/penguineering/hareairis/ai/RateLimitException.java @@ -1,13 +1,55 @@ package com.penguineering.hareairis.ai; +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpResponse; + import java.time.Duration; import java.time.Instant; +import java.util.Objects; import java.util.Optional; +import java.util.function.Consumer; /** * Exception thrown when the OpenAI service returns a rate-limiting error. */ public class RateLimitException extends RuntimeException { + /** + * Creates a RateLimitException from the specified HttpResponse. + * + * @param response The HttpResponse to create the exception from. + * @param logConsumer The consumer to log any parsing errors. + * @return The RateLimitException created from the HttpResponse. + * @throws IllegalArgumentException If the response status code is not 429. + */ + public static RateLimitException fromHttpResponse(HttpResponse response, Consumer logConsumer) { + if (response.getStatusCode() != 429) + throw new IllegalArgumentException("Expected status code 429 for RateLimitException, but received " + response.getStatusCode() + "."); + + String errorMessage = response.getBodyAsString().block(); + String retryAfterHeader = response.getHeaders().getValue(HttpHeaderName.RETRY_AFTER); + + if (Objects.isNull(retryAfterHeader)) + return new RateLimitException(errorMessage); + + // Try parsing as Instant + try { + Instant retryAfter = Instant.parse(retryAfterHeader); + return new RateLimitException(errorMessage, retryAfter); + } catch (Exception ex) { + // Ignore and try parsing as Duration + } + + // Try parsing as seconds + try { + long seconds = Long.parseLong(retryAfterHeader); + return new RateLimitException(errorMessage, Duration.ofSeconds(seconds)); + } catch (NumberFormatException e) { + logConsumer.accept("Failed to parse Retry-After header and returning an exception with message only: " + retryAfterHeader); + } + + return new RateLimitException(errorMessage); + } + private final Instant retryAfter; /** diff --git a/src/test/java/com/penguineering/hareairis/ai/RateLimitExceptionTest.java b/src/test/java/com/penguineering/hareairis/ai/RateLimitExceptionTest.java index dd6e0f9..f214cf3 100644 --- a/src/test/java/com/penguineering/hareairis/ai/RateLimitExceptionTest.java +++ b/src/test/java/com/penguineering/hareairis/ai/RateLimitExceptionTest.java @@ -1,12 +1,22 @@ package com.penguineering.hareairis.ai; +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpHeaders; +import com.azure.core.http.HttpResponse; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Mono; import java.time.Duration; import java.time.Instant; +import java.util.function.Consumer; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +@ExtendWith(MockitoExtension.class) class RateLimitExceptionTest { @Test @@ -43,4 +53,80 @@ void testConstructorWithMessageAndDuration() { assertTrue(retryAfter.isAfter(beforeCreation), "The retryAfter should be after the time before creation."); assertTrue(retryAfter.isBefore(afterCreation.plus(duration)), "The retryAfter should be within the expected duration range."); } + + + @Test + void testFromHttpResponseWithoutRetryAfterHeader() { + HttpResponse response = mock(HttpResponse.class); + when(response.getStatusCode()).thenReturn(429); + when(response.getBodyAsString()).thenReturn(Mono.just("Rate limit exceeded")); + when(response.getHeaders()).thenReturn(new HttpHeaders()); + + RateLimitException exception = RateLimitException.fromHttpResponse(response, System.out::println); + + assertEquals("Rate limit exceeded", exception.getMessage()); + assertTrue(exception.getRetryAfter().isEmpty()); + } + + // Apply similar changes to other test methods + @Test + void testFromHttpResponseWithRetryAfterDuration() { + HttpResponse response = mock(HttpResponse.class); + when(response.getStatusCode()).thenReturn(429); + when(response.getBodyAsString()).thenReturn(Mono.just("Rate limit exceeded")); + HttpHeaders headers = new HttpHeaders().set(HttpHeaderName.RETRY_AFTER, "60"); + when(response.getHeaders()).thenReturn(headers); + + RateLimitException exception = RateLimitException.fromHttpResponse(response, System.out::println); + + assertEquals("Rate limit exceeded", exception.getMessage()); + assertTrue(exception.getRetryAfter().isPresent()); + assertTrue(exception.getRetryAfter().get().isAfter(Instant.now().minusSeconds(60))); + } + + @Test + void testFromHttpResponseWithRetryAfterInstant() { + HttpResponse response = mock(HttpResponse.class); + when(response.getStatusCode()).thenReturn(429); + when(response.getBodyAsString()).thenReturn(Mono.just("Rate limit exceeded")); + Instant retryAfter = Instant.now().plus(Duration.ofMinutes(1)); + HttpHeaders headers = new HttpHeaders().set(HttpHeaderName.RETRY_AFTER, retryAfter.toString()); + when(response.getHeaders()).thenReturn(headers); + + RateLimitException exception = RateLimitException.fromHttpResponse(response, System.out::println); + + assertEquals("Rate limit exceeded", exception.getMessage()); + assertTrue(exception.getRetryAfter().isPresent()); + assertEquals(retryAfter, exception.getRetryAfter().get()); + } + + @Mock + Consumer logConsumer; + + @Test + void testFromHttpResponseWithInvalidRetryAfterHeader() { + HttpResponse response = mock(HttpResponse.class); + when(response.getStatusCode()).thenReturn(429); + when(response.getBodyAsString()).thenReturn(Mono.just("Rate limit exceeded")); + HttpHeaders headers = new HttpHeaders().set(HttpHeaderName.RETRY_AFTER, "invalid"); + when(response.getHeaders()).thenReturn(headers); + + RateLimitException exception = RateLimitException.fromHttpResponse(response, logConsumer); + + assertEquals("Rate limit exceeded", exception.getMessage()); + assertTrue(exception.getRetryAfter().isEmpty()); + verify(logConsumer).accept("Failed to parse Retry-After header and returning an exception with message only: invalid"); + } + + @Test + void testFromHttpResponseWithNon429StatusCode() { + HttpResponse response = mock(HttpResponse.class); + when(response.getStatusCode()).thenReturn(400); // Any status code other than 429 + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + throw RateLimitException.fromHttpResponse(response, logConsumer); + }); + + assertEquals("Expected status code 429 for RateLimitException, but received " + response.getStatusCode() + ".", exception.getMessage()); + } } \ No newline at end of file From e35df4619240f8ec99e5ee5ab62fdef97d8f2ea0 Mon Sep 17 00:00:00 2001 From: Stefan Haun Date: Tue, 5 Nov 2024 16:26:38 +0100 Subject: [PATCH 3/4] Throw the Rate Limit Exception from the AI Service --- .../java/com/penguineering/hareairis/ai/AIChatService.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/penguineering/hareairis/ai/AIChatService.java b/src/main/java/com/penguineering/hareairis/ai/AIChatService.java index e40140c..3dc4f2a 100644 --- a/src/main/java/com/penguineering/hareairis/ai/AIChatService.java +++ b/src/main/java/com/penguineering/hareairis/ai/AIChatService.java @@ -60,6 +60,9 @@ public ChatResponse handleChatRequest(ChatRequest chatRequest) { throw new ChatException(ChatException.Code.CODE_BAD_REQUEST, e.getMessage()); } catch (HttpResponseException e) { var response = e.getResponse(); + if (response.getStatusCode() == 429) + throw RateLimitException.fromHttpResponse(response, logger::warn); + throw new ChatException(response.getStatusCode(), e.getMessage()); } } From d9a65fbccc8279159fb5b80d5485343d4c3700df Mon Sep 17 00:00:00 2001 From: Stefan Haun Date: Tue, 5 Nov 2024 16:32:18 +0100 Subject: [PATCH 4/4] Derive the RateLimitExcepton from ChatException --- .../hareairis/ai/RateLimitException.java | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/penguineering/hareairis/ai/RateLimitException.java b/src/main/java/com/penguineering/hareairis/ai/RateLimitException.java index 245bc88..a0d5ab6 100644 --- a/src/main/java/com/penguineering/hareairis/ai/RateLimitException.java +++ b/src/main/java/com/penguineering/hareairis/ai/RateLimitException.java @@ -2,6 +2,7 @@ import com.azure.core.http.HttpHeaderName; import com.azure.core.http.HttpResponse; +import com.penguineering.hareairis.model.ChatException; import java.time.Duration; import java.time.Instant; @@ -12,11 +13,11 @@ /** * Exception thrown when the OpenAI service returns a rate-limiting error. */ -public class RateLimitException extends RuntimeException { +public class RateLimitException extends ChatException { /** * Creates a RateLimitException from the specified HttpResponse. * - * @param response The HttpResponse to create the exception from. + * @param response The HttpResponse to create the exception from. * @param logConsumer The consumer to log any parsing errors. * @return The RateLimitException created from the HttpResponse. * @throws IllegalArgumentException If the response status code is not 429. @@ -58,8 +59,7 @@ public static RateLimitException fromHttpResponse(HttpResponse response, Consume * @param message The detail message. */ public RateLimitException(String message) { - super(message); - this.retryAfter = null; + this(message, (Instant) null); } /** @@ -69,7 +69,7 @@ public RateLimitException(String message) { * @param retryAfter The optional point in time to hint when the service can be called again. */ public RateLimitException(String message, Instant retryAfter) { - super(message); + super(Code.CODE_TOO_MANY_REQUESTS, message); this.retryAfter = retryAfter; } @@ -80,8 +80,7 @@ public RateLimitException(String message, Instant retryAfter) { * @param duration The duration after which the service can be called again. */ public RateLimitException(String message, Duration duration) { - super(message); - this.retryAfter = Instant.now().plus(duration); + this(message, Instant.now().plus(duration)); } /**