Developer
News and Updates
Get Support
Sign in
Get Support
Sign in
DOCUMENTATION
Cloud
Data Center
Resources
Sign in
Sign in
DOCUMENTATION
Cloud
Data Center
Resources
Sign in
Last updated Oct 20, 2026

Build an app that respects rate limits

This guide shows how to build an app or integration that stays within Jira Cloud's rate limits, and recovers cleanly when it doesn't. The examples are written in TypeScript and run on Node.js 18 or later, using the built-in fetch.

This page is about how to build your app. For what the limits are, including quotas, thresholds, and the full header reference, see Rate limiting.

A well-behaved app does two things:

  1. Balances its load. It paces its own requests, so it rarely hits a limit in the first place.
  2. Responds to limit headers. When Jira does return a 429 Too Many Requests response, the app reads the headers, waits the right amount of time for the right part of the API, and retries.

You need both. Pacing alone breaks the first time traffic spikes, and backoff alone means you hit the limits constantly.

The limits your app has to handle

Jira Cloud applies three independent limits at the same time. Each one has a different scope, so each needs a different response.

RateLimit-ReasonLimitScopeWhat your app should do
jira-burst-basedBurst API rate limit (per second)One tenant, one endpoint, one HTTP methodSlow down requests to that endpoint only.
jira-per-issue-on-writePer-issue write limitOne issueSpace out writes to that issue. Other requests are unaffected.
jira-quota-global-based or jira-quota-tenant-basedPoints-based quota (per hour)All requests from your app (or from your app to one tenant)Pause all requests until Retry-After has passed.

The most important idea in this guide is this: a 429 only blocks the scope that was limited. If one endpoint is limited, keep sending requests to the others. The code below tracks each scope separately, so one limited endpoint doesn't stall your whole app.

Limits are shared, not per thread

Most limits are shared across a tenant. The burst limit applies to a tenant's endpoint and HTTP method, however many threads, processes, connections, or instances of your app send the requests. The hourly quota is shared across your whole app, or across your app's requests to one tenant. Adding workers doesn't give you more throughput. It only means more of them compete for the same allowance, and each one reacts to the same 429 on its own.

Treat your whole app as one client of a shared limit. The rest of this guide shows how, and Running more than one thread, process, or instance covers apps that can't keep all their requests in one place.

Read the rate limit headers

Every response can carry rate limit headers. Your app needs to understand these:

HeaderPresent onWhat to do with it
RateLimit-Reason429 onlyDecide which scope to back off (see the table above). A 503 response doesn't carry it, because a 503 isn't a rate limit response.
Retry-After429 responses, and some transient 503 responsesWait at least this many seconds. This is the preferred delay header for both 429 and retryable 503 responses. It isn't specific to rate limiting.
X-RateLimit-Reset429 onlyAn ISO 8601 time when the window resets. Use it only if Retry-After is missing.
X-RateLimit-RemainingRate limited endpointsHow much request capacity is left in the current second.
RateLimit-Policy and RateLimitWhen a limit is enforcedStructured headers describing the policy and your current state.
Beta-RateLimit-Policy and Beta-RateLimitDuring the beta periodSame format as above, for limits not yet enforced. Informational only.

The structured headers hold one entry per policy. Each entry has a quoted policy name and key=value attributes separated by ;. Entries are separated by ,:

1
2
3
4
RateLimit-Policy: "jira-burst-based";q=100;w=1
RateLimit: "jira-burst-based";r=90;t=1
Beta-RateLimit: "global-app-quota";r=11000;t=600
  • q is the total quota, and w is the window in seconds.
  • r is the remaining quota. It can be missing, which means you're well within the limit.
  • t is the number of seconds until the window resets.

Header grammar

The four structured headers share one format, which is defined by this grammar:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<rate-limit-header>     ::= <header-name> ":" <header-value>
<header-name>           ::= "RateLimit" | "RateLimit-Policy" | "Beta-RateLimit" | "Beta-RateLimit-Policy"
<header-value>          ::= <policy-list>
<policy-list>           ::= <policy-value> | <policy-list> ", " <policy-value>
<policy-value>          ::= <quoted-policy-name> <parameter-list>
<quoted-policy-name>    ::= '"' <policy-name> '"'
<policy-name>           ::= <name-char> | <policy-name> <name-char>
<name-char>             ::= <letter> | <digit> | "-" | "_"
<parameter-list>        ::= ε | <parameter-list> ";" <parameter>
<parameter>             ::= <param-key> "=" <param-value>
<param-key>             ::= <letter> | <param-key> <letter>
<param-value>           ::= <digit> | <param-value> <digit>
<letter>                ::= "A" | "B" | ... | "Z" | "a" | "b" | ... | "z"
<digit>                 ::= "0" | "1" | ... | "9"

In practice, this means:

  • A header value is a list of policy entries separated by , .
  • Each entry starts with a quoted policy name. The name can contain letters, digits, -, and _.
  • The name is followed by zero or more ;key=value parameters. Keys are letters only, and values are non-negative whole numbers.

Don't assume a fixed number or order of entries or parameters, because more limit types and parameters may be added. Ignore any parameters you don't recognize. The following function parses any of these headers:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export interface RateLimitEntry {
  policy: string;
  q?: number;
  w?: number;
  r?: number;
  t?: number;
}

export function parseRateLimitHeader(value: string | null): RateLimitEntry[] {
  if (!value) return [];
  return value.split(",").map((entry) => {
    const [name, ...attrs] = entry.split(";");
    const parsed: RateLimitEntry = { policy: name.trim().replace(/^"|"$/g, "") };
    for (const attr of attrs) {
      const [key, raw] = attr.trim().split("=");
      const n = Number(raw);
      if (["q", "w", "r", "t"].includes(key) && !Number.isNaN(n)) {
        parsed[key as "q" | "w" | "r" | "t"] = n;
      }
    }
    return parsed;
  });
}

During the beta period, the Beta- headers tell you what would happen at enforcement. They never cause a 429 by themselves. Use them to monitor your usage now. When enforcement starts, the Beta- prefix is dropped and the format stays the same.

Part 1: Balance your load

The best 429 is the one that never happens. Four techniques keep your app under its limits.

Cap your concurrency

The most common cause of a 429 is starting everything at once, for example calling Promise.all on thousands of issue keys. Limit how many requests are in flight:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));

export async function mapWithConcurrency<T, R>(
  items: readonly T[],
  limit: number,
  fn: (item: T) => Promise<R>,
): Promise<R[]> {
  const results = new Array<R>(items.length);
  let next = 0;

  async function worker(): Promise<void> {
    while (next < items.length) {
      const i = next++;
      results[i] = await fn(items[i]);
    }
  }

  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
  return results;
}

A limit of 5 to 10 is a reasonable starting point.

Concurrency doesn't buy throughput. The limits are shared by all your requests, so 50 parallel workers get the same allowance as 5. The extra workers only compete with each other, and produce more 429 responses and more retries. Use just enough concurrency to keep your requests flowing at the rate you're allowed.

The cap must also apply to your whole app. If each of 4 threads or processes has its own cap of 8, your app is really running 32 requests at once.

Pace requests per endpoint, to the steady-state rate

Burst limits are enforced as a token bucket for each endpoint and HTTP method (see Rate limiting). Design your app around the steady-state rate, not the burst buffer. The burst buffer absorbs the odd spike, but relying on it for normal work leads to 429 responses.

A client-side token bucket does the same job as Jira's. Keep one bucket per endpoint and method, and take a token before each request:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
export class TokenBucket {
  private tokens: number;
  private lastRefill = Date.now();

  constructor(
    private readonly capacity: number,
    private readonly refillPerSecond: number,
  ) {
    this.tokens = capacity;
  }

  /** Resolves when a token is available, and consumes it. */
  async take(): Promise<void> {
    for (;;) {
      this.refill();
      if (this.tokens >= 1) {
        this.tokens -= 1;
        return;
      }
      const waitMs = ((1 - this.tokens) / this.refillPerSecond) * 1000;
      await sleep(Math.ceil(waitMs));
    }
  }

  private refill(): void {
    const now = Date.now();
    this.tokens = Math.min(
      this.capacity,
      this.tokens + ((now - this.lastRefill) / 1000) * this.refillPerSecond,
    );
    this.lastRefill = now;
  }
}

/** Default steady-state requests per second, by HTTP method. */
const DEFAULT_RPS: Record<string, number> = { GET: 100, POST: 100, PUT: 50, DELETE: 50 };

/**
 * Pace well below the limit, because other workers may share the same allowance.
 * Each EndpointPacer only paces its own process. If you run N processes, each with its own pacer,
 * together they send N times this rate, so divide the rate by N or share one pacer.
 */
const SAFETY_FACTOR = 0.5;

/**
 * Group requests the way Jira does: by method and endpoint, with ids removed.
 * Issue keys and numeric ids (three or more digits, so API versions like `3` are kept) become `{id}`.
 */
export function endpointKey(method: string, path: string): string {
  const template = path
    .split("?")[0]
    .split("/")
    .map((segment) => (/^\d{3,}$/.test(segment) || /^[A-Z][A-Z0-9_]*-\d+$/.test(segment) ? "{id}" : segment))
    .join("/");
  return `${method} ${template}`;
}

export class EndpointPacer {
  private readonly buckets = new Map<string, TokenBucket>();

  async take(method: string, path: string): Promise<void> {
    const key = endpointKey(method, path);
    let bucket = this.buckets.get(key);
    if (!bucket) {
      const rps = Math.max(1, (DEFAULT_RPS[method] ?? 50) * SAFETY_FACTOR);
      bucket = new TokenBucket(rps, rps);
      this.buckets.set(key, bucket);
    }
    await bucket.take();
  }
}

Some endpoints have custom limits that differ from the defaults. The defaults are a safe starting point, and the 429 handling below corrects for the rest. Don't hard-code per-endpoint values from the docs, because they can change.

The pacer is shared by every request that goes through the same client, so many concurrent callers draw from one bucket and not one each. That only holds inside one process. A second thread with its own memory (a Node.js worker thread, for example), or a second process, gets its own bucket and sends its own full rate. Keep the bucket below the limit for all of them together, as described in Running more than one thread, process, or instance.

Space out writes to the same issue

Per-issue limits allow 20 writes per 2 seconds and 100 writes per 30 seconds for a single issue. Updating one issue from many places at once is the usual way to hit this. Send writes to the same issue one at a time, with a gap between them:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/** Runs tasks for the same key one at a time, with a pause after each. */
export class PerKeySpacer {
  private readonly tails = new Map<string, Promise<void>>();

  run<T>(key: string, gapMs: number, task: () => Promise<T>): Promise<T> {
    const previous = this.tails.get(key) ?? Promise.resolve();
    const result = previous.then(task);
    const tail = result.then(
      () => sleep(gapMs),
      () => sleep(gapMs),
    );
    this.tails.set(key, tail);
    void tail.then(() => {
      if (this.tails.get(key) === tail) this.tails.delete(key);
    });
    return result;
  }
}

const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);

/** 350 ms between writes is about 2.9 per second, under both per-issue windows. */
export const PER_ISSUE_GAP_MS = 350;

/** Returns the issue key a write request targets, if the path has one. */
export function issueKeyForWrite(method: string, path: string): string | undefined {
  if (!WRITE_METHODS.has(method)) return undefined;
  return path.match(/\/issue\/([A-Z][A-Z0-9_]*-\d+|\d+)(?:\/|\?|$)/)?.[1];
}

The spacer only orders the writes that go through it. If several threads, processes, or instances can update the same issue, send those updates through one place, such as a queue keyed by issue, so they can't all write at once.

Better still, combine several field changes into one update request, and use bulk operations when you change many issues.

Watch your hourly quota

The hourly quota is measured in points, so plan around it: request only the fields you need, paginate, cache stable responses, and use webhooks instead of polling.

When your usage gets close to the quota, the Beta-RateLimit or RateLimit header includes r (remaining) and t (seconds until reset). You can spread what's left over the rest of the window. If nothing is left (r=0), stop sending requests until the window resets:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
export interface QuotaPacing {
  /** Which quota is running low: `global` is shared by all of your app's tenants, `tenant` is one tenant's. */
  quota: "global" | "tenant";
  /** How long to pause between requests. */
  ms: number;
}

/**
 * Returns how long to pause between requests to make the remaining quota last, and which quota it applies to,
 * or undefined if there's no pressure. If the quota is exhausted (`r` is 0), the pause is the time left in
 * the window, so requests stop until it resets.
 */
export function quotaPacing(headers: Headers): QuotaPacing | undefined {
  const entries = [
    ...parseRateLimitHeader(headers.get("RateLimit")),
    ...parseRateLimitHeader(headers.get("Beta-RateLimit")),
  ];
  const found = entries.find(
    (e) => (e.policy === "global-app-quota" || e.policy === "tenant-app-quota") && e.r !== undefined && e.t !== undefined,
  );
  if (!found || found.r === undefined || found.t === undefined) return undefined;
  const quota = found.policy === "global-app-quota" ? "global" : "tenant";
  // An exhausted quota is the highest pressure of all: pause for the rest of the window.
  if (found.r < 1) return { quota, ms: found.t * 1000 };
  // A request costs at least 1 point, so this is a conservative lower bound on the spacing.
  // r and t describe the whole shared quota, not your share of it. If N workers each use this value on
  // their own, together they spend the remainder N times too fast, so multiply by N or decide in one place.
  return { quota, ms: Math.ceil((found.t * 1000) / found.r) };
}

The r and t values describe the whole shared quota, not your thread's share. If several workers each space out their requests using this value on their own, together they use up what's left several times too fast. Either multiply the spacing by the number of workers, or work it out in one place that all workers share.

The function also reports which quota is running low. The global-app-quota is shared across all of your app's tenants, so a pause for it must apply to every tenant's client. The tenant-app-quota belongs to one tenant, so a pause for it must only apply to that tenant. The client below blocks the matching scope.

Part 2: Respond to limits correctly

Even a well-paced app will occasionally be limited. For example, another app, or another instance of yours, may be using the same endpoint. Your retry logic should follow these rules:

  1. Retry 429 responses. Retry a 503 only if it has a Retry-After header.

  2. Wait at least Retry-After. If it's missing, use X-RateLimit-Reset. If that's missing too, use exponential backoff.

  3. Add jitter, a small random delay, so clients that were limited together don't all retry together.

  4. Back off only the limited scope, using RateLimit-Reason.

    For burst limits (jira-burst-based), Retry-After is always 1. Don't retry every second. Treat that value as the shortest possible wait, and back off exponentially on each further 429. Then reduce the pressure that caused the limit: pace your requests to the steady-state rate, cap your concurrency, and spread your traffic over time (see Part 1).

  5. Cap the retries and how long you're willing to wait. Cap your own backoff, but never retry before Retry-After has passed. If the server asks for longer than you're willing to wait (for example, a quota 429 with Retry-After: 1847), return the response to the caller instead of retrying early, and reschedule the work for later.

  6. Only retry requests that are safe to repeat. GET, PUT, and DELETE are normally safe. Retrying a POST that creates something can create duplicates if the first attempt did succeed, so retry it only if you know it's safe.

  7. Treat concurrent 429s as one event, not many. When several requests are in flight, they usually all get the same 429 together. Don't let each thread run its own independent backoff, because they will wake together and hit the limit together. Record one shared block that every caller checks before it sends, as the code below does. Remember that a retry count belongs to one request, so a burst of 429s can use up the retries of every request that was in flight. Fail the job, not just the request, when that happens, and let the job try again later.

Work out how long to wait

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export function retryDelayMs(headers: Headers, now = Date.now()): number | undefined {
  const retryAfter = headers.get("Retry-After");
  if (retryAfter) {
    const seconds = Number(retryAfter);
    if (!Number.isNaN(seconds)) return Math.max(0, seconds * 1000);
    const date = Date.parse(retryAfter); // Retry-After can also be an HTTP date.
    if (!Number.isNaN(date)) return Math.max(0, date - now);
  }
  const reset = headers.get("X-RateLimit-Reset");
  if (reset) {
    const resetAt = Date.parse(reset);
    if (!Number.isNaN(resetAt)) return Math.max(0, resetAt - now);
  }
  return undefined;
}

/** Exponential backoff with "full jitter": a random delay up to an exponentially growing ceiling. */
export function backoffMs(attempt: number, baseMs = 2000, maxMs = 30_000): number {
  return Math.random() * Math.min(maxMs, baseMs * 2 ** attempt);
}

Remember which scope is blocked

When a 429 arrives, record which scope is blocked and until when. Every other request in your app checks this before it goes out. That way, concurrent callers all wait, and the limited scope recovers sooner because you stop adding load to it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/**
 * One shared record of what is blocked and until when. Every caller that shares this gate waits on the
 * same block, so a 429 seen by one request holds back all of them. A gate lives in one process's memory.
 */
export class BackoffGate {
  private readonly blockedUntil = new Map<string, number>();

  block(scope: string, ms: number): void {
    const until = Date.now() + ms;
    if (until > (this.blockedUntil.get(scope) ?? 0)) this.blockedUntil.set(scope, until);
  }

  /** Resolves once none of the given scopes is blocked. */
  async wait(...scopes: string[]): Promise<void> {
    for (;;) {
      const until = Math.max(0, ...scopes.map((s) => this.blockedUntil.get(s) ?? 0));
      const remaining = until - Date.now();
      if (remaining <= 0) return;
      await sleep(remaining);
    }
  }
}

export const GLOBAL_SCOPE = "global";

/** The scopes a request belongs to. Everything except the global quota is qualified by tenant. */
export interface Scopes {
  tenant: string;
  endpoint: string;
  issue?: string;
}

/** Chooses what to block, based on RateLimit-Reason. */
export function scopeForReason(reason: string | null, scopes: Scopes): string {
  switch (reason) {
    case "jira-quota-global-based":
      return GLOBAL_SCOPE; // Shared across all of your app's tenants. Pause everything.
    case "jira-quota-tenant-based":
      return scopes.tenant; // Pause everything for this tenant.
    case "jira-per-issue-on-write":
      return scopes.issue ?? scopes.endpoint;
    case "jira-burst-based":
    default:
      return scopes.endpoint; // Slow down only this endpoint, for this tenant.
  }
}

Put it together in one client

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
export interface JiraClientOptions {
  baseUrl: string;
  token: string;
  maxRetries?: number; // default 4
  maxDelayMs?: number; // default 60,000: the longest we'll wait before a retry
  retryNonIdempotent?: boolean; // default false
  fetchImpl?: typeof fetch; // for tests
  /**
   * Share one gate between clients to share their backoff. The global quota is shared across all of your
   * app's tenants, so clients for different tenants should use the same gate. Otherwise, each client
   * keeps its own gate.
   */
  gate?: BackoffGate;
}

const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE"]);

export class JiraClient {
  private readonly pacer = new EndpointPacer();
  private readonly gate: BackoffGate;
  private readonly spacer = new PerKeySpacer();

  constructor(private readonly options: JiraClientOptions) {
    this.gate = options.gate ?? new BackoffGate();
  }

  request(method: string, path: string, body?: unknown): Promise<Response> {
    const issueKey = issueKeyForWrite(method, path);
    const run = () => this.send(method, path, body, issueKey);
    // Writes to the same issue go one at a time, with a gap.
    return issueKey ? this.spacer.run(issueKey, PER_ISSUE_GAP_MS, run) : run();
  }

  private async send(method: string, path: string, body: unknown, issueKey?: string): Promise<Response> {
    const { baseUrl, token, maxRetries = 4, maxDelayMs = 60_000, retryNonIdempotent = false } = this.options;
    const doFetch = this.options.fetchImpl ?? fetch;
    const endpoint = endpointKey(method, path);
    const canRetry = IDEMPOTENT.has(method) || retryNonIdempotent;
    // Qualify scopes by tenant, so a gate that is shared between clients doesn't mix tenants up.
    const scopes: Scopes = {
      tenant: `tenant:${baseUrl}`,
      endpoint: `${baseUrl} ${endpoint}`,
      issue: issueKey ? `${baseUrl} issue:${issueKey}` : undefined,
    };

    for (let attempt = 0; ; attempt++) {
      // 1. Wait out any block on the global quota, this tenant, this endpoint, or this issue.
      await this.gate.wait(GLOBAL_SCOPE, scopes.tenant, scopes.endpoint, ...(scopes.issue ? [scopes.issue] : []));
      // 2. Pace ourselves to the steady-state rate.
      await this.pacer.take(method, path);

      const res = await doFetch(`${baseUrl}${path}`, {
        method,
        headers: {
          Authorization: `Bearer ${token}`,
          Accept: "application/json",
          ...(body !== undefined && { "Content-Type": "application/json" }),
        },
        body: body === undefined ? undefined : JSON.stringify(body),
      });

      // 3. If the hourly quota is running low, slow everything down a little.
      const pacing = quotaPacing(res.headers);
      if (pacing) this.gate.block(pacing.quota === "global" ? GLOBAL_SCOPE : scopes.tenant, pacing.ms);

      const serverDelay = retryDelayMs(res.headers);
      const limited = res.status === 429 || (res.status === 503 && serverDelay !== undefined);
      if (!limited) return res;

      const reason = res.headers.get("RateLimit-Reason");

      // 4. Treat Retry-After as a minimum, never as the whole answer. Burst limits always return 1 second,
      //    so waiting only that long on every attempt would retry once a second. Back off exponentially
      //    instead, and add jitter to avoid synchronized retries. Only the backoff is capped.
      const backoff = Math.min(maxDelayMs, backoffMs(attempt));
      const delay = serverDelay !== undefined ? Math.max(serverDelay + Math.random() * 500, backoff) : backoff;

      // 5. Record the block for the scope that was limited, even if this request won't be retried. The
      //    limit still applies to every other caller, so they must see it before they send.
      this.gate.block(scopeForReason(reason, scopes), delay);

      // 6. Only the retry itself depends on whether the request can be repeated and how long we'll wait.
      //    Never retry before Retry-After: if the server asks for longer than we're willing to wait,
      //    return the response to the caller instead.
      const tooLong = serverDelay !== undefined && serverDelay > maxDelayMs;
      if (!canRetry || attempt >= maxRetries || tooLong) return res;

      console.warn(
        `Rate limited (${res.status}, reason=${reason ?? "unknown"}) on ${endpoint}; ` +
          `retry ${attempt + 1}/${maxRetries} in ${Math.round(delay)} ms`,
      );

      await res.body?.cancel(); // Release the connection before we wait.
    }
  }
}

The client does the following for every request:

  1. It waits if the global quota, the tenant, the endpoint, or the issue is currently blocked.
  2. It takes a token from the endpoint's bucket, which keeps the steady-state rate.
  3. It sends the request, and slows down if the response says the hourly quota is low.
  4. On a 429, or a 503 with a delay header, it works out the delay. It waits at least Retry-After, and longer as the retries add up. Only its own backoff is capped.
  5. It blocks only the scope named by RateLimit-Reason, even when it won't retry this request, so other callers still see the limit.
  6. It then retries, unless the request isn't safe to repeat, it has run out of retries, or Retry-After is longer than maxDelayMs. In those cases it returns the response to the caller instead of retrying early. Otherwise it loops back to step 1.

Use it

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const jira = new JiraClient({
  baseUrl: "https://your-domain.atlassian.net",
  token: process.env.JIRA_TOKEN!,
});

async function getIssue(key: string): Promise<unknown> {
  const res = await jira.request("GET", `/rest/api/3/issue/${key}?fields=summary,status`);
  if (!res.ok) throw new Error(`Jira responded ${res.status}: ${await res.text()}`);
  return res.json();
}

// Read many issues politely: at most 8 in flight, paced per endpoint.
const keys = ["PROJ-1", "PROJ-2", "PROJ-3"];
const issues = await mapWithConcurrency(keys, 8, getIssue);

// Several updates to one issue are spaced out for you.
await jira.request("PUT", "/rest/api/3/issue/PROJ-1", { fields: { labels: ["reviewed"] } });

Running more than one thread, process, or instance

Adding workers doesn't add throughput, because the limits are shared (see Limits are shared, not per thread). The simplest design is to send all requests for a tenant through one client, in one place. Within a single Node.js process, that's already true: every async caller shares the client's pacer, gate, and spacer.

The JiraClient above keeps its state in the memory of one process. A Node.js worker thread, a cluster worker, or another instance of your app has its own memory, so it has its own pacer, its own gate, and its own idea of how much allowance is left. Left alone, each of them sends a full share of traffic, and none of them knows when another has been limited. If your app does run in more than one place, make them coordinate:

  • Share the blocked-until state, for example in Redis. When one worker gets a 429, the others should pause that scope too. BackoffGate has only block and wait, so you can write a version that stores its state in a shared store and pass it in with the gate option.
  • Divide the request budget between workers, so that their combined rate is still under the steady-state rate. For example, with 4 workers, give each a quarter of the rate.
  • Share one gate between tenants only for the global quota. The burst, per-issue, and tenant quota limits belong to one tenant, so a 429 for one tenant mustn't slow down another. That's why the client qualifies those scopes by tenant. A jira-quota-global-based response applies to your app as a whole, so the clients for all of your tenants should share one gate, and it's then correct for them to pause together.
  • Don't count a 429 as a signal to add workers. More workers makes limiting worse. If you need more throughput than the limits allow, reduce the number of requests instead (see Optimizing API usage).
  • Spread out scheduled jobs. Add random jitter to start times, so that many instances don't all start on the hour.

Test your handling without hitting Jira

Don't test rate limit handling against a real Atlassian cloud tenant, as it can affect customers. This is a requirement, not just a recommendation. It's part of the Testing and compliance policy on the Rate limiting page, which also asks you to follow the Atlassian Acceptable Use Policy. Instead, give the client a fake fetch that returns the responses you want. The fetchImpl option exists for this.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import assert from "node:assert/strict";
import { test } from "node:test";

test("waits at least Retry-After after a burst limit, then succeeds", async () => {
  const responses = [
    new Response(null, {
      status: 429,
      headers: { "Retry-After": "1", "RateLimit-Reason": "jira-burst-based" },
    }),
    new Response('{"ok":true}', { status: 200 }),
  ];
  const sentAt: number[] = [];

  const client = new JiraClient({
    baseUrl: "https://example.test",
    token: "test",
    fetchImpl: async () => {
      sentAt.push(Date.now());
      return responses[sentAt.length - 1];
    },
  });

  const res = await client.request("GET", "/rest/api/3/issue/PROJ-1");

  assert.equal(res.status, 200);
  assert.equal(sentAt.length, 2);
  // The retry must not be sent before the 1 second the server asked for has passed.
  assert.ok(sentAt[1] - sentAt[0] >= 1000, `retried after only ${sentAt[1] - sentAt[0]} ms`);
});

It's also worth testing these cases:

  • A 429 with no Retry-After falls back to backoff.
  • A 429 with RateLimit-Reason: jira-burst-based doesn't delay requests to other endpoints.
  • Several concurrent requests that all get the same 429 wait on one shared block, and don't each back off separately.
  • With a gate shared by two tenants' clients, a burst 429 for one tenant doesn't delay the other, but a jira-quota-global-based one does.
  • A 429 with jira-quota-global-based pauses all requests.
  • A RateLimit header with "global-app-quota";r=0 pauses every tenant's client that shares the gate, but "tenant-app-quota";r=0 only pauses that tenant's client.
  • Retries stop after maxRetries, and the last 429 response is returned.
  • A 429 with a Retry-After longer than maxDelayMs is returned straight away, and isn't retried early.
  • A 429 for a request that isn't retried (a POST with the default options, or one that has used all its retries) still blocks its scope for other callers.

Checklist

  • Concurrency is capped.
  • Requests are paced per endpoint and method, to the steady-state rate and not to the burst buffer.
  • Writes to one issue are spaced out, and combined where possible.
  • 429 responses are retried using Retry-After, with jitter and a retry limit.
  • Only the scope named in RateLimit-Reason is backed off, and a quota 429 pauses everything.
  • Non-idempotent requests aren't retried unless it's safe.
  • Your concurrency cap and request rate apply to the whole app, not to each thread or process.
  • Concurrent 429s are handled as one event, with a shared block that every caller waits on.
  • All threads, processes, and instances of your app share their rate limit state, or split the budget between them.
  • You don't add workers to get more throughput, because the limits are shared.
  • Rate limit handling is tested with a fake fetch, not a real tenant.

Next steps

Rate this page: