From e862adf00638ad3305fd89e5d5f73e81f6b269fc Mon Sep 17 00:00:00 2001 From: Scott Leberknight <174812+sleberknight@users.noreply.github.com> Date: Sun, 21 Jul 2024 22:32:04 -0400 Subject: [PATCH] Check rate limits for each request to GitHub * Modify GitHubApi to throw an IllegalStateException if the rate limit is exceeded (X-RateLimit-Remaining is 0). I chose to fail-fast here instead of assuming callers will always check the result. This is fine in this small application. * Move the rate limit check logic out of the GitHubResponse "from" factory method and into its own method, sendRequestAndCheckRateLimit. This keeps the "from" method simple and free of cross-cutting concerns. * Add several methods to GitHubResponse to get the reset time, the time until reset from a given date, and methods to check if the rate limit is exceeded. * Add rateLimitResource to GitHubResponse, so that it can be included in log and exception messages, which will be most useful if the rate limit is exceeded. * Add GitHubResponseTest as dedicated test of GitHubResponse Misc: * Remove unused (except in tests) takeRequestWith1MilliTimeout from MockWebServerExtensions.kt * Add firstValueOrThrow to HttpHeadersExtensions.kt * Add top-level and extension functions in DateTimeExtensions.kt Closes #173 --- .../changelog/extension/DateTimeExtensions.kt | 18 +++ .../extension/HttpHeadersExtensions.kt | 3 + .../kiwiproject/changelog/github/GitHubApi.kt | 125 ++++++++++++------ .../extension/DateTimeExtensionsTest.kt | 64 +++++++++ .../extension/MockWebServerExtensions.kt | 22 +-- .../changelog/github/GitHubApiTest.kt | 92 ++++++++----- .../github/GitHubPagingHelperTest.kt | 3 +- .../changelog/github/GitHubResponseTest.kt | 90 +++++++++++++ 8 files changed, 332 insertions(+), 85 deletions(-) create mode 100644 src/main/kotlin/org/kiwiproject/changelog/extension/DateTimeExtensions.kt create mode 100644 src/test/kotlin/org/kiwiproject/changelog/extension/DateTimeExtensionsTest.kt create mode 100644 src/test/kotlin/org/kiwiproject/changelog/github/GitHubResponseTest.kt diff --git a/src/main/kotlin/org/kiwiproject/changelog/extension/DateTimeExtensions.kt b/src/main/kotlin/org/kiwiproject/changelog/extension/DateTimeExtensions.kt new file mode 100644 index 0000000..e96253c --- /dev/null +++ b/src/main/kotlin/org/kiwiproject/changelog/extension/DateTimeExtensions.kt @@ -0,0 +1,18 @@ +package org.kiwiproject.changelog.extension + +import java.time.Instant +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit + +fun nowUtcTruncatedToSeconds(): ZonedDateTime = + nowUtc().truncatedToSeconds() + +fun nowUtc(): ZonedDateTime = + ZonedDateTime.now(ZoneOffset.UTC) + +fun ZonedDateTime.truncatedToSeconds(): ZonedDateTime = + truncatedTo(ChronoUnit.SECONDS) + +fun utcZonedDateTimeFromEpochSeconds(epochSeconds: Long): ZonedDateTime = + Instant.ofEpochSecond(epochSeconds).atZone(ZoneOffset.UTC) diff --git a/src/main/kotlin/org/kiwiproject/changelog/extension/HttpHeadersExtensions.kt b/src/main/kotlin/org/kiwiproject/changelog/extension/HttpHeadersExtensions.kt index da3573c..87522a8 100644 --- a/src/main/kotlin/org/kiwiproject/changelog/extension/HttpHeadersExtensions.kt +++ b/src/main/kotlin/org/kiwiproject/changelog/extension/HttpHeadersExtensions.kt @@ -4,5 +4,8 @@ import java.net.http.HttpHeaders fun HttpHeaders.firstValueOrNull(name: String): String? = firstValue(name).orElse(null) +fun HttpHeaders.firstValueOrThrow(name: String): String = + firstValue(name).orElseThrow { IllegalStateException("$name header is required") } + fun HttpHeaders.firstValueAsLongOrThrow(name: String): Long = firstValueAsLong(name).orElseThrow { IllegalStateException("$name header is required")} diff --git a/src/main/kotlin/org/kiwiproject/changelog/github/GitHubApi.kt b/src/main/kotlin/org/kiwiproject/changelog/github/GitHubApi.kt index 34e71bc..48b89eb 100644 --- a/src/main/kotlin/org/kiwiproject/changelog/github/GitHubApi.kt +++ b/src/main/kotlin/org/kiwiproject/changelog/github/GitHubApi.kt @@ -5,24 +5,24 @@ import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.Level import org.kiwiproject.changelog.extension.firstValueAsLongOrThrow import org.kiwiproject.changelog.extension.firstValueOrNull +import org.kiwiproject.changelog.extension.firstValueOrThrow +import org.kiwiproject.changelog.extension.nowUtcTruncatedToSeconds +import org.kiwiproject.changelog.extension.utcZonedDateTimeFromEpochSeconds import org.kiwiproject.time.KiwiDurationFormatters import java.net.URI import java.net.http.HttpClient -import java.net.http.HttpHeaders import java.net.http.HttpRequest import java.net.http.HttpRequest.BodyPublishers import java.net.http.HttpResponse import java.net.http.HttpResponse.BodyHandlers import java.time.Duration -import java.time.Instant -import java.time.ZoneId -import java.time.ZoneOffset import java.time.ZonedDateTime import java.time.format.DateTimeFormatter -import java.time.temporal.ChronoUnit private val LOG = KotlinLogging.logger {} +internal const val RATE_LIMIT_REMAINING_WARNING_THRESHOLD = 5L + class GitHubApi( private val githubToken: String, private val httpClient: HttpClient = HttpClient.newHttpClient() @@ -30,34 +30,40 @@ class GitHubApi( /** * Generic method to make a GET request to any GitHub REST API endpoint. + * + * Throws [IllegalStateException] if the GitHub rate limit is exceeded. */ fun get(url: String): GitHubResponse { LOG.debug { "GET: $url" } val httpRequest = newRequestBuilder(url).GET().build() - return sendRequest(httpRequest) + return sendRequestAndCheckRateLimit(httpRequest) } /** * Generic method to make a POST request to any GitHub REST API endpoint. + * + * Throws [IllegalStateException] if the GitHub rate limit is exceeded. */ fun post(url: String, bodyJson: String): GitHubResponse { LOG.debug { "POST: $url" } val bodyPublisher = BodyPublishers.ofString(bodyJson) val httpRequest = newRequestBuilder(url).POST(bodyPublisher).build() - return sendRequest(httpRequest) + return sendRequestAndCheckRateLimit(httpRequest) } /** * Generic method to make a PATCH request to any GitHub REST API endpoint. + * + * Throws [IllegalStateException] if the GitHub rate limit is exceeded. */ fun patch(url: String, bodyJson: String): GitHubResponse { LOG.debug { "PATCH: $url" } val bodyPublisher = BodyPublishers.ofString(bodyJson) val httpRequest = newRequestBuilder(url).method("PATCH", bodyPublisher).build() - return sendRequest(httpRequest) + return sendRequestAndCheckRateLimit(httpRequest) } private fun newRequestBuilder(url: String): HttpRequest.Builder = @@ -66,8 +72,38 @@ class GitHubApi( .header("Content-Type", "application/vnd.github+json") .header("Authorization", "token $githubToken") + private fun sendRequestAndCheckRateLimit(httpRequest: HttpRequest): GitHubResponse { + val response = sendRequest(httpRequest) + + val now = nowUtcTruncatedToSeconds() + val timeUntilReset = response.timeUntilRateLimitResetsFrom(now) + val humanTimeUntilReset = humanTimeUntilReset(timeUntilReset, response.rateLimitRemaining) + + val currentDateTime = DateTimeFormatter.ISO_ZONED_DATE_TIME.format(now) + val rateLimitReset = response.resetAt() + val rateLimitLogMessage = "GitHub API rate info => Limit : ${response.rateLimitLimit}," + + " Remaining : ${response.rateLimitRemaining}," + + " Current time: ${currentDateTime}," + + " Reset at: $rateLimitReset, ${humanTimeUntilReset.message}," + + " Resource: ${response.rateLimitResource}" + LOG.at(humanTimeUntilReset.logLevel) { this.message = rateLimitLogMessage } + + check(response.belowRateLimit()) { + IllegalStateException( + "Rate limit exceeded for resource: ${response.rateLimitResource}." + + " No more requests can be made to that resource until $rateLimitReset (${humanTimeUntilReset.message})" + ) + } + + return response + } + private fun sendRequest(httpRequest: HttpRequest): GitHubResponse { val httpResponse = httpClient.send(httpRequest, BodyHandlers.ofString()) + + val link = httpResponse.headers().firstValueOrNull("Link") + LOG.debug { "GitHub 'Link' header: $link" } + return GitHubResponse.from(httpResponse) } @@ -81,9 +117,30 @@ class GitHubApi( val linkHeader: String?, val rateLimitLimit: Long, val rateLimitRemaining: Long, - val rateLimitResetAt: Long + val rateLimitResetAt: Long, + val rateLimitResource: String ) { + /** + * The UTC date/time when the rate limit resets. + */ + fun resetAt(): ZonedDateTime = utcZonedDateTimeFromEpochSeconds(rateLimitResetAt) + + /** + * The duration until the rate limit resets. + */ + fun timeUntilRateLimitResetsFrom(from: ZonedDateTime): Duration = Duration.between(from, resetAt()) + + /** + * There are requests remaining before the rate limit resets. + */ + fun belowRateLimit(): Boolean = !exceededRateLimit() + + /** + * There are no more requests remaining before the rate limit resets. + */ + fun exceededRateLimit(): Boolean = rateLimitRemaining == 0L + companion object { /** @@ -94,20 +151,8 @@ class GitHubApi( val rateLimitLimit = responseHeaders.firstValueAsLongOrThrow("X-RateLimit-Limit") val rateLimitRemaining = responseHeaders.firstValueAsLongOrThrow("X-RateLimit-Remaining") val rateLimitResetEpochSeconds = responseHeaders.firstValueAsLongOrThrow("X-RateLimit-Reset") - - val now = ZonedDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS) - val resetAt = Instant.ofEpochSecond(rateLimitResetEpochSeconds).atZone(ZoneId.of("UTC")) - val timeUntilReset = Duration.between(now, resetAt) - val humanTimeUntilReset = humanTimeUntilReset(timeUntilReset) - - val currentDateTime = DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(now) - val rateLimitReset = epochSecondsAsIsoFormatted(rateLimitResetEpochSeconds) - val rateLimitLogMessage = - "GitHub API rate info => Limit : $rateLimitLimit, Remaining : $rateLimitRemaining, Current time: $currentDateTime, Reset at: $rateLimitReset, ${humanTimeUntilReset.message}" - LOG.at(humanTimeUntilReset.logLevel) { this.message = rateLimitLogMessage } - + val rateLimitResource = responseHeaders.firstValueOrThrow("X-RateLimit-Resource") val link = responseHeaders.firstValueOrNull("Link") - LOG.debug { "GitHub 'Link' header: $link" } return GitHubResponse( httpResponse.statusCode(), @@ -116,30 +161,28 @@ class GitHubApi( link, rateLimitLimit, rateLimitRemaining, - rateLimitResetEpochSeconds + rateLimitResetEpochSeconds, + rateLimitResource ) } - - @VisibleForTesting - internal fun humanTimeUntilReset(timeUntilReset: Duration): TimeUntilReset = - when { - timeUntilReset.isNegative -> TimeUntilReset("Time until reset is negative! ($timeUntilReset)", true, Level.WARN) - else -> TimeUntilReset("Time until reset: ${KiwiDurationFormatters.formatDurationWords(timeUntilReset)}", false, Level.DEBUG) - } - - data class TimeUntilReset(val message: String, val isNegative: Boolean, val logLevel: Level) } } } @VisibleForTesting -fun resetLimitAsIsoFormatted(responseHeaders: HttpHeaders): String { - val rateLimitReset = responseHeaders.firstValueAsLongOrThrow("X-RateLimit-Reset") - return epochSecondsAsIsoFormatted(rateLimitReset) -} +internal fun humanTimeUntilReset(timeUntilReset: Duration, rateLimitRemaining: Long): TimeUntilReset = + when { + timeUntilReset.isNegative -> + TimeUntilReset("Time until reset is negative! ($timeUntilReset)", true, Level.WARN) + + else -> TimeUntilReset( + "Time until reset: ${KiwiDurationFormatters.formatDurationWords(timeUntilReset)}", + false, + logLevelForRateLimitRemaining(rateLimitRemaining) + ) + } -private fun epochSecondsAsIsoFormatted(epochSeconds: Long): String { - return DateTimeFormatter.ISO_LOCAL_DATE_TIME.format( - Instant.ofEpochSecond(epochSeconds).atZone(ZoneId.of("UTC")) - ) -} +private fun logLevelForRateLimitRemaining(remaining: Long) = + if (remaining > RATE_LIMIT_REMAINING_WARNING_THRESHOLD) Level.DEBUG else Level.WARN + +internal data class TimeUntilReset(val message: String, val isNegative: Boolean, val logLevel: Level) diff --git a/src/test/kotlin/org/kiwiproject/changelog/extension/DateTimeExtensionsTest.kt b/src/test/kotlin/org/kiwiproject/changelog/extension/DateTimeExtensionsTest.kt new file mode 100644 index 0000000..6feac54 --- /dev/null +++ b/src/test/kotlin/org/kiwiproject/changelog/extension/DateTimeExtensionsTest.kt @@ -0,0 +1,64 @@ +package org.kiwiproject.changelog.extension + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.within +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.RepeatedTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit +import kotlin.random.Random + +@DisplayName("DateTimeExtensions") +class DateTimeExtensionsTest { + + @Test + fun shouldGetCurrentTimeAtUTCTruncatedToSeconds() { + val now = nowUtcTruncatedToSeconds() + + assertAll( + { assertThat(now.offset).isEqualTo(ZoneOffset.UTC) }, + { assertThat(now.nano).isZero() } + ) + } + + @Test + fun shouldGetCurrentTimeAtUTC() { + val now = nowUtc() + + assertAll( + { assertThat(now.offset).isEqualTo(ZoneOffset.UTC) }, + { assertThat(now).isCloseTo(ZonedDateTime.now(ZoneOffset.UTC), within(100, ChronoUnit.MILLIS)) } + ) + } + + @RepeatedTest(10) + fun shouldTruncateZonedDateTimeToSeconds() { + val randomMinutes = Random.nextLong(1, 100) + val offset = Random.nextInt(10) + val now = ZonedDateTime.now(ZoneOffset.ofHours(offset)).plusMinutes(randomMinutes) + val nowWithSecondPrecision = now.truncatedToSeconds() + + assertAll( + { assertThat(nowWithSecondPrecision.offset).isEqualTo(now.offset) }, + { assertThat(nowWithSecondPrecision.nano).isZero() } + ) + } + + @RepeatedTest(10) + fun shouldCreateZonedDateTimeAtUTCFromEpochSeconds() { + val originalZonedDateTime = ZonedDateTime + .now(ZoneOffset.ofHours(Random.nextInt(10))) + .plusMinutes(Random.nextLong(0, 60)) + val epochSeconds = originalZonedDateTime.toEpochSecond() + + val utcZonedDateTime = utcZonedDateTimeFromEpochSeconds(epochSeconds) + + assertThat(utcZonedDateTime) + .isEqualTo(Instant.ofEpochSecond(epochSeconds).atZone(ZoneId.of("UTC"))) + } +} diff --git a/src/test/kotlin/org/kiwiproject/changelog/extension/MockWebServerExtensions.kt b/src/test/kotlin/org/kiwiproject/changelog/extension/MockWebServerExtensions.kt index 348d45e..5cddbfc 100644 --- a/src/test/kotlin/org/kiwiproject/changelog/extension/MockWebServerExtensions.kt +++ b/src/test/kotlin/org/kiwiproject/changelog/extension/MockWebServerExtensions.kt @@ -32,15 +32,6 @@ fun MockWebServer.urlWithoutTrailingSlashAsString(path: String): String = fun MockWebServer.takeRequestWith1SecTimeout(): RecordedRequest = this.takeRequest(1, TimeUnit.SECONDS)!! -/** - * Calls [MockWebServer.takeRequest] method with a 5-millisecond timeout. - * - * Use this when you don't expect there to be any more requests, and - * verify it by ensuring the returned `RecordedRequest` is `null`. - */ -fun MockWebServer.takeRequestWith1MilliTimeout() : RecordedRequest? = - this.takeRequest(1, TimeUnit.MILLISECONDS) - /** * Asserts that there are no more recorded requests for a [MockWebServer]. */ @@ -65,17 +56,28 @@ fun MockResponse.addJsonContentTypeHeader() : MockResponse { } /** - * Adds the GitHub + * Decrements `rateLimitRemaining`, and adds the GitHub * [rate limit headers](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api#checking-the-status-of-your-rate-limit). * * The rate limit reset time is calculated as "now" plus 42 minutes (naturally). */ fun MockResponse.addGitHubRateLimitHeaders(): MockResponse { rateLimitRemaining-- + return addGitHubRateLimitHeaders(rateLimitRemaining) +} + +/** + * Adds the GitHub + * [rate limit headers](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api#checking-the-status-of-your-rate-limit). + * + * The rate limit reset time is calculated as "now" plus 42 minutes (naturally). + */ +fun MockResponse.addGitHubRateLimitHeaders(rateLimitRemaining: Long): MockResponse { val rateLimitResetAt = Instant.now().plus(42, ChronoUnit.MINUTES).epochSecond addHeader("X-RateLimit-Limit", rateLimitLimit) addHeader("X-RateLimit-Remaining", rateLimitRemaining) addHeader("X-RateLimit-Reset", rateLimitResetAt) + addHeader("X-RateLimit-Resource", "core") return this } diff --git a/src/test/kotlin/org/kiwiproject/changelog/github/GitHubApiTest.kt b/src/test/kotlin/org/kiwiproject/changelog/github/GitHubApiTest.kt index f78fbc6..8efeb6a 100644 --- a/src/test/kotlin/org/kiwiproject/changelog/github/GitHubApiTest.kt +++ b/src/test/kotlin/org/kiwiproject/changelog/github/GitHubApiTest.kt @@ -15,21 +15,20 @@ import org.junit.jupiter.api.assertAll import org.junit.jupiter.api.extension.RegisterExtension import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource +import org.junitpioneer.jupiter.params.LongRangeSource import org.kiwiproject.changelog.MockWebServerExtension import org.kiwiproject.changelog.extension.addGitHubRateLimitHeaders import org.kiwiproject.changelog.extension.addJsonContentTypeHeader +import org.kiwiproject.changelog.extension.assertNoMoreRequests import org.kiwiproject.changelog.extension.rateLimitLimit import org.kiwiproject.changelog.extension.takeRequestWith1SecTimeout import org.kiwiproject.changelog.extension.urlWithoutTrailingSlashAsString -import org.kiwiproject.changelog.github.GitHubApi.GitHubResponse.Companion.humanTimeUntilReset import org.kiwiproject.test.util.Fixtures import org.kiwiproject.time.KiwiDurationFormatters -import java.net.http.HttpHeaders import java.time.Duration import java.time.Instant -import java.time.ZoneOffset -import java.time.format.DateTimeFormatter import java.util.concurrent.TimeUnit +import kotlin.random.Random private const val ISSUES_PATH = "/repos/kiwiproject/dropwizard-service-utilities/issues?page=1&per_page=10&state=closed&filter=all&direction=desc" @@ -87,6 +86,8 @@ class GitHubApiTest { { assertThat(getRequest.method).isEqualTo("GET") }, { assertThat(getRequest.requestUrl).hasToString(url) }, ) + + server.assertNoMoreRequests() } } @@ -125,6 +126,8 @@ class GitHubApiTest { { assertThat(postRequest.requestUrl).hasToString(url) }, { assertThat(postRequest.body.readUtf8()).isEqualTo(bodyJson) }, ) + + server.assertNoMoreRequests() } } @@ -169,6 +172,38 @@ class GitHubApiTest { { assertThat(patchRequest.requestUrl).hasToString(url) }, { assertThat(patchRequest.body.readUtf8()).isEqualTo(bodyJson) }, ) + + server.assertNoMoreRequests() + } + } + + @Nested + inner class RateLimitCheck { + + @Test + fun shouldThrowIllegalState_WhenRateLimitIsExceeded() { + val issueResponseJson = Fixtures.fixture("github-issues-response.json") + + server.enqueue( + MockResponse() + .setResponseCode(200) + .setBody(issueResponseJson) + .addJsonContentTypeHeader() + .addGitHubRateLimitHeaders(rateLimitRemaining = 0) + ) + + val url = server.urlWithoutTrailingSlashAsString(ISSUES_PATH) + assertThatIllegalStateException() + .isThrownBy { githubApi.get(url) } + .withMessageContaining("Rate limit exceeded") + + val getRequest = server.takeRequestWith1SecTimeout() + assertAll( + { assertThat(getRequest.method).isEqualTo("GET") }, + { assertThat(getRequest.requestUrl).hasToString(url) }, + ) + + server.assertNoMoreRequests() } } @@ -179,7 +214,8 @@ class GitHubApiTest { @ValueSource(longs = [0, 5, 10, 60, 180, 86_400]) fun shouldReturnFormattedDuration_WhenPositiveOrZero(durationSeconds: Long) { val duration = Duration.ofSeconds(durationSeconds) - val timeUntilReset = humanTimeUntilReset(duration) + val rateLimitRemaining = Random.nextLong(RATE_LIMIT_REMAINING_WARNING_THRESHOLD + 1, 30) + val timeUntilReset = humanTimeUntilReset(duration, rateLimitRemaining) assertAll( { assertThat(timeUntilReset.isNegative).isFalse() }, { assertThat(timeUntilReset.logLevel).isEqualTo(Level.DEBUG) }, @@ -190,11 +226,28 @@ class GitHubApiTest { ) } + @ParameterizedTest + @LongRangeSource(from = RATE_LIMIT_REMAINING_WARNING_THRESHOLD + 1, to = 10, closed = true) + fun shouldReturnDebugLogLevel_WhenRateLimitRemainingIsAboveWarningThreshold(rateLimitRemaining: Long) { + val duration = Duration.ofSeconds(45) + val timeUntilReset = humanTimeUntilReset(duration, rateLimitRemaining) + assertThat(timeUntilReset.logLevel).isEqualTo(Level.DEBUG) + } + + @ParameterizedTest + @LongRangeSource(from = 0, to = RATE_LIMIT_REMAINING_WARNING_THRESHOLD, closed = true) + fun shouldReturnWarnLogLevel_WhenRateLimitRemainingIsAtOrBelowWarningThreshold(rateLimitRemaining: Long) { + val duration = Duration.ofSeconds(60) + val timeUntilReset = humanTimeUntilReset(duration, rateLimitRemaining) + assertThat(timeUntilReset.logLevel).isEqualTo(Level.WARN) + } + @ParameterizedTest @ValueSource(longs = [-100, -50, -25, -1]) fun shouldReturnWarning_WhenNegative(durationSeconds: Long) { val duration = Duration.ofSeconds(durationSeconds) - val timeUntilReset = humanTimeUntilReset(duration) + val rateLimitRemaining = Random.nextLong(10, 30) + val timeUntilReset = humanTimeUntilReset(duration, rateLimitRemaining) assertAll( { assertThat(timeUntilReset.isNegative).isTrue() }, { assertThat(timeUntilReset.logLevel).isEqualTo(Level.WARN) }, @@ -202,31 +255,4 @@ class GitHubApiTest { ) } } - - @Nested - inner class ResetLimitAsIsoFormatted { - - @Test - fun shouldReturnFormattedDateTime_WhenHeaderExists() { - val resetLimitEpochSeconds = Instant.now().epochSecond - - val responseHeaders = HttpHeaders.of(mapOf( - "X-RateLimit-Reset" to listOf(resetLimitEpochSeconds.toString()) - )) { _, _ -> true } - - val result = resetLimitAsIsoFormatted(responseHeaders) - assertThat(result).isEqualTo( - DateTimeFormatter.ISO_LOCAL_DATE_TIME.format( - Instant.ofEpochSecond(resetLimitEpochSeconds).atZone(ZoneOffset.UTC)) - ) - } - - @Test - fun shouldThrowIllegalState_WhenHeaderExists() { - val responseHeaders = HttpHeaders.of(mapOf>()) { _, _ -> true } - - assertThatIllegalStateException() - .isThrownBy { resetLimitAsIsoFormatted(responseHeaders) } - } - } } diff --git a/src/test/kotlin/org/kiwiproject/changelog/github/GitHubPagingHelperTest.kt b/src/test/kotlin/org/kiwiproject/changelog/github/GitHubPagingHelperTest.kt index f5ad154..2bdc635 100644 --- a/src/test/kotlin/org/kiwiproject/changelog/github/GitHubPagingHelperTest.kt +++ b/src/test/kotlin/org/kiwiproject/changelog/github/GitHubPagingHelperTest.kt @@ -52,7 +52,8 @@ class GitHubPagingHelperTest { null, 60, 42, - Instant.now().plus(30, ChronoUnit.MINUTES).epochSecond + Instant.now().plus(30, ChronoUnit.MINUTES).epochSecond, + "core" ) } diff --git a/src/test/kotlin/org/kiwiproject/changelog/github/GitHubResponseTest.kt b/src/test/kotlin/org/kiwiproject/changelog/github/GitHubResponseTest.kt new file mode 100644 index 0000000..0cfbbfb --- /dev/null +++ b/src/test/kotlin/org/kiwiproject/changelog/github/GitHubResponseTest.kt @@ -0,0 +1,90 @@ +package org.kiwiproject.changelog.github + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.RepeatedTest +import org.junit.jupiter.api.assertAll +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.kiwiproject.changelog.extension.nowUtc +import org.kiwiproject.changelog.extension.truncatedToSeconds +import org.kiwiproject.changelog.extension.utcZonedDateTimeFromEpochSeconds +import org.kiwiproject.changelog.github.GitHubApi.GitHubResponse +import java.net.URI +import java.time.Duration +import java.time.ZonedDateTime +import kotlin.random.Random + +@DisplayName("GitHubResponse") +class GitHubResponseTest { + + @RepeatedTest(10) + fun shouldReturnTheZonedDateTimeWhenRateLimitResetsAtUTC() { + val randomMinutesFromNow = Random.nextLong(1, 61) + val rateLimitResetAt = ZonedDateTime.now().plusMinutes(randomMinutesFromNow) + val rateLimitResetAtEpochSeconds = rateLimitResetAt.toEpochSecond() + + val response = GitHubResponse( + 200, + URI.create("https://fake-github.com/some-request"), + "{}", + null, + 5000, + 4997, + rateLimitResetAtEpochSeconds, + "core" + ) + + val now = nowUtc() + val duration = response.timeUntilRateLimitResetsFrom(now) + + assertThat(duration).isEqualTo(Duration.between(now, rateLimitResetAt.truncatedToSeconds())) + } + + @RepeatedTest(10) + fun shouldCalculateTimeUntilRateLimitResets() { + val randomMinutesFromNow = Random.nextLong(1, 61) + val rateLimitResetAt = ZonedDateTime.now().plusMinutes(randomMinutesFromNow).toEpochSecond() + + val response = GitHubResponse( + 200, + URI.create("https://fake-github.com/some-request"), + "{}", + null, + 5000, + 4997, + rateLimitResetAt, + "core" + ) + + assertThat(response.resetAt()) + .isEqualTo(utcZonedDateTimeFromEpochSeconds(rateLimitResetAt)) + } + + @ParameterizedTest + @CsvSource( + textBlock = """ + 0, true + 1, false + 10, false + 1000, false + 4999, false""" + ) + fun shouldCheckIfRateLimitHasBeenExceeded(rateLimitRemaining: Long, expectToExceed: Boolean) { + val response = GitHubResponse( + 200, + URI.create("https://fake-github.com/some-request"), + "{}", + null, + 10, + rateLimitRemaining, + ZonedDateTime.now().plusMinutes(1).toEpochSecond(), + "core" + ) + + assertAll( + { assertThat(response.belowRateLimit()).isEqualTo(!expectToExceed) }, + { assertThat(response.exceededRateLimit()).isEqualTo(expectToExceed) }, + ) + } +}