Skip to content

Commit

Permalink
[JENKINS-64418] Add exponential backoff to BitBucket rate limit retry…
Browse files Browse the repository at this point in the history
… loop

Configure Apache HTTP client to use an exponential backoff retry strategy
  • Loading branch information
nfalco79 committed Dec 15, 2024
1 parent 2dcf2d9 commit 2f719d7
Show file tree
Hide file tree
Showing 13 changed files with 561 additions and 560 deletions.
12 changes: 12 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,18 @@
<artifactId>scribejava-core</artifactId>
<version>8.3.3</version>
</dependency>
<dependency>
<groupId>org.mock-server</groupId>
<artifactId>mockserver-junit-jupiter</artifactId>
<version>5.15.0</version>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>

<repositories>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@
* Represents the default branch of a specific repository
*/
public class BitbucketDefaultBranch extends InvisibleAction implements Serializable {
private static final long serialVersionUID = 1L;
private static final long serialVersionUID = 1826270778226063782L;

@NonNull
private final String repoOwner;
@NonNull
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,8 @@ private void withPullRequestRemote(PullRequestSCMHead head, String headName) {
String scmSourceRepository = scmSource.getRepository();
String pullRequestRepoOwner = head.getRepoOwner();
String pullRequestRepository = head.getRepository();
boolean prFromTargetRepository = pullRequestRepoOwner.equals(scmSourceRepoOwner)
&& pullRequestRepository.equals(scmSourceRepository);
boolean prFromTargetRepository = pullRequestRepoOwner.equalsIgnoreCase(scmSourceRepoOwner)
&& pullRequestRepository.equalsIgnoreCase(scmSourceRepository);

Check warning on line 218 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/BitbucketGitSCMBuilder.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 218 is only partially covered, one branch is missing
SCMRevision revision = revision();
ChangeRequestCheckoutStrategy checkoutStrategy = head.getCheckoutStrategy();
// PullRequestSCMHead should be refactored to add references to target and source commit hashes.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
package com.cloudbees.jenkins.plugins.bitbucket.api;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

@JsonIgnoreProperties(ignoreUnknown = true)
public class BitbucketProject {

private String key;
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -23,40 +23,69 @@
*/
package com.cloudbees.jenkins.plugins.bitbucket.impl.client;

import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketAuthenticator;
import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketRequestException;
import com.cloudbees.jenkins.plugins.bitbucket.client.ClosingConnectionInputStream;
import edu.umd.cs.findbugs.annotations.CheckForNull;
import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.Nullable;
import hudson.ProxyConfiguration;
import hudson.util.Secret;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.logging.Logger;
import jenkins.model.Jenkins;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.http.Header;
import org.apache.http.HttpHost;
import org.apache.http.HttpStatus;
import org.apache.http.NameValuePair;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.AuthCache;
import org.apache.http.client.CredentialsProvider;
import org.apache.http.client.ServiceUnavailableRetryStrategy;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpHead;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpPut;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.conn.HttpClientConnectionManager;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.auth.BasicScheme;
import org.apache.http.impl.client.BasicAuthCache;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.StandardHttpRequestRetryHandler;
import org.apache.http.util.EntityUtils;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.ProtectedExternally;

@Restricted(ProtectedExternally.class)
public abstract class AbstractBitbucketApi {
protected static final int API_RATE_LIMIT_STATUS_CODE = 429;

public abstract class AbstractBitbucketApi implements AutoCloseable {
protected final Logger logger = Logger.getLogger(this.getClass().getName());
protected HttpClientContext context;
private final BitbucketAuthenticator authenticator;
private HttpClientContext context;

protected AbstractBitbucketApi(BitbucketAuthenticator authenticator) {
this.authenticator = authenticator;
}

protected String truncateMiddle(@CheckForNull String value, int maxLength) {
int length = StringUtils.length(value);
Expand Down Expand Up @@ -109,7 +138,39 @@ private long getLenghtFromHeader(CloseableHttpResponse response) {
return len;
}

protected void setClientProxyParams(String host, HttpClientBuilder builder) {
protected HttpClientBuilder setupClientBuilder(@Nullable String host) {
int connectTimeout = Integer.getInteger("http.connect.timeout", 10);
int connectionRequestTimeout = Integer.getInteger("http.connect.request.timeout", 60);
int socketTimeout = Integer.getInteger("http.socket.timeout", 60);

RequestConfig config = RequestConfig.custom()
.setConnectTimeout(connectTimeout * 1000)
.setConnectionRequestTimeout(connectionRequestTimeout * 1000)
.setSocketTimeout(socketTimeout * 1000)
.build();

HttpClientConnectionManager connectionManager = getConnectionManager();
ServiceUnavailableRetryStrategy serviceUnavailableStrategy = new ExponentialBackOffServiceUnavailableRetryStrategy(2, TimeUnit.SECONDS.toMillis(5), TimeUnit.HOURS.toMillis(1));
HttpClientBuilder httpClientBuilder = HttpClientBuilder.create()
.useSystemProperties()
.setConnectionManager(connectionManager)

Check warning on line 156 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 156 is only partially covered, one branch is missing
.setConnectionManagerShared(connectionManager != null)
.setServiceUnavailableRetryStrategy(serviceUnavailableStrategy)
.setRetryHandler(new StandardHttpRequestRetryHandler())
.setDefaultRequestConfig(config)
.disableCookieManagement();

if (authenticator != null) {
authenticator.configureBuilder(httpClientBuilder);

context = HttpClientContext.create();
authenticator.configureContext(context, getHost());
}
setClientProxyParams(host, httpClientBuilder);
return httpClientBuilder;
}

private void setClientProxyParams(String host, HttpClientBuilder builder) {
Jenkins jenkins = Jenkins.getInstanceOrNull(); // because unit test
ProxyConfiguration proxyConfig = jenkins != null ? jenkins.proxy : null;

Expand Down Expand Up @@ -150,4 +211,142 @@ protected void setClientProxyParams(String host, HttpClientBuilder builder) {
}
}

@CheckForNull
protected abstract HttpClientConnectionManager getConnectionManager();

@NonNull
protected abstract HttpHost getHost();

@NonNull
protected abstract CloseableHttpClient getClient();

/* for test purpose */
protected CloseableHttpResponse executeMethod(HttpHost host, HttpRequestBase httpMethod) throws IOException {
if (authenticator != null) {

Check warning on line 225 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 225 is only partially covered, one branch is missing
authenticator.configureRequest(httpMethod);

Check warning on line 226 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 226 is not covered by tests
}
return getClient().execute(host, httpMethod, context);
}

protected String doRequest(HttpRequestBase request) throws IOException {
try (CloseableHttpResponse response = executeMethod(getHost(), request)) {
int statusCode = response.getStatusLine().getStatusCode();
if (statusCode == HttpStatus.SC_NOT_FOUND) {

Check warning on line 234 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 234 is only partially covered, one branch is missing
throw new FileNotFoundException("URL: " + request.getURI());

Check warning on line 235 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 235 is not covered by tests
}
if (statusCode == HttpStatus.SC_NO_CONTENT) {

Check warning on line 237 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 237 is only partially covered, one branch is missing
EntityUtils.consume(response.getEntity());
// 204, no content
return "";

Check warning on line 240 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 238-240 are not covered by tests
}
String content = getResponseContent(response);
EntityUtils.consume(response.getEntity());
if (statusCode != HttpStatus.SC_OK && statusCode != HttpStatus.SC_CREATED) {

Check warning on line 244 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 244 is only partially covered, one branch is missing
throw buildResponseException(response, content);
}
return content;
} catch (BitbucketRequestException e) {

Check warning on line 248 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 248 is not covered by tests
throw e;
} catch (IOException e) {
throw new IOException("Communication error for url: " + request, e);

Check warning on line 251 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 250-251 are not covered by tests
} finally {
release(request);
}
}

private void release(HttpRequestBase method) {
method.releaseConnection();
HttpClientConnectionManager connectionManager = getConnectionManager();
if (connectionManager != null) {

Check warning on line 260 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 260 is only partially covered, one branch is missing
connectionManager.closeExpiredConnections();
}
}

/**
* Caller's responsible to close the InputStream.
*/
protected InputStream getRequestAsInputStream(String path) throws IOException {
HttpGet httpget = new HttpGet(path);
HttpHost host = getHost();

// Extract host from URL, if present
try {
URI uri = new URI(host.toURI());
if (uri.isAbsolute() && ! uri.isOpaque()) {
host = HttpHost.create(uri.getScheme() + "://" + uri.getAuthority());
}
} catch (URISyntaxException ex) {
// use default
}

try (CloseableHttpResponse response = executeMethod(host, httpget)) {
int statusCode = response.getStatusLine().getStatusCode();
if (statusCode == HttpStatus.SC_NOT_FOUND) {
EntityUtils.consume(response.getEntity());
throw new FileNotFoundException("URL: " + path);
}
if (statusCode != HttpStatus.SC_OK) {
String content = getResponseContent(response);
throw buildResponseException(response, content);
}
return new ClosingConnectionInputStream(response, httpget, getConnectionManager());
} catch (BitbucketRequestException | FileNotFoundException e) {
throw e;
} catch (IOException e) {
throw new IOException("Communication error for url: " + path, e);
} finally {
release(httpget);

Check warning on line 298 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 269-298 are not covered by tests
}
}

protected int headRequestStatus(String path) throws IOException {
HttpHead httpHead = new HttpHead(path);
try (CloseableHttpResponse response = executeMethod(getHost(), httpHead)) {
EntityUtils.consume(response.getEntity());
return response.getStatusLine().getStatusCode();
} catch (IOException e) {
throw new IOException("Communication error for url: " + path, e);

Check warning on line 308 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 307-308 are not covered by tests
} finally {
release(httpHead);
}
}

protected String getRequest(String path) throws IOException {
HttpGet httpget = new HttpGet(path);
return doRequest(httpget);
}

protected String postRequest(String path, List<? extends NameValuePair> params) throws IOException {
HttpPost request = new HttpPost(path);
request.setEntity(new UrlEncodedFormEntity(params));
return doRequest(request);

Check warning on line 322 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 320-322 are not covered by tests
}

protected String postRequest(String path, String content) throws IOException {
HttpPost request = new HttpPost(path);
request.setEntity(new StringEntity(content, ContentType.create("application/json", "UTF-8")));
return doRequest(request);
}

protected String putRequest(String path, String content) throws IOException {
HttpPut request = new HttpPut(path);
request.setEntity(new StringEntity(content, ContentType.create("application/json", "UTF-8")));
return doRequest(request);
}

protected String deleteRequest(String path) throws IOException {
HttpDelete request = new HttpDelete(path);
return doRequest(request);
}

@Override
public void close() throws Exception {
if (getClient() != null) {
getClient().close();
}
}

protected BitbucketAuthenticator getAuthenticator() {
return authenticator;

Check warning on line 350 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 338-350 are not covered by tests
}
}
Loading

0 comments on commit 2f719d7

Please sign in to comment.