diff --git a/backend/src/main/java/de/otto/platform/gitactionboard/adapters/service/GithubApiService.java b/backend/src/main/java/de/otto/platform/gitactionboard/adapters/service/GithubApiService.java index af658bb1..e9893f83 100644 --- a/backend/src/main/java/de/otto/platform/gitactionboard/adapters/service/GithubApiService.java +++ b/backend/src/main/java/de/otto/platform/gitactionboard/adapters/service/GithubApiService.java @@ -1,18 +1,29 @@ package de.otto.platform.gitactionboard.adapters.service; import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.http.HttpStatus.FORBIDDEN; +import static org.springframework.http.HttpStatus.TOO_MANY_REQUESTS; +import java.time.Duration; +import java.time.Instant; import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestClient; @Slf4j @Service public class GithubApiService { + private static final String RATE_LIMIT_REMAINING_HEADER = "x-ratelimit-remaining"; + private static final String RATE_LIMIT_RESET_HEADER = "x-ratelimit-reset"; + private final String authToken; private final RestClient.Builder restClientBuilder; + private final AtomicReference rateLimitResetAt = new AtomicReference<>(Instant.EPOCH); public GithubApiService( @Qualifier("domainName") String baseUri, @@ -34,6 +45,60 @@ private RestClient getRestClient(String accessToken) { } public T getForObject(String url, String accessToken, Class responseType) { - return getRestClient(accessToken).get().uri(url).retrieve().body(responseType); + final RestClient restClient = getRestClient(accessToken); + + // Another request may already have tripped the GitHub rate limit; hold off until it resets. + waitUntilRateLimitResets(); + + try { + return restClient.get().uri(url).retrieve().body(responseType); + } catch (HttpClientErrorException exception) { + if (!isRateLimited(exception)) { + throw exception; + } + + rememberRateLimitReset(exception); + waitUntilRateLimitResets(); + + // Retry once now that the window has reset; any further failure propagates to the caller. + return restClient.get().uri(url).retrieve().body(responseType); + } + } + + private static boolean isRateLimited(HttpClientErrorException exception) { + final boolean rateLimitStatus = + FORBIDDEN.equals(exception.getStatusCode()) + || TOO_MANY_REQUESTS.equals(exception.getStatusCode()); + final HttpHeaders headers = exception.getResponseHeaders(); + return rateLimitStatus + && headers != null + && "0".equals(headers.getFirst(RATE_LIMIT_REMAINING_HEADER)); + } + + private void rememberRateLimitReset(HttpClientErrorException exception) { + final HttpHeaders headers = exception.getResponseHeaders(); + final String resetEpochSeconds = + headers == null ? null : headers.getFirst(RATE_LIMIT_RESET_HEADER); + if (resetEpochSeconds == null) { + return; + } + final Instant resetAt = Instant.ofEpochSecond(Long.parseLong(resetEpochSeconds)); + rateLimitResetAt.accumulateAndGet( + resetAt, (current, candidate) -> candidate.isAfter(current) ? candidate : current); + } + + private void waitUntilRateLimitResets() { + final Duration waitFor = Duration.between(Instant.now(), rateLimitResetAt.get()); + if (waitFor.isZero() || waitFor.isNegative()) { + return; + } + log.warn("GitHub rate limit hit; waiting {}s until reset", waitFor.toSeconds()); + try { + Thread.sleep(waitFor.toMillis()); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + throw new IllegalStateException( + "Interrupted while waiting for GitHub rate limit to reset", interruptedException); + } } } diff --git a/backend/src/test/java/de/otto/platform/gitactionboard/adapters/service/GithubApiServiceTest.java b/backend/src/test/java/de/otto/platform/gitactionboard/adapters/service/GithubApiServiceTest.java new file mode 100644 index 00000000..b29c5db3 --- /dev/null +++ b/backend/src/test/java/de/otto/platform/gitactionboard/adapters/service/GithubApiServiceTest.java @@ -0,0 +1,67 @@ +package de.otto.platform.gitactionboard.adapters.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockserver.matchers.Times.once; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; +import static org.mockserver.verify.VerificationTimes.exactly; + +import de.otto.platform.gitactionboard.IntegrationTest; +import de.otto.platform.gitactionboard.Sequential; +import java.time.Instant; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockserver.integration.ClientAndServer; +import org.springframework.web.client.RestClient; + +@IntegrationTest +@Sequential +class GithubApiServiceTest { + private static final String OWNER = "johndoe"; + private static final String PATH = "/hello-world/branches"; + private static final String FULL_PATH = "/repos/%s%s".formatted(OWNER, PATH); + private static final String ACCESS_TOKEN = "accessToken"; + private static final String RESPONSE_BODY = "{\"ok\":true}"; + + private ClientAndServer mockServer; + private GithubApiService githubApiService; + + @BeforeEach + void setUp() { + mockServer = ClientAndServer.startClientAndServer(); + githubApiService = + new GithubApiService( + "http://localhost:%d".formatted(mockServer.getPort()), + OWNER, + "defaultToken", + RestClient.builder()); + } + + @AfterEach + void tearDown() { + mockServer.stop(); + } + + @Test + void shouldWaitForRateLimitToResetAndThenRetryTheRequest() { + // First call is rate limited; reset ~1s in the future. + mockServer + .when(request().withMethod("GET").withPath(FULL_PATH), once()) + .respond( + response() + .withStatusCode(403) + .withHeader("x-ratelimit-remaining", "0") + .withHeader("x-ratelimit-reset", String.valueOf(Instant.now().getEpochSecond() + 1))); + + // Second call (after the wait) succeeds. + mockServer + .when(request().withMethod("GET").withPath(FULL_PATH)) + .respond(response().withStatusCode(200).withBody(RESPONSE_BODY)); + + final String body = githubApiService.getForObject(PATH, ACCESS_TOKEN, String.class); + + assertThat(body).isEqualTo(RESPONSE_BODY); + mockServer.verify(request().withMethod("GET").withPath(FULL_PATH), exactly(2)); + } +}