Skip to content

Commit 969cfe0

Browse files
dwnusbaumThejashwini Swamy (RBEI/ETP3)
andauthored
[JENKINS-62479] Automatically retry rate limited Bitbucket Server requests with exponential backoff (#581)
* Retry request in 5 seconds on getting rate limit error from Bitbucket server * Fix issues with authenticator not being set to static context object * Retry requests to Bitbucket server for about 300 times(25 minutes) only on getting rate limit error. * Return reposnse on interrupted exception while thread is in sleep * Close HttpClient after use, use exponential backoff, and propagate InterruptedException * No need for try/finally block around releaseConnection because it just resets the request * Add rate limiting test for Bitbucket Server using BitbucketIntegrationClientFactory Co-authored-by: Thejashwini Swamy (RBEI/ETP3) <[email protected]>
1 parent 687567a commit 969cfe0

File tree

3 files changed

+141
-48
lines changed

3 files changed

+141
-48
lines changed

src/main/java/com/cloudbees/jenkins/plugins/bitbucket/server/client/BitbucketServerAPIClient.java

Lines changed: 64 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
import com.fasterxml.jackson.core.type.TypeReference;
6060
import edu.umd.cs.findbugs.annotations.CheckForNull;
6161
import edu.umd.cs.findbugs.annotations.NonNull;
62+
import hudson.Main;
6263
import hudson.ProxyConfiguration;
6364
import hudson.Util;
6465
import java.awt.image.BufferedImage;
@@ -72,6 +73,8 @@
7273
import java.net.URI;
7374
import java.net.URISyntaxException;
7475
import java.nio.charset.StandardCharsets;
76+
import java.time.Duration;
77+
import java.time.Instant;
7578
import java.util.ArrayList;
7679
import java.util.Collections;
7780
import java.util.Comparator;
@@ -114,6 +117,8 @@
114117
import org.apache.http.impl.client.StandardHttpRequestRetryHandler;
115118
import org.apache.http.message.BasicNameValuePair;
116119
import org.apache.http.util.EntityUtils;
120+
import org.kohsuke.accmod.Restricted;
121+
import org.kohsuke.accmod.restrictions.ProtectedExternally;
117122

118123
import static java.util.Objects.requireNonNull;
119124

@@ -150,6 +155,9 @@ public class BitbucketServerAPIClient implements BitbucketApi {
150155

151156
private static final String API_COMMIT_STATUS_PATH = "/rest/build-status/1.0/commits{/hash}";
152157
private static final Integer DEFAULT_PAGE_LIMIT = 200;
158+
private static final int API_RATE_LIMIT_STATUS_CODE = 429;
159+
private static final Duration API_RATE_LIMIT_INITIAL_SLEEP = Main.isUnitTest ? Duration.ofMillis(100) : Duration.ofSeconds(5);
160+
private static final Duration API_RATE_LIMIT_MAX_SLEEP = Duration.ofMinutes(30);
153161

154162
/**
155163
* Repository owner.
@@ -335,7 +343,7 @@ private List<BitbucketServerPullRequest> getPullRequests(UriTemplate template)
335343
return pullRequests;
336344
}
337345

338-
private void setupPullRequest(BitbucketServerPullRequest pullRequest, BitbucketServerEndpoint endpoint) throws IOException {
346+
private void setupPullRequest(BitbucketServerPullRequest pullRequest, BitbucketServerEndpoint endpoint) throws IOException, InterruptedException {
339347
// set commit closure to make commit information available when needed, in a similar way to when request branches
340348
setupClosureForPRBranch(pullRequest);
341349

@@ -406,7 +414,7 @@ private void setupClosureForPRBranch(BitbucketServerPullRequest pr) {
406414
}
407415
}
408416

409-
private void callPullRequestChangesById(@NonNull String id) throws IOException {
417+
private void callPullRequestChangesById(@NonNull String id) throws IOException, InterruptedException {
410418
String url = UriTemplate
411419
.fromTemplate(API_PULL_REQUEST_CHANGES_PATH)
412420
.set("owner", getUserCentricOwner())
@@ -416,7 +424,7 @@ private void callPullRequestChangesById(@NonNull String id) throws IOException {
416424
getRequest(url);
417425
}
418426

419-
private boolean getPullRequestCanMergeById(@NonNull String id) throws IOException {
427+
private boolean getPullRequestCanMergeById(@NonNull String id) throws IOException, InterruptedException {
420428
String url = UriTemplate
421429
.fromTemplate(API_PULL_REQUEST_MERGE_PATH)
422430
.set("owner", getUserCentricOwner())
@@ -436,7 +444,7 @@ private boolean getPullRequestCanMergeById(@NonNull String id) throws IOExceptio
436444
*/
437445
@Override
438446
@NonNull
439-
public BitbucketPullRequest getPullRequestById(@NonNull Integer id) throws IOException {
447+
public BitbucketPullRequest getPullRequestById(@NonNull Integer id) throws IOException, InterruptedException {
440448
String url = UriTemplate
441449
.fromTemplate(API_PULL_REQUEST_PATH)
442450
.set("owner", getUserCentricOwner())
@@ -460,7 +468,7 @@ public BitbucketPullRequest getPullRequestById(@NonNull Integer id) throws IOExc
460468
*/
461469
@Override
462470
@NonNull
463-
public BitbucketRepository getRepository() throws IOException {
471+
public BitbucketRepository getRepository() throws IOException, InterruptedException {
464472
if (repositoryName == null) {
465473
throw new UnsupportedOperationException(
466474
"Cannot get a repository from an API instance that is not associated with a repository");
@@ -482,7 +490,7 @@ public BitbucketRepository getRepository() throws IOException {
482490
* {@inheritDoc}
483491
*/
484492
@Override
485-
public void postCommitComment(@NonNull String hash, @NonNull String comment) throws IOException {
493+
public void postCommitComment(@NonNull String hash, @NonNull String comment) throws IOException, InterruptedException {
486494
postRequest(
487495
UriTemplate
488496
.fromTemplate(API_COMMIT_COMMENT_PATH)
@@ -500,7 +508,7 @@ public void postCommitComment(@NonNull String hash, @NonNull String comment) thr
500508
* {@inheritDoc}
501509
*/
502510
@Override
503-
public void postBuildStatus(@NonNull BitbucketBuildStatus status) throws IOException {
511+
public void postBuildStatus(@NonNull BitbucketBuildStatus status) throws IOException, InterruptedException {
504512
postRequest(
505513
UriTemplate
506514
.fromTemplate(API_COMMIT_STATUS_PATH)
@@ -514,7 +522,7 @@ public void postBuildStatus(@NonNull BitbucketBuildStatus status) throws IOExcep
514522
* {@inheritDoc}
515523
*/
516524
@Override
517-
public boolean checkPathExists(@NonNull String branchOrHash, @NonNull String path) throws IOException {
525+
public boolean checkPathExists(@NonNull String branchOrHash, @NonNull String path) throws IOException, InterruptedException {
518526
String url = UriTemplate
519527
.fromTemplate(API_BROWSE_PATH)
520528
.set("owner", getUserCentricOwner())
@@ -536,7 +544,7 @@ public boolean checkPathExists(@NonNull String branchOrHash, @NonNull String pat
536544

537545
@CheckForNull
538546
@Override
539-
public String getDefaultBranch() throws IOException {
547+
public String getDefaultBranch() throws IOException, InterruptedException {
540548
String url = UriTemplate
541549
.fromTemplate(API_DEFAULT_BRANCH_PATH)
542550
.set("owner", getUserCentricOwner())
@@ -591,7 +599,7 @@ private List<BitbucketServerBranch> getServerBranches(String apiPath) throws IOE
591599
/** {@inheritDoc} */
592600
@NonNull
593601
@Override
594-
public BitbucketCommit resolveCommit(@NonNull String hash) throws IOException {
602+
public BitbucketCommit resolveCommit(@NonNull String hash) throws IOException, InterruptedException {
595603
String url = UriTemplate
596604
.fromTemplate(API_COMMITS_PATH)
597605
.set("owner", getUserCentricOwner())
@@ -739,7 +747,7 @@ public List<? extends BitbucketWebHook> getWebHooks() throws IOException, Interr
739747
* There is no such Team concept in Bitbucket Server but Project.
740748
*/
741749
@Override
742-
public BitbucketTeam getTeam() throws IOException {
750+
public BitbucketTeam getTeam() throws IOException, InterruptedException {
743751
if (userCentric) {
744752
return null;
745753
} else {
@@ -807,7 +815,7 @@ public List<BitbucketServerRepository> getRepositories() throws IOException, Int
807815
}
808816

809817
@Override
810-
public boolean isPrivate() throws IOException {
818+
public boolean isPrivate() throws IOException, InterruptedException {
811819
return getRepository().isPrivate();
812820
}
813821

@@ -841,15 +849,15 @@ private <V> List<V> getResources(UriTemplate template, Class<? extends PagedApiR
841849
return resources;
842850
}
843851

844-
protected String getRequest(String path) throws IOException {
852+
protected String getRequest(String path) throws IOException, InterruptedException {
845853
HttpGet httpget = new HttpGet(this.baseURL + path);
846854

847855
if (authenticator != null) {
848856
authenticator.configureRequest(httpget);
849857
}
850858

851859
try(CloseableHttpClient client = getHttpClient(httpget);
852-
CloseableHttpResponse response = client.execute(httpget, context)) {
860+
CloseableHttpResponse response = executeMethod(client, httpget)) {
853861
String content;
854862
long len = response.getEntity().getContentLength();
855863
if (len == 0) {
@@ -892,7 +900,7 @@ private BufferedImage getImageRequest(String path) throws IOException, Interrupt
892900
}
893901

894902
try (CloseableHttpClient client = getHttpClient(httpget);
895-
CloseableHttpResponse response = client.execute(httpget, context)) {
903+
CloseableHttpResponse response = executeMethod(client, httpget)) {
896904
BufferedImage content;
897905
long len = response.getEntity().getContentLength();
898906
if (len == 0) {
@@ -995,14 +1003,14 @@ private void setClientProxyParams(String host, HttpClientBuilder builder) {
9951003
}
9961004
}
9971005

998-
private int getRequestStatus(String path) throws IOException {
1006+
private int getRequestStatus(String path) throws IOException, InterruptedException {
9991007
HttpGet httpget = new HttpGet(this.baseURL + path);
10001008
if (authenticator != null) {
10011009
authenticator.configureRequest(httpget);
10021010
}
10031011

10041012
try(CloseableHttpClient client = getHttpClient(httpget);
1005-
CloseableHttpResponse response = client.execute(httpget, context)) {
1013+
CloseableHttpResponse response = executeMethod(client, httpget)) {
10061014
EntityUtils.consume(response.getEntity());
10071015
return response.getStatusLine().getStatusCode();
10081016
} finally {
@@ -1016,13 +1024,13 @@ private static String getMethodHost(HttpRequestBase method) {
10161024
return scheme + "://" + uri.getAuthority();
10171025
}
10181026

1019-
private String postRequest(String path, List<? extends NameValuePair> params) throws IOException {
1027+
private String postRequest(String path, List<? extends NameValuePair> params) throws IOException, InterruptedException {
10201028
HttpPost request = new HttpPost(this.baseURL + path);
10211029
request.setEntity(new UrlEncodedFormEntity(params));
10221030
return postRequest(request);
10231031
}
10241032

1025-
private String postRequest(String path, String content) throws IOException {
1033+
private String postRequest(String path, String content) throws IOException, InterruptedException {
10261034
HttpPost request = new HttpPost(this.baseURL + path);
10271035
request.setEntity(new StringEntity(content, ContentType.create("application/json", "UTF-8")));
10281036
LOGGER.log(Level.FINEST, content);
@@ -1037,17 +1045,17 @@ private String nameValueToJson(NameValuePair[] params) {
10371045
return o.toString();
10381046
}
10391047

1040-
private String postRequest(HttpPost httppost) throws IOException {
1048+
private String postRequest(HttpPost httppost) throws IOException, InterruptedException {
10411049
return doRequest(httppost);
10421050
}
10431051

1044-
private String doRequest(HttpRequestBase request) throws IOException {
1052+
private String doRequest(HttpRequestBase request) throws IOException, InterruptedException {
10451053
if (authenticator != null) {
10461054
authenticator.configureRequest(request);
10471055
}
10481056

10491057
try(CloseableHttpClient client = getHttpClient(request);
1050-
CloseableHttpResponse response = client.execute(request, context)) {
1058+
CloseableHttpResponse response = executeMethod(client, request)) {
10511059
if (response.getStatusLine().getStatusCode() == HttpStatus.SC_NO_CONTENT) {
10521060
EntityUtils.consume(response.getEntity());
10531061
// 204, no content
@@ -1093,13 +1101,13 @@ private String doRequest(HttpRequestBase request) throws IOException {
10931101
}
10941102
}
10951103

1096-
private String putRequest(String path, String content) throws IOException {
1104+
private String putRequest(String path, String content) throws IOException, InterruptedException {
10971105
HttpPut request = new HttpPut(this.baseURL + path);
10981106
request.setEntity(new StringEntity(content, ContentType.create("application/json", "UTF-8")));
10991107
return doRequest(request);
11001108
}
11011109

1102-
private String deleteRequest(String path) throws IOException {
1110+
private String deleteRequest(String path) throws IOException, InterruptedException {
11031111
HttpDelete request = new HttpDelete(this.baseURL + path);
11041112
return doRequest(request);
11051113
}
@@ -1190,4 +1198,36 @@ private Map<String,Object> collectLines(String response, final List<String> line
11901198
}
11911199
return content;
11921200
}
1201+
1202+
private CloseableHttpResponse executeMethod(CloseableHttpClient client, HttpRequestBase httpMethod) throws IOException, InterruptedException {
1203+
CloseableHttpResponse response = executeMethodNoRetry(client, httpMethod, context);
1204+
Instant start = Instant.now();
1205+
Instant forcedEnd = start.plus(API_RATE_LIMIT_MAX_SLEEP);
1206+
Duration sleepDuration = API_RATE_LIMIT_INITIAL_SLEEP;
1207+
while (response.getStatusLine().getStatusCode() == API_RATE_LIMIT_STATUS_CODE
1208+
&& Instant.now().plus(sleepDuration).isBefore(forcedEnd)) {
1209+
response.close();
1210+
httpMethod.releaseConnection();
1211+
/*
1212+
* TODO: If The Bitbucket Server API ever starts sending rate limit expiration time, we should
1213+
* change this to a more precise sleep.
1214+
* TODO: It would be better to log this to a context-appropriate TaskListener, e.g. an org/repo scan log.
1215+
*/
1216+
LOGGER.log(Level.FINE, "Bitbucket server API rate limit reached, sleeping for {0} before retrying",
1217+
sleepDuration);
1218+
Thread.sleep(sleepDuration.toMillis());
1219+
// Duration increases exponentially: 5s, 7s, 10s, 15s, 22s, ... 6m6s, 9m9s.
1220+
// We will retry at most 13 times and sleep for roughly 27 minutes.
1221+
sleepDuration = Duration.ofSeconds((int)(sleepDuration.getSeconds() * 1.5));
1222+
response = executeMethodNoRetry(client, httpMethod, context);
1223+
}
1224+
return response;
1225+
}
1226+
1227+
// Exists just so it can be mocked in BitbucketIntegrationClientFactory.
1228+
@Restricted(ProtectedExternally.class)
1229+
protected CloseableHttpResponse executeMethodNoRetry(CloseableHttpClient client, HttpRequestBase httpMethod, HttpClientContext context) throws IOException, InterruptedException {
1230+
return client.execute(httpMethod, context);
1231+
}
1232+
11931233
}

0 commit comments

Comments
 (0)