Skip to content

Commit

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

Configure Apache HTTP client to use an exponential backoff retry strategy
  • Loading branch information
nfalco79 committed Nov 30, 2024
1 parent cb32459 commit 9bef7e5
Show file tree
Hide file tree
Showing 7 changed files with 401 additions and 579 deletions.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -23,39 +23,75 @@
*/
package com.cloudbees.jenkins.plugins.bitbucket.internal.api;

import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketRequestException;
import edu.umd.cs.findbugs.annotations.CheckForNull;
import hudson.ProxyConfiguration;
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;

Check warning on line 37 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/internal/api/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Java Compiler

checkstyle:check

ERROR: (imports) ImportOrder: Extra separation in import group before 'org.apache.commons.io.IOUtils'

Check warning on line 37 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/internal/api/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / CheckStyle

ImportOrderCheck

ERROR: Extra separation in import group before 'org.apache.commons.io.IOUtils'
Raw output
<p>Since Checkstyle 3.2</p><p>Checks the ordering/grouping of imports. Features are:</p><ul><li>groups type/static imports: ensures that groups of imports come in a specific order (e.g., java. comes first, javax. comes second, then everything else)</li><li>adds a separation between type import groups : ensures that a blank line sit between each group</li><li>type/static import groups aren't separated internally: ensures that each group aren't separated internally by blank line or comment</li><li>sorts type/static imports inside each group: ensures that imports within each group are in lexicographic order</li><li>sorts according to case: ensures that the comparison between imports is case sensitive, in <a href="https://en.wikipedia.org/wiki/ASCII#Order">ASCII sort order</a></li><li>arrange static imports: ensures the relative order between type imports and static imports (see <a href="property_types.html#importOrder">import orders</a>)</li></ul><p><a href="#ImportOrder_Examples">Examples section</a> contains examples that work with default formatter configurations of Eclipse, IntelliJ IDEA and NetBeans </p>
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;

import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketAuthenticator;

Check warning on line 74 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/internal/api/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Java Compiler

checkstyle:check

ERROR: (imports) ImportOrder: Extra separation in import group before 'com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketAuthenticator'

Check warning on line 74 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/internal/api/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Java Compiler

checkstyle:check

ERROR: (imports) ImportOrder: Wrong order for 'com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketAuthenticator' import.

Check warning on line 74 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/internal/api/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / CheckStyle

ImportOrderCheck

ERROR: Extra separation in import group before 'com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketAuthenticator'
Raw output
<p>Since Checkstyle 3.2</p><p>Checks the ordering/grouping of imports. Features are:</p><ul><li>groups type/static imports: ensures that groups of imports come in a specific order (e.g., java. comes first, javax. comes second, then everything else)</li><li>adds a separation between type import groups : ensures that a blank line sit between each group</li><li>type/static import groups aren't separated internally: ensures that each group aren't separated internally by blank line or comment</li><li>sorts type/static imports inside each group: ensures that imports within each group are in lexicographic order</li><li>sorts according to case: ensures that the comparison between imports is case sensitive, in <a href="https://en.wikipedia.org/wiki/ASCII#Order">ASCII sort order</a></li><li>arrange static imports: ensures the relative order between type imports and static imports (see <a href="property_types.html#importOrder">import orders</a>)</li></ul><p><a href="#ImportOrder_Examples">Examples section</a> contains examples that work with default formatter configurations of Eclipse, IntelliJ IDEA and NetBeans </p>

Check warning on line 74 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/internal/api/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / CheckStyle

ImportOrderCheck

ERROR: Wrong order for 'com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketAuthenticator' import.
Raw output
<p>Since Checkstyle 3.2</p><p>Checks the ordering/grouping of imports. Features are:</p><ul><li>groups type/static imports: ensures that groups of imports come in a specific order (e.g., java. comes first, javax. comes second, then everything else)</li><li>adds a separation between type import groups : ensures that a blank line sit between each group</li><li>type/static import groups aren't separated internally: ensures that each group aren't separated internally by blank line or comment</li><li>sorts type/static imports inside each group: ensures that imports within each group are in lexicographic order</li><li>sorts according to case: ensures that the comparison between imports is case sensitive, in <a href="https://en.wikipedia.org/wiki/ASCII#Order">ASCII sort order</a></li><li>arrange static imports: ensures the relative order between type imports and static imports (see <a href="property_types.html#importOrder">import orders</a>)</li></ul><p><a href="#ImportOrder_Examples">Examples section</a> contains examples that work with default formatter configurations of Eclipse, IntelliJ IDEA and NetBeans </p>
import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketRequestException;
import com.cloudbees.jenkins.plugins.bitbucket.client.ClosingConnectionInputStream;

import edu.umd.cs.findbugs.annotations.CheckForNull;

Check warning on line 78 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/internal/api/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Java Compiler

checkstyle:check

ERROR: (imports) ImportOrder: Extra separation in import group before 'edu.umd.cs.findbugs.annotations.CheckForNull'

Check warning on line 78 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/internal/api/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / CheckStyle

ImportOrderCheck

ERROR: Extra separation in import group before 'edu.umd.cs.findbugs.annotations.CheckForNull'
Raw output
<p>Since Checkstyle 3.2</p><p>Checks the ordering/grouping of imports. Features are:</p><ul><li>groups type/static imports: ensures that groups of imports come in a specific order (e.g., java. comes first, javax. comes second, then everything else)</li><li>adds a separation between type import groups : ensures that a blank line sit between each group</li><li>type/static import groups aren't separated internally: ensures that each group aren't separated internally by blank line or comment</li><li>sorts type/static imports inside each group: ensures that imports within each group are in lexicographic order</li><li>sorts according to case: ensures that the comparison between imports is case sensitive, in <a href="https://en.wikipedia.org/wiki/ASCII#Order">ASCII sort order</a></li><li>arrange static imports: ensures the relative order between type imports and static imports (see <a href="property_types.html#importOrder">import orders</a>)</li></ul><p><a href="#ImportOrder_Examples">Examples section</a> contains examples that work with default formatter configurations of Eclipse, IntelliJ IDEA and NetBeans </p>
import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.Nullable;
import hudson.ProxyConfiguration;
import jenkins.model.Jenkins;

@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 @@ -108,7 +144,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 161 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/internal/api/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 161 is only partially covered, one branch is missing
backoffManager = new AIMDBackoffManager(connPerRoute);
}

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

Check warning on line 167 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/internal/api/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 167 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 @@ -149,4 +223,145 @@ protected void setClientProxyParams(String host, HttpClientBuilder builder) {
}
}

@Nullable
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 240 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/internal/api/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 237-240 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 246 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/internal/api/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

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

Check warning on line 247 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/internal/api/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

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

Check warning on line 249 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/internal/api/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

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

Check warning on line 252 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/internal/api/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 250-252 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 256 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/internal/api/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 256 is only partially covered, 3 branches are missing
throw buildResponseException(response, content);

Check warning on line 257 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/internal/api/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 257 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 263 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/internal/api/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 260-263 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 272 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/internal/api/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 272 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 310 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/internal/api/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 281-310 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 320 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/internal/api/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 319-320 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 334 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/internal/api/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 332-334 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 351 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/internal/api/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 350-351 are not covered by tests
}

// TODO move interface to autoclosable

Check warning on line 354 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/internal/api/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 365 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/internal/api/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 365 is not covered by tests
}
}
Loading

0 comments on commit 9bef7e5

Please sign in to comment.