Skip to content

Commit e4d32d5

Browse files
authored
[JENKINS-64418] Add exponential backoff to BitBucket rate limit retry loop (#927)
Configure Apache HTTP client to use an exponential backoff retry strategy. Move methods common to both server and cloud client to the abstract class.
1 parent b0b29bd commit e4d32d5

14 files changed

+594
-550
lines changed

pom.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,18 @@
167167
<artifactId>scribejava-core</artifactId>
168168
<version>8.3.3</version>
169169
</dependency>
170+
<dependency>
171+
<groupId>org.mock-server</groupId>
172+
<artifactId>mockserver-junit-jupiter</artifactId>
173+
<version>5.15.0</version>
174+
<scope>test</scope>
175+
<exclusions>
176+
<exclusion>
177+
<groupId>javax.servlet</groupId>
178+
<artifactId>javax.servlet-api</artifactId>
179+
</exclusion>
180+
</exclusions>
181+
</dependency>
170182
</dependencies>
171183

172184
<repositories>

src/main/java/com/cloudbees/jenkins/plugins/bitbucket/BitbucketDefaultBranch.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@
3333
* Represents the default branch of a specific repository
3434
*/
3535
public class BitbucketDefaultBranch extends InvisibleAction implements Serializable {
36-
private static final long serialVersionUID = 1L;
36+
private static final long serialVersionUID = 1826270778226063782L;
37+
3738
@NonNull
3839
private final String repoOwner;
3940
@NonNull

src/main/java/com/cloudbees/jenkins/plugins/bitbucket/BitbucketGitSCMBuilder.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,8 @@ private void withPullRequestRemote(PullRequestSCMHead head, String headName) {
214214
String scmSourceRepository = scmSource.getRepository();
215215
String pullRequestRepoOwner = head.getRepoOwner();
216216
String pullRequestRepository = head.getRepository();
217-
boolean prFromTargetRepository = pullRequestRepoOwner.equals(scmSourceRepoOwner)
218-
&& pullRequestRepository.equals(scmSourceRepository);
217+
boolean prFromTargetRepository = pullRequestRepoOwner.equalsIgnoreCase(scmSourceRepoOwner)
218+
&& pullRequestRepository.equalsIgnoreCase(scmSourceRepository);
219219
SCMRevision revision = revision();
220220
ChangeRequestCheckoutStrategy checkoutStrategy = head.getCheckoutStrategy();
221221
// PullRequestSCMHead should be refactored to add references to target and source commit hashes.

src/main/java/com/cloudbees/jenkins/plugins/bitbucket/api/BitbucketProject.java

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
package com.cloudbees.jenkins.plugins.bitbucket.api;
22

3-
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
4-
5-
@JsonIgnoreProperties(ignoreUnknown = true)
63
public class BitbucketProject {
74

85
private String key;

src/main/java/com/cloudbees/jenkins/plugins/bitbucket/client/BitbucketCloudApiClient.java

Lines changed: 25 additions & 221 deletions
Large diffs are not rendered by default.

src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java

Lines changed: 204 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,40 +23,69 @@
2323
*/
2424
package com.cloudbees.jenkins.plugins.bitbucket.impl.client;
2525

26+
import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketAuthenticator;
2627
import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketRequestException;
28+
import com.cloudbees.jenkins.plugins.bitbucket.client.ClosingConnectionInputStream;
2729
import edu.umd.cs.findbugs.annotations.CheckForNull;
30+
import edu.umd.cs.findbugs.annotations.NonNull;
31+
import edu.umd.cs.findbugs.annotations.Nullable;
2832
import hudson.ProxyConfiguration;
2933
import hudson.util.Secret;
34+
import java.io.FileNotFoundException;
3035
import java.io.IOException;
3136
import java.io.InputStream;
3237
import java.net.InetSocketAddress;
3338
import java.net.Proxy;
39+
import java.net.URI;
40+
import java.net.URISyntaxException;
3441
import java.nio.charset.StandardCharsets;
42+
import java.util.List;
43+
import java.util.concurrent.TimeUnit;
3544
import java.util.logging.Logger;
3645
import jenkins.model.Jenkins;
3746
import org.apache.commons.io.IOUtils;
3847
import org.apache.commons.lang.StringUtils;
3948
import org.apache.http.Header;
4049
import org.apache.http.HttpHost;
50+
import org.apache.http.HttpStatus;
51+
import org.apache.http.NameValuePair;
4152
import org.apache.http.auth.AuthScope;
4253
import org.apache.http.auth.UsernamePasswordCredentials;
4354
import org.apache.http.client.AuthCache;
4455
import org.apache.http.client.CredentialsProvider;
56+
import org.apache.http.client.ServiceUnavailableRetryStrategy;
57+
import org.apache.http.client.config.RequestConfig;
58+
import org.apache.http.client.entity.UrlEncodedFormEntity;
4559
import org.apache.http.client.methods.CloseableHttpResponse;
60+
import org.apache.http.client.methods.HttpDelete;
61+
import org.apache.http.client.methods.HttpGet;
62+
import org.apache.http.client.methods.HttpHead;
63+
import org.apache.http.client.methods.HttpPost;
64+
import org.apache.http.client.methods.HttpPut;
65+
import org.apache.http.client.methods.HttpRequestBase;
4666
import org.apache.http.client.protocol.HttpClientContext;
67+
import org.apache.http.conn.HttpClientConnectionManager;
68+
import org.apache.http.entity.ContentType;
69+
import org.apache.http.entity.StringEntity;
4770
import org.apache.http.impl.auth.BasicScheme;
4871
import org.apache.http.impl.client.BasicAuthCache;
4972
import org.apache.http.impl.client.BasicCredentialsProvider;
73+
import org.apache.http.impl.client.CloseableHttpClient;
5074
import org.apache.http.impl.client.HttpClientBuilder;
75+
import org.apache.http.impl.client.StandardHttpRequestRetryHandler;
76+
import org.apache.http.util.EntityUtils;
5177
import org.kohsuke.accmod.Restricted;
5278
import org.kohsuke.accmod.restrictions.ProtectedExternally;
5379

5480
@Restricted(ProtectedExternally.class)
55-
public abstract class AbstractBitbucketApi {
56-
protected static final int API_RATE_LIMIT_STATUS_CODE = 429;
57-
81+
public abstract class AbstractBitbucketApi implements AutoCloseable {
5882
protected final Logger logger = Logger.getLogger(this.getClass().getName());
59-
protected HttpClientContext context;
83+
private final BitbucketAuthenticator authenticator;
84+
private HttpClientContext context;
85+
86+
protected AbstractBitbucketApi(BitbucketAuthenticator authenticator) {
87+
this.authenticator = authenticator;
88+
}
6089

6190
protected String truncateMiddle(@CheckForNull String value, int maxLength) {
6291
int length = StringUtils.length(value);
@@ -109,7 +138,39 @@ private long getLenghtFromHeader(CloseableHttpResponse response) {
109138
return len;
110139
}
111140

112-
protected void setClientProxyParams(String host, HttpClientBuilder builder) {
141+
protected HttpClientBuilder setupClientBuilder(@Nullable String host) {
142+
int connectTimeout = Integer.getInteger("http.connect.timeout", 10);
143+
int connectionRequestTimeout = Integer.getInteger("http.connect.request.timeout", 60);
144+
int socketTimeout = Integer.getInteger("http.socket.timeout", 60);
145+
146+
RequestConfig config = RequestConfig.custom()
147+
.setConnectTimeout(connectTimeout * 1000)
148+
.setConnectionRequestTimeout(connectionRequestTimeout * 1000)
149+
.setSocketTimeout(socketTimeout * 1000)
150+
.build();
151+
152+
HttpClientConnectionManager connectionManager = getConnectionManager();
153+
ServiceUnavailableRetryStrategy serviceUnavailableStrategy = new ExponentialBackOffServiceUnavailableRetryStrategy(2, TimeUnit.SECONDS.toMillis(5), TimeUnit.HOURS.toMillis(1));
154+
HttpClientBuilder httpClientBuilder = HttpClientBuilder.create()
155+
.useSystemProperties()
156+
.setConnectionManager(connectionManager)
157+
.setConnectionManagerShared(connectionManager != null)
158+
.setServiceUnavailableRetryStrategy(serviceUnavailableStrategy)
159+
.setRetryHandler(new StandardHttpRequestRetryHandler())
160+
.setDefaultRequestConfig(config)
161+
.disableCookieManagement();
162+
163+
if (authenticator != null) {
164+
authenticator.configureBuilder(httpClientBuilder);
165+
166+
context = HttpClientContext.create();
167+
authenticator.configureContext(context, getHost());
168+
}
169+
setClientProxyParams(host, httpClientBuilder);
170+
return httpClientBuilder;
171+
}
172+
173+
private void setClientProxyParams(String host, HttpClientBuilder builder) {
113174
Jenkins jenkins = Jenkins.getInstanceOrNull(); // because unit test
114175
ProxyConfiguration proxyConfig = jenkins != null ? jenkins.proxy : null;
115176

@@ -150,4 +211,142 @@ protected void setClientProxyParams(String host, HttpClientBuilder builder) {
150211
}
151212
}
152213

214+
@CheckForNull
215+
protected abstract HttpClientConnectionManager getConnectionManager();
216+
217+
@NonNull
218+
protected abstract HttpHost getHost();
219+
220+
@NonNull
221+
protected abstract CloseableHttpClient getClient();
222+
223+
/* for test purpose */
224+
protected CloseableHttpResponse executeMethod(HttpHost host, HttpRequestBase httpMethod) throws IOException {
225+
if (authenticator != null) {
226+
authenticator.configureRequest(httpMethod);
227+
}
228+
return getClient().execute(host, httpMethod, context);
229+
}
230+
231+
protected String doRequest(HttpRequestBase request) throws IOException {
232+
try (CloseableHttpResponse response = executeMethod(getHost(), request)) {
233+
int statusCode = response.getStatusLine().getStatusCode();
234+
if (statusCode == HttpStatus.SC_NOT_FOUND) {
235+
throw new FileNotFoundException("URL: " + request.getURI());
236+
}
237+
if (statusCode == HttpStatus.SC_NO_CONTENT) {
238+
EntityUtils.consume(response.getEntity());
239+
// 204, no content
240+
return "";
241+
}
242+
String content = getResponseContent(response);
243+
EntityUtils.consume(response.getEntity());
244+
if (statusCode != HttpStatus.SC_OK && statusCode != HttpStatus.SC_CREATED) {
245+
throw buildResponseException(response, content);
246+
}
247+
return content;
248+
} catch (BitbucketRequestException e) {
249+
throw e;
250+
} catch (IOException e) {
251+
throw new IOException("Communication error for url: " + request, e);
252+
} finally {
253+
release(request);
254+
}
255+
}
256+
257+
private void release(HttpRequestBase method) {
258+
method.releaseConnection();
259+
HttpClientConnectionManager connectionManager = getConnectionManager();
260+
if (connectionManager != null) {
261+
connectionManager.closeExpiredConnections();
262+
}
263+
}
264+
265+
/*
266+
* Caller's responsible to close the InputStream.
267+
*/
268+
protected InputStream getRequestAsInputStream(String path) throws IOException {
269+
HttpGet httpget = new HttpGet(path);
270+
HttpHost host = getHost();
271+
272+
// Extract host from URL, if present
273+
try {
274+
URI uri = new URI(host.toURI());
275+
if (uri.isAbsolute() && ! uri.isOpaque()) {
276+
host = HttpHost.create(uri.getScheme() + "://" + uri.getAuthority());
277+
}
278+
} catch (URISyntaxException ex) {
279+
// use default
280+
}
281+
282+
try (CloseableHttpResponse response = executeMethod(host, httpget)) {
283+
int statusCode = response.getStatusLine().getStatusCode();
284+
if (statusCode == HttpStatus.SC_NOT_FOUND) {
285+
EntityUtils.consume(response.getEntity());
286+
throw new FileNotFoundException("URL: " + path);
287+
}
288+
if (statusCode != HttpStatus.SC_OK) {
289+
String content = getResponseContent(response);
290+
throw buildResponseException(response, content);
291+
}
292+
return new ClosingConnectionInputStream(response, httpget, getConnectionManager());
293+
} catch (BitbucketRequestException | FileNotFoundException e) {
294+
throw e;
295+
} catch (IOException e) {
296+
throw new IOException("Communication error for url: " + path, e);
297+
} finally {
298+
release(httpget);
299+
}
300+
}
301+
302+
protected int headRequestStatus(String path) throws IOException {
303+
HttpHead httpHead = new HttpHead(path);
304+
try (CloseableHttpResponse response = executeMethod(getHost(), httpHead)) {
305+
EntityUtils.consume(response.getEntity());
306+
return response.getStatusLine().getStatusCode();
307+
} catch (IOException e) {
308+
throw new IOException("Communication error for url: " + path, e);
309+
} finally {
310+
release(httpHead);
311+
}
312+
}
313+
314+
protected String getRequest(String path) throws IOException {
315+
HttpGet httpget = new HttpGet(path);
316+
return doRequest(httpget);
317+
}
318+
319+
protected String postRequest(String path, List<? extends NameValuePair> params) throws IOException {
320+
HttpPost request = new HttpPost(path);
321+
request.setEntity(new UrlEncodedFormEntity(params));
322+
return doRequest(request);
323+
}
324+
325+
protected String postRequest(String path, String content) throws IOException {
326+
HttpPost request = new HttpPost(path);
327+
request.setEntity(new StringEntity(content, ContentType.create("application/json", "UTF-8")));
328+
return doRequest(request);
329+
}
330+
331+
protected String putRequest(String path, String content) throws IOException {
332+
HttpPut request = new HttpPut(path);
333+
request.setEntity(new StringEntity(content, ContentType.create("application/json", "UTF-8")));
334+
return doRequest(request);
335+
}
336+
337+
protected String deleteRequest(String path) throws IOException {
338+
HttpDelete request = new HttpDelete(path);
339+
return doRequest(request);
340+
}
341+
342+
@Override
343+
public void close() throws Exception {
344+
if (getClient() != null) {
345+
getClient().close();
346+
}
347+
}
348+
349+
protected BitbucketAuthenticator getAuthenticator() {
350+
return authenticator;
351+
}
153352
}

0 commit comments

Comments
 (0)