diff --git a/src/main/java/org/kohsuke/github/GitHubAbuseLimitHandler.java b/src/main/java/org/kohsuke/github/GitHubAbuseLimitHandler.java index 2c79ab4119..30de171417 100644 --- a/src/main/java/org/kohsuke/github/GitHubAbuseLimitHandler.java +++ b/src/main/java/org/kohsuke/github/GitHubAbuseLimitHandler.java @@ -2,15 +2,13 @@ import org.apache.commons.io.IOUtils; import org.kohsuke.github.connector.GitHubConnectorResponse; +import org.kohsuke.github.internal.WaitTimeUtils; import java.io.IOException; import java.io.InputStream; import java.io.InterruptedIOException; import java.nio.charset.StandardCharsets; import java.time.Duration; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; -import java.time.temporal.ChronoUnit; import java.util.regex.Pattern; import javax.annotation.Nonnull; @@ -79,32 +77,12 @@ public void onError(GitHubConnectorResponse connectorResponse) throws IOExceptio /* * Exposed for testability. Given an http response, find the retry-after header field and parse it as either a * number or a date (the spec allows both). If no header is found, wait for a reasonably amount of time. + * + * The implementation lives in WaitTimeUtils, shared with GitHubRateLimitHandler. */ static long parseWaitTime(GitHubConnectorResponse connectorResponse) { - String v = connectorResponse.header("Retry-After"); - if (v == null) { - return DEFAULT_WAIT_MILLIS; - } - - try { - return Math.max(MINIMUM_ABUSE_RETRY_MILLIS, Duration.ofSeconds(Long.parseLong(v)).toMillis()); - } catch (NumberFormatException nfe) { - // The retry-after header could be a number in seconds, or an http-date - // We know it was a date if we got a number format exception :) - - // Don't use ZonedDateTime.now(), because the local and remote server times may not be in sync - // Instead, we can take advantage of the Date field in the response to see what time the remote server - // thinks it is - String dateField = connectorResponse.header("Date"); - ZonedDateTime now; - if (dateField != null) { - now = ZonedDateTime.parse(dateField, DateTimeFormatter.RFC_1123_DATE_TIME); - } else { - now = ZonedDateTime.now(); - } - ZonedDateTime zdt = ZonedDateTime.parse(v, DateTimeFormatter.RFC_1123_DATE_TIME); - return Math.max(MINIMUM_ABUSE_RETRY_MILLIS, ChronoUnit.MILLIS.between(now, zdt)); - } + return WaitTimeUtils + .parseRetryAfterWaitTime(connectorResponse, DEFAULT_WAIT_MILLIS, MINIMUM_ABUSE_RETRY_MILLIS); } /** diff --git a/src/main/java/org/kohsuke/github/GitHubRateLimitHandler.java b/src/main/java/org/kohsuke/github/GitHubRateLimitHandler.java index dd6149b1bd..12f523aef8 100644 --- a/src/main/java/org/kohsuke/github/GitHubRateLimitHandler.java +++ b/src/main/java/org/kohsuke/github/GitHubRateLimitHandler.java @@ -2,12 +2,11 @@ import org.jetbrains.annotations.NotNull; import org.kohsuke.github.connector.GitHubConnectorResponse; +import org.kohsuke.github.internal.WaitTimeUtils; import java.io.IOException; import java.io.InterruptedIOException; import java.time.Duration; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; import javax.annotation.Nonnull; @@ -99,23 +98,13 @@ boolean isError(@NotNull GitHubConnectorResponse connectorResponse) throws IOExc /* * Exposed for testability. Given an http response, find the rate limit reset header field and parse it. If no * header is found, wait for a reasonably amount of time. + * + * The implementation lives in WaitTimeUtils, shared with GitHubAbuseLimitHandler. */ long parseWaitTime(GitHubConnectorResponse connectorResponse) { - String v = connectorResponse.header("X-RateLimit-Reset"); - if (v == null) - return Duration.ofMinutes(1).toMillis(); // can't tell, return 1 min - - // Don't use ZonedDateTime.now(), because the local and remote server times may not be in sync - // Instead, we can take advantage of the Date field in the response to see what time the remote server - // thinks it is - String dateField = connectorResponse.header("Date"); - ZonedDateTime now; - if (dateField != null) { - now = ZonedDateTime.parse(dateField, DateTimeFormatter.RFC_1123_DATE_TIME); - } else { - now = ZonedDateTime.now(); - } - return Math.max(MINIMUM_RATE_LIMIT_RETRY_MILLIS, (Long.parseLong(v) - now.toInstant().getEpochSecond()) * 1000); + return WaitTimeUtils.parseRateLimitResetWaitTime(connectorResponse, + Duration.ofMinutes(1).toMillis(), // can't tell, return 1 min + MINIMUM_RATE_LIMIT_RETRY_MILLIS); } } diff --git a/src/main/java/org/kohsuke/github/internal/WaitTimeUtils.java b/src/main/java/org/kohsuke/github/internal/WaitTimeUtils.java new file mode 100644 index 0000000000..a0fcccfc82 --- /dev/null +++ b/src/main/java/org/kohsuke/github/internal/WaitTimeUtils.java @@ -0,0 +1,105 @@ +package org.kohsuke.github.internal; + +import org.kohsuke.github.connector.GitHubConnectorResponse; + +import java.time.Duration; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; + +import javax.annotation.Nonnull; + +/** + * Shared logic for computing how long to wait before retrying a request after GitHub asks the client to slow down. + * + *
+ * Extracted from {@code GitHubAbuseLimitHandler} and {@code GitHubRateLimitHandler}, which previously each carried + * their own near-duplicate {@code parseWaitTime} implementation. + *
+ */ +public final class WaitTimeUtils { + + /** + * Computes the wait time described by the {@code X-RateLimit-Reset} header of a response. + * + *+ * The header value is a Unix epoch timestamp (in seconds) marking when the rate limit window resets. If the header + * is missing, {@code defaultWaitMillis} is returned. The result is never smaller than {@code minimumWaitMillis}. + *
+ * + * @param connectorResponse + * the response to read the {@code X-RateLimit-Reset} header from + * @param defaultWaitMillis + * the wait to use when the response carries no {@code X-RateLimit-Reset} header + * @param minimumWaitMillis + * the minimum wait to return, even if the reset time has already passed + * @return the number of milliseconds to wait before retrying + */ + public static long parseRateLimitResetWaitTime(@Nonnull GitHubConnectorResponse connectorResponse, + long defaultWaitMillis, + long minimumWaitMillis) { + String v = connectorResponse.header("X-RateLimit-Reset"); + if (v == null) { + return defaultWaitMillis; + } + + ZonedDateTime now = serverNow(connectorResponse); + return Math.max(minimumWaitMillis, (Long.parseLong(v) - now.toInstant().getEpochSecond()) * 1000); + } + + /** + * Computes the wait time described by the {@code Retry-After} header of a response. + * + *+ * The header value is either a number of seconds or an HTTP-date (the spec allows both). If the header is missing, + * {@code defaultWaitMillis} is returned. The result is never smaller than {@code minimumWaitMillis}. + *
+ * + * @param connectorResponse + * the response to read the {@code Retry-After} header from + * @param defaultWaitMillis + * the wait to use when the response carries no {@code Retry-After} header + * @param minimumWaitMillis + * the minimum wait to return, even if the response suggests a shorter one + * @return the number of milliseconds to wait before retrying + */ + public static long parseRetryAfterWaitTime(@Nonnull GitHubConnectorResponse connectorResponse, + long defaultWaitMillis, + long minimumWaitMillis) { + String v = connectorResponse.header("Retry-After"); + if (v == null) { + return defaultWaitMillis; + } + + try { + return Math.max(minimumWaitMillis, Duration.ofSeconds(Long.parseLong(v)).toMillis()); + } catch (NumberFormatException nfe) { + // The retry-after header could be a number in seconds, or an http-date + // We know it was a date if we got a number format exception :) + ZonedDateTime zdt = ZonedDateTime.parse(v, DateTimeFormatter.RFC_1123_DATE_TIME); + return Math.max(minimumWaitMillis, ChronoUnit.MILLIS.between(serverNow(connectorResponse), zdt)); + } + } + + /** + * Determines what time the remote server thinks it is, based on the {@code Date} header of the response. + * + *+ * The local clock is only used as a fallback, because the local and remote server times may not be in sync. + *
+ */ + private static ZonedDateTime serverNow(GitHubConnectorResponse connectorResponse) { + // Don't use ZonedDateTime.now(), because the local and remote server times may not be in sync + // Instead, we can take advantage of the Date field in the response to see what time the remote server + // thinks it is + String dateField = connectorResponse.header("Date"); + if (dateField != null) { + return ZonedDateTime.parse(dateField, DateTimeFormatter.RFC_1123_DATE_TIME); + } else { + return ZonedDateTime.now(); + } + } + + private WaitTimeUtils() { + } +} diff --git a/src/test/java/org/kohsuke/github/internal/WaitTimeUtilsTest.java b/src/test/java/org/kohsuke/github/internal/WaitTimeUtilsTest.java new file mode 100644 index 0000000000..e3f1e44853 --- /dev/null +++ b/src/test/java/org/kohsuke/github/internal/WaitTimeUtilsTest.java @@ -0,0 +1,114 @@ +package org.kohsuke.github.internal; + +import org.junit.Test; +import org.kohsuke.github.connector.GitHubConnectorResponse; + +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.lessThanOrEqualTo; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link WaitTimeUtils}, the shared implementation behind {@code GitHubAbuseLimitHandler.parseWaitTime} and + * {@code GitHubRateLimitHandler.parseWaitTime}. + */ +public class WaitTimeUtilsTest { + + private static final long DEFAULT_WAIT = 61_000L; + private static final long MINIMUM_WAIT = 1_000L; + + private static String httpDate(ZonedDateTime dateTime) { + return DateTimeFormatter.RFC_1123_DATE_TIME.format(dateTime.withZoneSameInstant(ZoneOffset.UTC)); + } + + @Test + public void rateLimitResetClampsToMinimumWhenResetAlreadyPassed() { + ZonedDateTime serverNow = ZonedDateTime.of(2026, 10, 16, 12, 0, 0, 0, ZoneOffset.UTC); + long resetEpochSeconds = serverNow.minusSeconds(90).toInstant().getEpochSecond(); + + GitHubConnectorResponse response = mock(GitHubConnectorResponse.class); + when(response.header("Date")).thenReturn(httpDate(serverNow)); + when(response.header("X-RateLimit-Reset")).thenReturn(String.valueOf(resetEpochSeconds)); + + assertThat(WaitTimeUtils.parseRateLimitResetWaitTime(response, DEFAULT_WAIT, MINIMUM_WAIT), + equalTo(MINIMUM_WAIT)); + } + + @Test + public void rateLimitResetComputesWaitFromResponseDate() { + ZonedDateTime serverNow = ZonedDateTime.of(2026, 10, 16, 12, 0, 0, 0, ZoneOffset.UTC); + long resetEpochSeconds = serverNow.plusSeconds(90).toInstant().getEpochSecond(); + + GitHubConnectorResponse response = mock(GitHubConnectorResponse.class); + when(response.header("Date")).thenReturn(httpDate(serverNow)); + when(response.header("X-RateLimit-Reset")).thenReturn(String.valueOf(resetEpochSeconds)); + + assertThat(WaitTimeUtils.parseRateLimitResetWaitTime(response, DEFAULT_WAIT, MINIMUM_WAIT), equalTo(90_000L)); + } + + @Test + public void rateLimitResetFallsBackToLocalTimeWhenDateHeaderMissing() { + // No Date header: serverNow() must fall back to the local clock. + // The result depends on the wall clock, so assert a range around the expected wait + // instead of an exact value (the reset timestamp has one-second precision). + long resetEpochSeconds = ZonedDateTime.now().plusSeconds(120).toInstant().getEpochSecond(); + + GitHubConnectorResponse response = mock(GitHubConnectorResponse.class); + when(response.header("Date")).thenReturn(null); + when(response.header("X-RateLimit-Reset")).thenReturn(String.valueOf(resetEpochSeconds)); + + long wait = WaitTimeUtils.parseRateLimitResetWaitTime(response, DEFAULT_WAIT, MINIMUM_WAIT); + assertThat(wait, greaterThanOrEqualTo(115_000L)); + assertThat(wait, lessThanOrEqualTo(120_000L)); + } + + @Test + public void rateLimitResetReturnsDefaultWhenHeaderMissing() { + GitHubConnectorResponse response = mock(GitHubConnectorResponse.class); + when(response.header("X-RateLimit-Reset")).thenReturn(null); + + assertThat(WaitTimeUtils.parseRateLimitResetWaitTime(response, DEFAULT_WAIT, MINIMUM_WAIT), + equalTo(DEFAULT_WAIT)); + } + + @Test + public void retryAfterClampsToMinimum() { + GitHubConnectorResponse response = mock(GitHubConnectorResponse.class); + when(response.header("Retry-After")).thenReturn("0"); + + assertThat(WaitTimeUtils.parseRetryAfterWaitTime(response, DEFAULT_WAIT, MINIMUM_WAIT), equalTo(MINIMUM_WAIT)); + } + + @Test + public void retryAfterParsesHttpDateRelativeToResponseDate() { + ZonedDateTime serverNow = ZonedDateTime.of(2026, 10, 16, 12, 0, 0, 0, ZoneOffset.UTC); + + GitHubConnectorResponse response = mock(GitHubConnectorResponse.class); + when(response.header("Date")).thenReturn(httpDate(serverNow)); + when(response.header("Retry-After")).thenReturn(httpDate(serverNow.plusSeconds(45))); + + assertThat(WaitTimeUtils.parseRetryAfterWaitTime(response, DEFAULT_WAIT, MINIMUM_WAIT), equalTo(45_000L)); + } + + @Test + public void retryAfterParsesSeconds() { + GitHubConnectorResponse response = mock(GitHubConnectorResponse.class); + when(response.header("Retry-After")).thenReturn("30"); + + assertThat(WaitTimeUtils.parseRetryAfterWaitTime(response, DEFAULT_WAIT, MINIMUM_WAIT), equalTo(30_000L)); + } + + @Test + public void retryAfterReturnsDefaultWhenHeaderMissing() { + GitHubConnectorResponse response = mock(GitHubConnectorResponse.class); + when(response.header("Retry-After")).thenReturn(null); + + assertThat(WaitTimeUtils.parseRetryAfterWaitTime(response, DEFAULT_WAIT, MINIMUM_WAIT), equalTo(DEFAULT_WAIT)); + } +}