Skip to content

Commit 314917e

Browse files
Accept 429 response codes as an indication of the secondary rate limit being exceeded (#1895)
* Accept 429 as an indication of the rate limit Co-Authored-By: Liam Newman <[email protected]> * Remove assertion which is mostly asserting about the configuration of wiremock * Add extra test to bring coverage above threshold, also handle case where retry-after is a date, and light refactoring * Reformat header to resolve warning * Update src/main/java/org/kohsuke/github/AbuseLimitHandler.java * Update src/main/java/org/kohsuke/github/AbuseLimitHandler.java * Apply suggestions from code review --------- Co-authored-by: Liam Newman <[email protected]>
1 parent 7db2ec0 commit 314917e

File tree

18 files changed

+1155
-27
lines changed

18 files changed

+1155
-27
lines changed

src/main/java/org/kohsuke/github/AbuseLimitHandler.java

+24-7
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
import java.io.IOException;
66
import java.io.InterruptedIOException;
77
import java.net.HttpURLConnection;
8+
import java.time.ZonedDateTime;
9+
import java.time.format.DateTimeFormatter;
10+
import java.time.temporal.ChronoUnit;
811

912
import javax.annotation.Nonnull;
1013

@@ -86,13 +89,6 @@ public void onError(IOException e, HttpURLConnection uc) throws IOException {
8689
}
8790
}
8891

89-
private long parseWaitTime(HttpURLConnection uc) {
90-
String v = uc.getHeaderField("Retry-After");
91-
if (v == null)
92-
return 60 * 1000; // can't tell, return 1 min
93-
94-
return Math.max(1000, Long.parseLong(v) * 1000);
95-
}
9692
};
9793

9894
/**
@@ -105,4 +101,25 @@ public void onError(IOException e, HttpURLConnection uc) throws IOException {
105101
throw e;
106102
}
107103
};
104+
105+
/*
106+
* Exposed for testability. Given an http response, find the retry-after header field and parse it as either a
107+
* number or a date (the spec allows both). If no header is found, wait for a reasonably amount of time.
108+
*/
109+
long parseWaitTime(HttpURLConnection uc) {
110+
String v = uc.getHeaderField("Retry-After");
111+
if (v == null) {
112+
// can't tell, wait for unambiguously over one minute per GitHub guidance
113+
return 61 * 1000;
114+
}
115+
116+
try {
117+
return Math.max(1000, Long.parseLong(v) * 1000);
118+
} catch (NumberFormatException nfe) {
119+
// The retry-after header could be a number in seconds, or an http-date
120+
ZonedDateTime zdt = ZonedDateTime.parse(v, DateTimeFormatter.RFC_1123_DATE_TIME);
121+
return ChronoUnit.MILLIS.between(ZonedDateTime.now(), zdt);
122+
}
123+
}
124+
108125
}

src/main/java/org/kohsuke/github/GitHubAbuseLimitHandler.java

+13-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,19 @@ public abstract class GitHubAbuseLimitHandler extends GitHubConnectorResponseErr
2929
*/
3030
@Override
3131
boolean isError(@Nonnull GitHubConnectorResponse connectorResponse) {
32-
return isForbidden(connectorResponse) && hasRetryOrLimitHeader(connectorResponse);
32+
return isTooManyRequests(connectorResponse)
33+
|| (isForbidden(connectorResponse) && hasRetryOrLimitHeader(connectorResponse));
34+
}
35+
36+
/**
37+
* Checks if the response status code is TOO_MANY_REQUESTS (429).
38+
*
39+
* @param connectorResponse
40+
* the response from the GitHub connector
41+
* @return true if the status code is TOO_MANY_REQUESTS
42+
*/
43+
private boolean isTooManyRequests(GitHubConnectorResponse connectorResponse) {
44+
return connectorResponse.statusCode() == TOO_MANY_REQUESTS;
3345
}
3446

3547
/**

src/main/java/org/kohsuke/github/GitHubConnectorResponseErrorHandler.java

+11
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@
2323
*/
2424
abstract class GitHubConnectorResponseErrorHandler {
2525

26+
/**
27+
* The HTTP 429 Too Many Requests response status code indicates the user has sent too many requests in a given
28+
* amount of time ("rate limiting").
29+
*
30+
* A Retry-After header might be included to this response indicating how long to wait before making a new request.
31+
*
32+
* Why is this hardcoded here? The HttpURLConnection class is missing the status codes above 415, so the constant
33+
* needs to be sourced from elsewhere.
34+
*/
35+
public static final int TOO_MANY_REQUESTS = 429;
36+
2637
/**
2738
* Called to detect an error handled by this handler.
2839
*

src/test/java/org/kohsuke/github/AbuseLimitHandlerTest.java

+199-19
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import static org.hamcrest.CoreMatchers.*;
1818
import static org.hamcrest.CoreMatchers.notNullValue;
19+
import static org.hamcrest.Matchers.greaterThan;
1920
import static org.hamcrest.core.IsInstanceOf.instanceOf;
2021

2122
// TODO: Auto-generated Javadoc
@@ -98,22 +99,14 @@ public void onError(IOException e, HttpURLConnection uc) throws IOException {
9899
// getting an input stream in an error case should throw
99100
IOException ioEx = Assert.assertThrows(IOException.class, () -> uc.getInputStream());
100101

101-
try (InputStream errorStream = uc.getErrorStream()) {
102-
assertThat(errorStream, notNullValue());
103-
String errorString = IOUtils.toString(errorStream, StandardCharsets.UTF_8);
104-
assertThat(errorString, containsString("Must have push access to repository"));
105-
}
102+
checkErrorMessageMatches(uc, "Must have push access to repository");
106103

107104
// calling again should still error
108105
ioEx = Assert.assertThrows(IOException.class, () -> uc.getInputStream());
109106

110107
// calling again on a GitHubConnectorResponse should yield the same value
111108
if (uc.toString().contains("GitHubConnectorResponseHttpUrlConnectionAdapter")) {
112-
try (InputStream errorStream = uc.getErrorStream()) {
113-
assertThat(errorStream, notNullValue());
114-
String errorString = IOUtils.toString(errorStream, StandardCharsets.UTF_8);
115-
assertThat(errorString, containsString("Must have push access to repository"));
116-
}
109+
checkErrorMessageMatches(uc, "Must have push access to repository");
117110
} else {
118111
try (InputStream errorStream = uc.getErrorStream()) {
119112
assertThat(errorStream, notNullValue());
@@ -126,7 +119,7 @@ public void onError(IOException e, HttpURLConnection uc) throws IOException {
126119
}
127120

128121
assertThat(uc.getHeaderFields(), instanceOf(Map.class));
129-
assertThat(uc.getHeaderFields().size(), Matchers.greaterThan(25));
122+
assertThat(uc.getHeaderFields().size(), greaterThan(25));
130123
assertThat(uc.getHeaderField("Status"), equalTo("403 Forbidden"));
131124

132125
String key = uc.getHeaderFieldKey(1);
@@ -349,16 +342,203 @@ public void onError(IOException e, HttpURLConnection uc) throws IOException {
349342
assertThat(uc.getContentType(), equalTo("application/json; charset=utf-8"));
350343
assertThat(uc.getContentLength(), equalTo(-1));
351344
assertThat(uc.getHeaderFields(), instanceOf(Map.class));
352-
assertThat(uc.getHeaderFields().size(), Matchers.greaterThan(25));
345+
assertThat(uc.getHeaderFields().size(), greaterThan(25));
353346
assertThat(uc.getHeaderField("Status"), equalTo("403 Forbidden"));
354347

355-
try (InputStream errorStream = uc.getErrorStream()) {
356-
assertThat(errorStream, notNullValue());
357-
String errorString = IOUtils.toString(errorStream, StandardCharsets.UTF_8);
358-
assertThat(errorString,
359-
containsString(
360-
"You have exceeded a secondary rate limit. Please wait a few minutes before you try again"));
361-
}
348+
checkErrorMessageMatches(uc,
349+
"You have exceeded a secondary rate limit. Please wait a few minutes before you try again");
350+
AbuseLimitHandler.FAIL.onError(e, uc);
351+
}
352+
})
353+
.build();
354+
355+
gitHub.getMyself();
356+
assertThat(mockGitHub.getRequestCount(), equalTo(1));
357+
try {
358+
getTempRepository();
359+
fail();
360+
} catch (Exception e) {
361+
assertThat(e, instanceOf(HttpException.class));
362+
assertThat(e.getMessage(), equalTo("Abuse limit reached"));
363+
}
364+
assertThat(mockGitHub.getRequestCount(), equalTo(2));
365+
}
366+
367+
/**
368+
* This is making an assertion about the behaviour of the mock, so it's useful for making sure we're on the right
369+
* mock, but should not be used to validate assumptions about the behaviour of the actual GitHub API.
370+
*/
371+
private static void checkErrorMessageMatches(HttpURLConnection uc, String substring) throws IOException {
372+
try (InputStream errorStream = uc.getErrorStream()) {
373+
assertThat(errorStream, notNullValue());
374+
String errorString = IOUtils.toString(errorStream, StandardCharsets.UTF_8);
375+
assertThat(errorString, containsString(substring));
376+
}
377+
}
378+
379+
/**
380+
* Tests the behavior of the GitHub API client when the abuse limit handler is set to WAIT then the handler waits
381+
* appropriately when secondary rate limits are encountered.
382+
*
383+
* @throws Exception
384+
* if any error occurs during the test execution.
385+
*/
386+
@Test
387+
public void testHandler_Wait_Secondary_Limits_Too_Many_Requests() throws Exception {
388+
// Customized response that templates the date to keep things working
389+
snapshotNotAllowed();
390+
final HttpURLConnection[] savedConnection = new HttpURLConnection[1];
391+
gitHub = getGitHubBuilder().withEndpoint(mockGitHub.apiServer().baseUrl())
392+
.withAbuseLimitHandler(new AbuseLimitHandler() {
393+
/**
394+
* Overriding method because the actual method will wait for one minute causing slowness in unit
395+
* tests
396+
*/
397+
@Override
398+
public void onError(IOException e, HttpURLConnection uc) throws IOException {
399+
savedConnection[0] = uc;
400+
// Verify the test data is what we expected it to be for this test case
401+
assertThat(uc.getDate(), Matchers.greaterThanOrEqualTo(new Date().getTime() - 10000));
402+
assertThat(uc.getExpiration(), equalTo(0L));
403+
assertThat(uc.getIfModifiedSince(), equalTo(0L));
404+
assertThat(uc.getLastModified(), equalTo(1581014017000L));
405+
assertThat(uc.getRequestMethod(), equalTo("GET"));
406+
assertThat(uc.getResponseCode(), equalTo(429));
407+
assertThat(uc.getResponseMessage(), containsString("Many"));
408+
assertThat(uc.getURL().toString(),
409+
endsWith(
410+
"/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Too_Many_Requests"));
411+
assertThat(uc.getContentLength(), equalTo(-1));
412+
assertThat(uc.getHeaderFields(), instanceOf(Map.class));
413+
assertThat(uc.getHeaderField("Status"), equalTo("429 Too Many Requests"));
414+
assertThat(uc.getHeaderField("Retry-After"), equalTo("42"));
415+
416+
checkErrorMessageMatches(uc,
417+
"You have exceeded a secondary rate limit. Please wait a few minutes before you try again");
418+
// Because we've overridden onError to bypass the wait, we don't cover the wait calculation
419+
// logic
420+
// Manually invoke it to make sure it's what we intended
421+
long waitTime = parseWaitTime(uc);
422+
assertThat(waitTime, equalTo(42 * 1000l));
423+
424+
AbuseLimitHandler.FAIL.onError(e, uc);
425+
}
426+
})
427+
.build();
428+
429+
gitHub.getMyself();
430+
assertThat(mockGitHub.getRequestCount(), equalTo(1));
431+
try {
432+
getTempRepository();
433+
fail();
434+
} catch (Exception e) {
435+
assertThat(e, instanceOf(HttpException.class));
436+
assertThat(e.getMessage(), equalTo("Abuse limit reached"));
437+
}
438+
assertThat(mockGitHub.getRequestCount(), equalTo(2));
439+
}
440+
441+
/**
442+
* Tests the behavior of the GitHub API client when the abuse limit handler with a date retry.
443+
*
444+
* @throws Exception
445+
* if any error occurs during the test execution.
446+
*/
447+
@Test
448+
public void testHandler_Wait_Secondary_Limits_Too_Many_Requests_Date_Retry_After() throws Exception {
449+
// Customized response that templates the date to keep things working
450+
snapshotNotAllowed();
451+
final HttpURLConnection[] savedConnection = new HttpURLConnection[1];
452+
gitHub = getGitHubBuilder().withEndpoint(mockGitHub.apiServer().baseUrl())
453+
.withAbuseLimitHandler(new AbuseLimitHandler() {
454+
/**
455+
* Overriding method because the actual method will wait for one minute causing slowness in unit
456+
* tests
457+
*/
458+
@Override
459+
public void onError(IOException e, HttpURLConnection uc) throws IOException {
460+
savedConnection[0] = uc;
461+
// Verify the test data is what we expected it to be for this test case
462+
assertThat(uc.getRequestMethod(), equalTo("GET"));
463+
assertThat(uc.getResponseCode(), equalTo(429));
464+
assertThat(uc.getResponseMessage(), containsString("Many"));
465+
assertThat(uc.getURL().toString(),
466+
endsWith(
467+
"/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Too_Many_Requests_Date_Retry_After"));
468+
assertThat(uc.getContentLength(), equalTo(-1));
469+
assertThat(uc.getHeaderField("Status"), equalTo("429 Too Many Requests"));
470+
assertThat(uc.getHeaderField("Retry-After"), startsWith("Mon"));
471+
472+
checkErrorMessageMatches(uc,
473+
"You have exceeded a secondary rate limit. Please wait a few minutes before you try again");
474+
475+
// Because we've overridden onError to bypass the wait, we don't cover the wait calculation
476+
// logic
477+
// Manually invoke it to make sure it's what we intended
478+
long waitTime = parseWaitTime(uc);
479+
// The exact value here will depend on when the test is run, but it should be positive, and huge
480+
assertThat(waitTime, greaterThan(1000 * 1000l));
481+
482+
AbuseLimitHandler.FAIL.onError(e, uc);
483+
}
484+
})
485+
.build();
486+
487+
gitHub.getMyself();
488+
assertThat(mockGitHub.getRequestCount(), equalTo(1));
489+
try {
490+
getTempRepository();
491+
fail();
492+
} catch (Exception e) {
493+
assertThat(e, instanceOf(HttpException.class));
494+
assertThat(e.getMessage(), equalTo("Abuse limit reached"));
495+
}
496+
assertThat(mockGitHub.getRequestCount(), equalTo(2));
497+
}
498+
499+
/**
500+
* Tests the behavior of the GitHub API client when the abuse limit handler with a no retry after header.
501+
*
502+
* @throws Exception
503+
* if any error occurs during the test execution.
504+
*/
505+
@Test
506+
public void testHandler_Wait_Secondary_Limits_Too_Many_Requests_No_Retry_After() throws Exception {
507+
// Customized response that templates the date to keep things working
508+
snapshotNotAllowed();
509+
final HttpURLConnection[] savedConnection = new HttpURLConnection[1];
510+
gitHub = getGitHubBuilder().withEndpoint(mockGitHub.apiServer().baseUrl())
511+
.withAbuseLimitHandler(new AbuseLimitHandler() {
512+
/**
513+
* Overriding method because the actual method will wait for one minute causing slowness in unit
514+
* tests
515+
*/
516+
@Override
517+
public void onError(IOException e, HttpURLConnection uc) throws IOException {
518+
savedConnection[0] = uc;
519+
// Verify the test data is what we expected it to be for this test case
520+
assertThat(uc.getRequestMethod(), equalTo("GET"));
521+
assertThat(uc.getResponseCode(), equalTo(429));
522+
assertThat(uc.getResponseMessage(), containsString("Many"));
523+
assertThat(uc.getURL().toString(),
524+
endsWith(
525+
"/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Too_Many_Requests_No_Retry_After"));
526+
assertThat(uc.getContentEncoding(), nullValue());
527+
assertThat(uc.getContentType(), equalTo("application/json; charset=utf-8"));
528+
assertThat(uc.getContentLength(), equalTo(-1));
529+
assertThat(uc.getHeaderFields(), instanceOf(Map.class));
530+
assertThat(uc.getHeaderField("Status"), equalTo("429 Too Many Requests"));
531+
assertThat(uc.getHeaderField("Retry-After"), nullValue());
532+
533+
checkErrorMessageMatches(uc,
534+
"You have exceeded a secondary rate limit. Please wait a few minutes before you try again");
535+
536+
// Because we've overridden onError to bypass the wait, we don't cover the wait calculation
537+
// logic
538+
// Manually invoke it to make sure it's what we intended
539+
long waitTime = parseWaitTime(uc);
540+
assertThat(waitTime, greaterThan(60000l));
541+
362542
AbuseLimitHandler.FAIL.onError(e, uc);
363543
}
364544
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"login": "bitwiseman",
3+
"id": 1958953,
4+
"node_id": "MDQ6VXNlcjE5NTg5NTM=",
5+
"avatar_url": "https://avatars3.githubusercontent.com/u/1958953?v=4",
6+
"gravatar_id": "",
7+
"url": "https://api.github.com/users/bitwiseman",
8+
"html_url": "https://github.com/bitwiseman",
9+
"followers_url": "https://api.github.com/users/bitwiseman/followers",
10+
"following_url": "https://api.github.com/users/bitwiseman/following{/other_user}",
11+
"gists_url": "https://api.github.com/users/bitwiseman/gists{/gist_id}",
12+
"starred_url": "https://api.github.com/users/bitwiseman/starred{/owner}{/repo}",
13+
"subscriptions_url": "https://api.github.com/users/bitwiseman/subscriptions",
14+
"organizations_url": "https://api.github.com/users/bitwiseman/orgs",
15+
"repos_url": "https://api.github.com/users/bitwiseman/repos",
16+
"events_url": "https://api.github.com/users/bitwiseman/events{/privacy}",
17+
"received_events_url": "https://api.github.com/users/bitwiseman/received_events",
18+
"type": "User",
19+
"site_admin": false,
20+
"name": "Liam Newman",
21+
"company": "Cloudbees, Inc.",
22+
"blog": "",
23+
"location": "Seattle, WA, USA",
24+
"email": "[email protected]",
25+
"hireable": null,
26+
"bio": "https://twitter.com/bitwiseman",
27+
"public_repos": 181,
28+
"public_gists": 7,
29+
"followers": 146,
30+
"following": 9,
31+
"created_at": "2012-07-11T20:38:33Z",
32+
"updated_at": "2020-02-06T17:29:39Z",
33+
"private_gists": 8,
34+
"total_private_repos": 10,
35+
"owned_private_repos": 0,
36+
"disk_usage": 33697,
37+
"collaborators": 0,
38+
"two_factor_authentication": true,
39+
"plan": {
40+
"name": "free",
41+
"space": 976562499,
42+
"collaborators": 0,
43+
"private_repos": 10000
44+
}
45+
}

0 commit comments

Comments
 (0)