Skip to content
Closed
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
32 changes: 5 additions & 27 deletions src/main/java/org/kohsuke/github/GitHubAbuseLimitHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

/**
Expand Down
23 changes: 6 additions & 17 deletions src/main/java/org/kohsuke/github/GitHubRateLimitHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}

}
105 changes: 105 additions & 0 deletions src/main/java/org/kohsuke/github/internal/WaitTimeUtils.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>
* Extracted from {@code GitHubAbuseLimitHandler} and {@code GitHubRateLimitHandler}, which previously each carried
* their own near-duplicate {@code parseWaitTime} implementation.
* </p>
*/
public final class WaitTimeUtils {

/**
* Computes the wait time described by the {@code X-RateLimit-Reset} header of a response.
*
* <p>
* 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}.
* </p>
*
* @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.
*
* <p>
* 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}.
* </p>
*
* @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.
*
* <p>
* The local clock is only used as a fallback, because the local and remote server times may not be in sync.
* </p>
*/
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() {
}
}
114 changes: 114 additions & 0 deletions src/test/java/org/kohsuke/github/internal/WaitTimeUtilsTest.java
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading