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 12, 2024
1 parent 2dcf2d9 commit f8207f9
Show file tree
Hide file tree
Showing 10 changed files with 371 additions and 560 deletions.
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,71 @@
*/
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.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.BackoffManager;
import org.apache.http.client.CredentialsProvider;
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.AIMDBackoffManager;
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.DefaultBackoffStrategy;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.StandardHttpRequestRetryHandler;
import org.apache.http.pool.ConnPoolControl;
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;

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 +140,45 @@ private long getLenghtFromHeader(CloseableHttpResponse response) {
return len;
}

protected void setClientProxyParams(String host, HttpClientBuilder builder) {
@SuppressWarnings("unchecked")
protected CloseableHttpClient buildClient(@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();
BackoffManager backoffManager = null;
if (connectionManager instanceof ConnPoolControl connPerRoute) {

Check warning on line 157 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 157 is only partially covered, one branch is missing
backoffManager = new AIMDBackoffManager(connPerRoute);
}

HttpClientBuilder httpClientBuilder = HttpClientBuilder.create()
.useSystemProperties()
.setConnectionManager(connectionManager)

Check warning on line 163 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 163 is only partially covered, one branch is missing
.setConnectionManagerShared(connectionManager != null)
.setConnectionBackoffStrategy(new DefaultBackoffStrategy())
.setBackoffManager(backoffManager)
.setRetryHandler(new StandardHttpRequestRetryHandler())
.setDefaultRequestConfig(config)
.disableCookieManagement();

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

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

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 +219,145 @@ 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) {
authenticator.configureRequest(httpMethod);
}
return getClient().execute(host, httpMethod, context);

Check warning on line 236 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 233-236 are not covered by tests
}

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 242 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 242 is only partially covered, one branch is missing
throw new FileNotFoundException("URL: " + request.getURI());

Check warning on line 243 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 243 is not covered by tests
}
if (statusCode == HttpStatus.SC_NO_CONTENT) {

Check warning on line 245 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 245 is only partially covered, one branch is missing
EntityUtils.consume(response.getEntity());
// 204, no content
return "";

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 lines

Lines 246-248 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 252 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 252 is only partially covered, 3 branches are missing
throw buildResponseException(response, content);

Check warning on line 253 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 253 is not covered by tests
}
return content;
} catch (BitbucketRequestException e) {
throw e;
} catch (IOException e) {
throw new IOException("Communication error for url: " + request, e);

Check warning on line 259 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 256-259 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 268 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 268 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);

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

Potential server-side request forgery due to a
user-provided value
.
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 306 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 277-306 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 316 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 315-316 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 330 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 328-330 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);

Check warning on line 347 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 346-347 are not covered by tests
}

// TODO move interface to autoclosable

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 / Open Tasks Scanner

TODO

NORMAL: move interface to autoclosable
@Override
protected void finalize() throws Throwable {
if (getClient() != null) {
getClient().close();
}

super.finalize();

Check notice

Code scanning / CodeQL

Deprecated method or constructor invocation Note

Invoking
Object.finalize
should be avoided because it has been deprecated.
}

protected BitbucketAuthenticator getAuthenticator() {
return authenticator;

Check warning on line 361 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 361 is not covered by tests
}
}
Loading

0 comments on commit f8207f9

Please sign in to comment.