Skip to content
Open
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
46 changes: 43 additions & 3 deletions src/main/java/org/kohsuke/github/GitHubAbuseLimitHandler.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package org.kohsuke.github;

import org.apache.commons.io.IOUtils;
import org.kohsuke.github.connector.GitHubConnectorResponse;

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 @@ -57,6 +61,18 @@ public void onError(GitHubConnectorResponse connectorResponse) throws IOExceptio
*/
private static final int MINIMUM_ABUSE_RETRY_MILLIS = 1000;

/**
* Pattern matching the marker GitHub returns in the response body when the request is rejected by a secondary rate
* limit, regardless of whether {@code Retry-After} or {@code gh-limited-by} headers are present. Used as a fallback
* detection path so that responses without those headers are still recognized.
*
* @see <a href=
* "https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#about-secondary-rate-limits">About
* secondary rate limits</a>
*/
private static final Pattern SECONDARY_RATE_LIMIT_BODY_PATTERN = Pattern.compile("\\bsecondary rate\\b",
Pattern.CASE_INSENSITIVE);

// If "Retry-After" missing, wait for unambiguously over one minute per GitHub guidance
static long DEFAULT_WAIT_MILLIS = Duration.ofSeconds(61).toMillis();

Expand Down Expand Up @@ -146,6 +162,25 @@ private boolean hasRetryOrLimitHeader(GitHubConnectorResponse connectorResponse)
return hasHeader(connectorResponse, "Retry-After") || hasHeader(connectorResponse, "gh-limited-by");
}

/**
* Checks if the response body contains the secondary rate limit marker. GitHub does not always return
* {@code Retry-After} or {@code gh-limited-by} headers when a secondary rate limit is hit; in those cases the body
* still contains a "secondary rate" marker that can be matched as a fallback detection path.
*
* @param connectorResponse
* the response from the GitHub connector
* @return true if the response body contains the secondary rate limit marker
* @throws IOException
* if reading the response body fails
* @see <a href="https://github.com/hub4j/github-api/issues/2009">hub4j/github-api#2009</a>
*/
private boolean hasSecondaryRateLimitBodyMarker(GitHubConnectorResponse connectorResponse) throws IOException {
try (InputStream bodyStream = connectorResponse.bodyStream()) {
String body = IOUtils.toString(bodyStream, StandardCharsets.UTF_8);
return SECONDARY_RATE_LIMIT_BODY_PATTERN.matcher(body).find();
}
}

/**
* Checks if the response status code is HTTP_FORBIDDEN (403).
*
Expand Down Expand Up @@ -178,9 +213,14 @@ private boolean isTooManyRequests(GitHubConnectorResponse connectorResponse) {
* Signals that an I/O exception has occurred.
*/
@Override
boolean isError(@Nonnull GitHubConnectorResponse connectorResponse) {
return isTooManyRequests(connectorResponse)
|| (isForbidden(connectorResponse) && hasRetryOrLimitHeader(connectorResponse));
boolean isError(@Nonnull GitHubConnectorResponse connectorResponse) throws IOException {
if (isTooManyRequests(connectorResponse)) {
return true;
}
if (!isForbidden(connectorResponse)) {
return false;
}
return hasRetryOrLimitHeader(connectorResponse) || hasSecondaryRateLimitBodyMarker(connectorResponse);
}

}
44 changes: 44 additions & 0 deletions src/test/java/org/kohsuke/github/AbuseLimitHandlerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,50 @@ public void onError(@NotNull GitHubConnectorResponse connectorResponse) throws I
assertThat(mockGitHub.getRequestCount(), equalTo(3));
}

/**
* Tests the behavior of the GitHub API client when the abuse limit handler encounters a 403 response that contains
* the secondary rate limit marker in the body, but neither the {@code Retry-After} nor the {@code gh-limited-by}
* header. Without body-based detection, this case slips through {@code isError()} and surfaces as a generic
* {@code HttpException} (see hub4j/github-api#2009).
*
* @throws Exception
* if any error occurs during the test execution.
*/
@Test
public void testHandler_Wait_Secondary_Limits_Body_Marker_Only() throws Exception {
snapshotNotAllowed();
gitHub = getGitHubBuilder().withEndpoint(mockGitHub.apiServer().baseUrl())
.withAbuseLimitHandler(new GitHubAbuseLimitHandler() {
/**
* Overriding method because the actual method will wait for one minute causing slowness in unit
* tests.
*/
@Override
public void onError(@NotNull GitHubConnectorResponse connectorResponse) throws IOException {
assertThat(connectorResponse.request().method(), equalTo("GET"));
assertThat(connectorResponse.statusCode(), equalTo(403));
assertThat(connectorResponse.request().url().toString(),
endsWith(
"/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only"));
assertThat(connectorResponse.header("Retry-After"), nullValue());
assertThat(connectorResponse.header("gh-limited-by"), nullValue());

checkErrorMessageMatches(connectorResponse,
"You have exceeded a secondary rate limit. Please wait a few minutes before you try again");

long waitTime = parseWaitTime(connectorResponse);
assertThat(waitTime, equalTo(GitHubAbuseLimitHandler.DEFAULT_WAIT_MILLIS));
}
})
.build();

gitHub.getMyself();
assertThat(mockGitHub.getRequestCount(), equalTo(1));

getTempRepository();
assertThat(mockGitHub.getRequestCount(), equalTo(3));
}

/**
* Tests the behavior of the GitHub API client when the abuse limit handler is set to WAIT then the handler waits
* appropriately when secondary rate limits are encountered.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"login": "bitwiseman",
"id": 1958953,
"node_id": "MDQ6VXNlcjE5NTg5NTM=",
"avatar_url": "https://avatars3.githubusercontent.com/u/1958953?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/bitwiseman",
"html_url": "https://github.com/bitwiseman",
"followers_url": "https://api.github.com/users/bitwiseman/followers",
"following_url": "https://api.github.com/users/bitwiseman/following{/other_user}",
"gists_url": "https://api.github.com/users/bitwiseman/gists{/gist_id}",
"starred_url": "https://api.github.com/users/bitwiseman/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/bitwiseman/subscriptions",
"organizations_url": "https://api.github.com/users/bitwiseman/orgs",
"repos_url": "https://api.github.com/users/bitwiseman/repos",
"events_url": "https://api.github.com/users/bitwiseman/events{/privacy}",
"received_events_url": "https://api.github.com/users/bitwiseman/received_events",
"type": "User",
"site_admin": false,
"name": "Liam Newman",
"company": "Cloudbees, Inc.",
"blog": "",
"location": "Seattle, WA, USA",
"email": "bitwiseman@gmail.com",
"hireable": null,
"bio": "https://twitter.com/bitwiseman",
"public_repos": 181,
"public_gists": 7,
"followers": 146,
"following": 9,
"created_at": "2012-07-11T20:38:33Z",
"updated_at": "2020-02-06T17:29:39Z",
"private_gists": 8,
"total_private_repos": 10,
"owned_private_repos": 0,
"disk_usage": 33697,
"collaborators": 0,
"two_factor_authentication": true,
"plan": {
"name": "free",
"space": 976562499,
"collaborators": 0,
"private_repos": 10000
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
{
"id": 238757196,
"node_id": "MDEwOlJlcG9zaXRvcnkyMzg3NTcxOTY=",
"name": "temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only",
"full_name": "hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only",
"private": false,
"owner": {
"login": "hub4j-test-org",
"id": 7544739,
"node_id": "MDEyOk9yZ2FuaXphdGlvbjc1NDQ3Mzk=",
"avatar_url": "https://avatars3.githubusercontent.com/u/7544739?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/hub4j-test-org",
"html_url": "https://github.com/hub4j-test-org",
"followers_url": "https://api.github.com/users/hub4j-test-org/followers",
"following_url": "https://api.github.com/users/hub4j-test-org/following{/other_user}",
"gists_url": "https://api.github.com/users/hub4j-test-org/gists{/gist_id}",
"starred_url": "https://api.github.com/users/hub4j-test-org/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/hub4j-test-org/subscriptions",
"organizations_url": "https://api.github.com/users/hub4j-test-org/orgs",
"repos_url": "https://api.github.com/users/hub4j-test-org/repos",
"events_url": "https://api.github.com/users/hub4j-test-org/events{/privacy}",
"received_events_url": "https://api.github.com/users/hub4j-test-org/received_events",
"type": "Organization",
"site_admin": false
},
"html_url": "https://github.com/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only",
"description": "A test repository for testing the github-api project: temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only",
"fork": false,
"url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only",
"forks_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/forks",
"keys_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/keys{/key_id}",
"collaborators_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/collaborators{/collaborator}",
"teams_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/teams",
"hooks_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/hooks",
"issue_events_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/issues/events{/number}",
"events_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/events",
"assignees_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/assignees{/user}",
"branches_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/branches{/branch}",
"tags_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/tags",
"blobs_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/git/blobs{/sha}",
"git_tags_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/git/tags{/sha}",
"git_refs_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/git/refs{/sha}",
"trees_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/git/trees{/sha}",
"statuses_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/statuses/{sha}",
"languages_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/languages",
"stargazers_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/stargazers",
"contributors_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/contributors",
"subscribers_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/subscribers",
"subscription_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/subscription",
"commits_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/commits{/sha}",
"git_commits_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/git/commits{/sha}",
"comments_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/comments{/number}",
"issue_comment_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/issues/comments{/number}",
"contents_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/contents/{+path}",
"compare_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/compare/{base}...{head}",
"merges_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/merges",
"archive_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/{archive_format}{/ref}",
"downloads_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/downloads",
"issues_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/issues{/number}",
"pulls_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/pulls{/number}",
"milestones_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/milestones{/number}",
"notifications_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/notifications{?since,all,participating}",
"labels_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/labels{/name}",
"releases_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/releases{/id}",
"deployments_url": "https://api.github.com/repos/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only/deployments",
"created_at": "2020-02-06T18:33:39Z",
"updated_at": "2020-02-06T18:33:43Z",
"pushed_at": "2020-02-06T18:33:41Z",
"git_url": "git://github.com/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only.git",
"ssh_url": "git@github.com:hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only.git",
"clone_url": "https://github.com/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only.git",
"svn_url": "https://github.com/hub4j-test-org/temp-testHandler_Wait_Secondary_Limits_Body_Marker_Only",
"homepage": "http://github-api.kohsuke.org/",
"size": 0,
"stargazers_count": 0,
"watchers_count": 0,
"language": null,
"has_issues": true,
"has_projects": true,
"has_downloads": true,
"has_wiki": true,
"has_pages": false,
"forks_count": 0,
"mirror_url": null,
"archived": false,
"disabled": false,
"open_issues_count": 0,
"license": null,
"forks": 0,
"open_issues": 0,
"watchers": 0,
"default_branch": "main",
"permissions": {
"admin": true,
"push": true,
"pull": true
},
"temp_clone_token": "",
"allow_squash_merge": true,
"allow_merge_commit": true,
"allow_rebase_merge": true,
"delete_branch_on_merge": false,
"organization": {
"login": "hub4j-test-org",
"id": 7544739,
"node_id": "MDEyOk9yZ2FuaXphdGlvbjc1NDQ3Mzk=",
"avatar_url": "https://avatars3.githubusercontent.com/u/7544739?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/hub4j-test-org",
"html_url": "https://github.com/hub4j-test-org",
"followers_url": "https://api.github.com/users/hub4j-test-org/followers",
"following_url": "https://api.github.com/users/hub4j-test-org/following{/other_user}",
"gists_url": "https://api.github.com/users/hub4j-test-org/gists{/gist_id}",
"starred_url": "https://api.github.com/users/hub4j-test-org/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/hub4j-test-org/subscriptions",
"organizations_url": "https://api.github.com/users/hub4j-test-org/orgs",
"repos_url": "https://api.github.com/users/hub4j-test-org/repos",
"events_url": "https://api.github.com/users/hub4j-test-org/events{/privacy}",
"received_events_url": "https://api.github.com/users/hub4j-test-org/received_events",
"type": "Organization",
"site_admin": false
},
"network_count": 0,
"subscribers_count": 6
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"id": "a60baf84-5b5c-4f86-af3d-cab0d609c7c3",
"name": "user",
"request": {
"url": "/user",
"method": "GET",
"headers": {
"Accept": {
"equalTo": "application/vnd.github+json"
}
}
},
"response": {
"status": 200,
"bodyFileName": "1-user.json",
"headers": {
"Date": "{{now timezone='GMT' format='EEE, dd MMM yyyy HH:mm:ss z'}}",
"Content-Type": "application/json; charset=utf-8",
"Server": "GitHub.com",
"Status": "200 OK",
"X-RateLimit-Limit": "5000",
"X-RateLimit-Remaining": "4930",
"X-RateLimit-Reset": "{{now offset='3 seconds' format='unix'}}",
"Cache-Control": "private, max-age=60, s-maxage=60",
"Vary": [
"Accept, Authorization, Cookie, X-GitHub-OTP",
"Accept-Encoding"
],
"ETag": "W/\"1cb30f031c67c499473b3aad01c7f7a5\"",
"Last-Modified": "Thu, 06 Feb 2020 17:29:39 GMT",
"X-OAuth-Scopes": "admin:org, admin:org_hook, admin:public_key, admin:repo_hook, delete_repo, gist, notifications, repo, user, write:discussion",
"X-Accepted-OAuth-Scopes": "",
"X-GitHub-Media-Type": "unknown, github.v3",
"Access-Control-Expose-Headers": "ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type",
"Access-Control-Allow-Origin": "*",
"Strict-Transport-Security": "max-age=31536000; includeSubdomains; preload",
"X-Frame-Options": "deny",
"X-Content-Type-Options": "nosniff",
"X-XSS-Protection": "1; mode=block",
"Referrer-Policy": "origin-when-cross-origin, strict-origin-when-cross-origin",
"Content-Security-Policy": "default-src 'none'",
"X-GitHub-Request-Id": "CC37:2605:3F884:4E941:5E3C5BFC"
}
},
"uuid": "a60baf84-5b5c-4f86-af3d-cab0d609c7c3",
"persistent": true,
"insertionIndex": 1
}
Loading
Loading