5959import com .fasterxml .jackson .core .type .TypeReference ;
6060import edu .umd .cs .findbugs .annotations .CheckForNull ;
6161import edu .umd .cs .findbugs .annotations .NonNull ;
62+ import hudson .Main ;
6263import hudson .ProxyConfiguration ;
6364import hudson .Util ;
6465import java .awt .image .BufferedImage ;
7273import java .net .URI ;
7374import java .net .URISyntaxException ;
7475import java .nio .charset .StandardCharsets ;
76+ import java .time .Duration ;
77+ import java .time .Instant ;
7578import java .util .ArrayList ;
7679import java .util .Collections ;
7780import java .util .Comparator ;
114117import org .apache .http .impl .client .StandardHttpRequestRetryHandler ;
115118import org .apache .http .message .BasicNameValuePair ;
116119import org .apache .http .util .EntityUtils ;
120+ import org .kohsuke .accmod .Restricted ;
121+ import org .kohsuke .accmod .restrictions .ProtectedExternally ;
117122
118123import 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