ExportXMLWordPrintable

    • Type: Bug
    • Resolution: Unresolved
    • Priority: Major
    • Environment:
        Jenkins 2.568.3-lts-jdk21
        cloudbees-bitbucket-branch-source 937.3.10
        Self-hosted Bitbucket Server/Data Center, one credential per endpoint
        Organization folders, default concurrent indexing limit (5)

      Sustained HTTP 429 blocks indexing threads for ~85 minutes per call; unreleased
      master adds an unbounded retry recursion. Affects any installation using a single
      credential, on both Bitbucket Cloud and Server/Data Center.

      --------------------------------------------------------------------------
      Part 1 - released behaviour (937.3.10 and 937.3.11)
      --------------------------------------------------------------------------

      AbstractBitbucketApi#createClient installs the retry strategy unconditionally:

       

          .setRetryStrategy(new ExponentialBackoffRetryStrategy(
                  2, TimeUnit.SECONDS.toMillis(5), TimeUnit.HOURS.toMillis(1)))

       

      https://github.com/jenkinsci/bitbucket-branch-source-plugin/blob/937.3.11/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java#L174

      ExponentialBackoffRetryStrategy#retryRequest retries on 429 and 503 while the
      computed interval is below maxExpiryInMillis, and never consults maxRetries:

       
          return getRetryInterval(executionCount) < maxExpiryInMillis
                  && (statusCode == HttpStatus.SC_TOO_MANY_REQUESTS
                  || statusCode == HttpStatus.SC_SERVICE_UNAVAILABLE);
      

       

      https://github.com/jenkinsci/bitbucket-branch-source-plugin/blob/937.3.11/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/ExponentialBackoffRetryStrategy.java#L122-L127

      https://github.com/jenkinsci/bitbucket-branch-source-plugin/blob/937.3.11/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/ExponentialBackoffRetryStrategy.java#L129-L136

       

      With (backOffRate=2, initialExpiry=5s, maxExpiry=1h) the delays are
      5, 10, 20, 40, 80, 160, 320, 640, 1280 and 2560 seconds. The eleventh interval
      would reach the 1 hour cap, so retrying stops there. That is 10 retries and
      about 5115 seconds - roughly 85 minutes - during which the calling thread is
      blocked.

      For folder indexing this means one API call can hold an indexing slot for ~85
      minutes. With the default limit of 5 concurrent computations, five such calls
      stall indexing entirely for that period.

      Observed in production. Our Bitbucket Server began returning 429
      under load from Jenkins organization folder indexing. The cause of that load was
      on our side and is already fixed. What follows is independent of how the volume
      arose: once 429 started, the installation did not recover on its own:

        - Scan slots were never released; the queue showed
          "At maximum indexing capacity" against the default limit of 5.
        - Per-project last-scan timestamps stopped advancing: scans started but did
          not finish.
        - The indexing queue did not drain over many hours. Cancelling queue items
          did not help, as the upstream trigger kept firing.
        - Bitbucket CPU stayed elevated and other, non-Jenkins clients of the same
          instance were rate-limited as collateral damage.

      We froze folder scans via a QueueDecisionHandler to let Bitbucket recover. But
      the retry behaviour means any installation that reaches 429 for any reason will
      behave the same way: it responds to "too many requests" by holding threads for
      85 minutes and then trying again.

      One further note on the released code: Retry-After is honoured for the length of
      the delay in getRetryInterval(HttpResponse, int, HttpContext), but the decision
      to keep retrying comes solely from the exponential value. A server asking for a
      short pause therefore still receives all 10 retries.

      https://github.com/jenkinsci/bitbucket-branch-source-plugin/blob/937.3.11/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/ExponentialBackoffRetryStrategy.java#L166-L184

      --------------------------------------------------------------------------
      Part 2 - regression on master, not yet released
      --------------------------------------------------------------------------

      Current master (ae3f03428c76c0a9fbfaf4f867be0ee153d4a21b, 2026-11-03) adds a
      429 branch to AbstractBitbucketApi#executeMethod that is not present in any
      released version:

       

          if (statusCode == HttpStatus.SC_TOO_MANY_REQUESTS) {
              if (authenticator != null) {
                  authenticator.markLimitReached();
                  response.close();
                  // retry
                  return executeMethod(request);
              } else {
                  String content = getResponseContent(response);
                  throw buildResponseException(response, content);
              }
          }

       

      https://github.com/jenkinsci/bitbucket-branch-source-plugin/blob/ae3f03428c76c0a9fbfaf4f867be0ee153d4a21b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java#L278-L289

       

      This retries with no delay, no attempt counter and no upper bound.

      BitbucketAuthenticator#markLimitReached() is a no-op by default; only
      BitbucketMultiCredentialsAuthenticator overrides it to rotate credentials:

      https://github.com/jenkinsci/bitbucket-branch-source-plugin/blob/ae3f03428c76c0a9fbfaf4f867be0ee153d4a21b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/api/BitbucketAuthenticator.java#L117-L118

       

      So for any single-credential configuration nothing changes between attempts and
      the recursion cannot terminate. Because it is real recursion rather than a loop,
      sustained 429 also risks StackOverflowError.

      It compounds with Part 1. On master the retry strategy is applied only when the
      authenticator does not support rate limiting:

       

          if (authenticator != null) {
              if (!authenticator.supportsRateLimit()) {
                  httpClientBuilder.setRetryStrategy(new ExponentialBackoffRetryStrategy(
                          2, TimeUnit.SECONDS.toMillis(5), TimeUnit.HOURS.toMillis(1)));
              }
              ...
          }

       

      https://github.com/jenkinsci/bitbucket-branch-source-plugin/blob/ae3f03428c76c0a9fbfaf4f867be0ee153d4a21b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java#L181-L184

       

      supportsRateLimit() is false by default:

      https://github.com/jenkinsci/bitbucket-branch-source-plugin/blob/ae3f03428c76c0a9fbfaf4f867be0ee153d4a21b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/api/BitbucketAuthenticator.java#L124-L126

       

      A single-credential installation therefore gets both: the ~85 minute ladder,
      followed by an unbounded recursion that starts the whole ladder again.

      Note also that the strategy is now installed inside the
      "if (authenticator != null)" block, so anonymous clients - which received the
      backoff unconditionally in 937.3.x - get no retry strategy at all on master.
      This looks unintended.

      Finally, BitbucketMultiCredentialsAuthenticator#markLimitReached hard-codes
      Bitbucket Cloud's one-hour rolling window (DateUtils.addHours(entry.getValue(), 1)),
      citing the Cloud rate-limit documentation. Bitbucket Server/Data Center uses a
      token bucket with different refill behaviour, so a credential banned for a fixed
      hour is both too long and not aligned with when quota actually returns there.
      Deriving the ban duration from Retry-After when the server supplies it would
      cover both platforms.

      --------------------------------------------------------------------------
      Relation to existing tickets
      --------------------------------------------------------------------------

      JENKINS-76471 and PRs #1232 and #1248 address Bitbucket Cloud rate limits with
      pools of fallback credentials, using X-RateLimit-NearLimit and the Cloud rolling
      window. Those mechanisms do not exist on Bitbucket Server/Data Center, and a
      credential pool does not address either problem above: it only provides something
      to rotate to. An installation with a single credential, on either platform, still
      blocks for 85 minutes per call and - on master - still recurses without bound.

      --------------------------------------------------------------------------
      Suggested fix
      --------------------------------------------------------------------------

        - Gate the executeMethod 429 branch on authenticator.supportsRateLimit(), the
          same condition already used to select the retry strategy in createClient.
          Only BitbucketMultiCredentialsAuthenticator overrides markLimitReached() to
          rotate; for every other authenticator it is a no-op, so the retry changes
          nothing and the recursion cannot terminate. Authenticators without a
          credential pool should fall through to the retry strategy and then surface
          the 429.

        - Treat 429 as an instruction rather than a transient error. When the server
          asks for a pause, honour it and then stop: retry at most once or twice,
          and when Retry-After is present do not retry beyond it. Today Retry-After
          only shortens the wait; the decision to keep retrying still comes solely
          from the exponential value, so a server asking for a short pause still
          receives all ten attempts.

        - Distinguish 429 from 503. The response-based retryRequest treats both
          identically, but 503 means the server is temporarily unavailable, where
          retrying is appropriate, while 429 means the client is sending too much,
          where retrying adds to the problem. A 429 is an instruction to stop, not a
          transient failure.

        - Default to a short retry budget, and make it configurable. The current
          values (backOffRate=2, initialExpiry=5s, maxExpiry=1h) are hard-coded, and
          because retryRequest stops only when the interval reaches maxExpiry rather
          than on an attempt count, maxRetries has no effect. An administrator facing
          sustained 429 has no setting to adjust. A short default matters more than
          the setting, since most installations will never change it.

        - For folder indexing specifically, fail fast and release the indexing slot
          instead of blocking it. A scan runs again on the next periodic trigger or
          webhook, so waiting 85 minutes inside one of five shared slots retries work
          that would have been repeated anyway.

            Assignee:
            Nikolas Falco
            Reporter:
            J
            Votes:
            0 Vote for this issue
            Watchers:
            1 Start watching this issue

              Created:
              Updated: