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:
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.
Jira Cloud applies three independent limits at the same time. Each one has a different scope, so each needs a different response.
RateLimit-Reason | Limit | Scope | What your app should do |
|---|---|---|---|
jira-burst-based | Burst API rate limit (per second) | One tenant, one endpoint, one HTTP method | Slow down requests to that endpoint only. |
jira-per-issue-on-write | Per-issue write limit | One issue | Space out writes to that issue. Other requests are unaffected. |
jira-quota-global-based or jira-quota-tenant-based | Points-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.
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.
Every response can carry rate limit headers. Your app needs to understand these:
| Header | Present on | What to do with it |
|---|---|---|
RateLimit-Reason | 429 only | Decide 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-After | 429 responses, and some transient 503 responses | Wait 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-Reset | 429 only | An ISO 8601 time when the window resets. Use it only if Retry-After is missing. |
X-RateLimit-Remaining | Rate limited endpoints | How much request capacity is left in the current second. |
RateLimit-Policy and RateLimit | When a limit is enforced | Structured headers describing the policy and your current state. |
Beta-RateLimit-Policy and Beta-RateLimit | During the beta period | Same 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 4RateLimit-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.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:
, .-, and _.;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 24export 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.
The best 429 is the one that never happens. Four techniques keep your app under its limits.
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 21export 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.
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 72export 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.
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.
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 30export 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.
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:
Retry 429 responses. Retry a 503 only if it has a Retry-After header.
Wait at least Retry-After. If it's missing, use X-RateLimit-Reset. If that's missing too, use exponential backoff.
Add jitter, a small random delay, so clients that were limited together don't all retry together.
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).
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.
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.
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21export 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); }
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. } }
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 97export 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:
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.RateLimit-Reason, even when it won't retry this request, so other callers still see the limit.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.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18const 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"] } });
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:
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.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.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).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 30import 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:
429 with no Retry-After falls back to backoff.429 with RateLimit-Reason: jira-burst-based doesn't delay requests to other endpoints.429 wait on one shared block, and don't each back off separately.429 for one tenant doesn't delay the other, but a jira-quota-global-based one does.429 with jira-quota-global-based pauses all requests.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.maxRetries, and the last 429 response is returned.429 with a Retry-After longer than maxDelayMs is returned straight away, and isn't retried early.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.429 responses are retried using Retry-After, with jitter and a retry limit.RateLimit-Reason is backed off, and a quota 429 pauses everything.429s are handled as one event, with a shared block that every caller waits on.fetch, not a real tenant.Rate this page: