Skip to content

Rate limits

AppGantry rate-limits API traffic. This page tells you how to build a client that copes with it.

What is enforced, and where

Throttling happens in two places, and a client has to cope with both.

At the edge, in front of the API. Requests that exceed a limit are rejected before they reach application code, so the response may not carry the AppGantry error envelope at all.

Inside the application, per resource rather than per caller. These are deliberate product limits, not traffic shaping. Most of them refuse the request with 429 and the normal envelope, carrying error: "rate_limit_exceeded":

Limit Applies to
One send per pending invitation every ~5 minutes, counting the original Re-sending an organization or project member invitation
One send per pending tester grant every ~5 minutes, counting the one sent on add Re-notifying a pending organization or project tester
One new data export per 24 hours, and at most 5 per rolling 30 days Requesting a personal data export. The refusal carries a structured details explaining the refusal and your rights
20 downloads per ready export archive Downloading a personal data export
25 uploads in flight per project Initiating a build upload. The ceiling counts pending uploads that have been started and not yet completed, so it clears as they complete or expire

Two more product limits belong in the same family but do not answer 429, which is why they sit in their own table: a client that treats every product limit as throttling retries a billing refusal it can never clear, and never learns that an email it asked for was not sent.

Limit Applies to What you actually get
One transactional email of each kind per recipient every ~5 minutes Signup verification, password-reset and email-change verification email, each on a cooldown of its own A success. The second request is absorbed silently rather than refused, because answering "too soon" would confirm whether an address is on file. Nothing is sent, and the link already in the inbox stays valid
Storage quota and byte-movement limits Uploads and downloads, which draw on your balance and spend cap 402, not 429 — a cap is a billing gate, not a rate limit. The size ceiling around an upload is different again: 413 payload_too_large

See Errors → 429 for the bodies these produce and Account & data management for the export limits in context.

The edge thresholds are not published. They vary by route class and are adjusted in response to traffic and abuse patterns, so any number printed here would be wrong within a release. Build a client that reacts to a 429 rather than one tuned to a specific rate.

Broadly, unauthenticated and credential-sensitive routes — sign-in, sign-up, password reset, token exchange — are limited far more tightly than authenticated read traffic. Assume authentication endpoints have very little headroom.

429 is not declared per operation in the schema

No operation in the published OpenAPI schema lists a 429 response. That is a gap in the schema, not a promise: any operation can answer 429, from either of the two places above. Switch on the status class rather than looking a 429 up per operation. See API surface map → Rate limiting and 429.

Recognising a rate limit

Two things can produce HTTP 429:

  • The edge. The response may not use the AppGantry error envelope, because it never reached the API. Don't assume you can parse {error, message, details} out of a 429.
  • The API, which uses the standard envelope with error: "rate_limit_exceeded".

Handle both: switch on the status code first and only try to parse a body if the content type is JSON.

Handling 429

if status == 429:
    wait = retry_after_header or (base * 2**attempt)
    wait = wait * random_between(0.5, 1.5)     # jitter
    sleep(min(wait, ceiling))
    retry(up to a bounded number of attempts)

Concretely:

  1. Honour Retry-After if it is present. It may be a number of seconds or an HTTP date. If it is absent — and it often is — fall back to your own backoff.
  2. Back off exponentially. Double the wait each attempt, from a base of about a second, up to a ceiling of a minute or so.
  3. Add jitter. Multiply the delay by a random factor around 1. Without jitter, a fleet of CI runners that all got limited at the same moment will all retry at the same moment.
  4. Cap the attempts. Five or so, then fail the job with a clear message. Retrying forever turns a transient limit into an outage.
  5. Never retry in a tight loop. That is what gets an account flagged.

Don't assume Retry-After is always there

Only some responses carry it: the canonical list is Errors → Retry-After and other error headers. A 429 may or may not carry one depending on where it was produced. Treat the header as an optimisation, not a requirement, and always have your own backoff.

A worked example

The whole recipe in one place. It reads the status first, only parses a body when the response is JSON, honours Retry-After when it is present, and falls back to jittered exponential backoff when it is not:

import random
import time

import httpx

BASE_DELAY_SECONDS = 1.0
MAX_DELAY_SECONDS = 60.0
MAX_ATTEMPTS = 5


def _retry_delay(response: httpx.Response, attempt: int) -> float:
    """How long to wait before retrying a 429, in seconds."""
    header = response.headers.get("Retry-After")
    if header and header.isdigit():
        return min(float(header), MAX_DELAY_SECONDS)
    # No header (or an HTTP-date this example doesn't parse): back off
    # exponentially, with jitter so a fleet doesn't retry in lockstep.
    backoff = BASE_DELAY_SECONDS * 2**attempt
    return min(backoff, MAX_DELAY_SECONDS) * random.uniform(0.5, 1.5)


def get_with_backoff(client: httpx.Client, url: str) -> httpx.Response:
    """GET url, retrying a bounded number of times on 429."""
    for attempt in range(MAX_ATTEMPTS):
        response = client.get(url)
        if response.status_code != 429:
            return response
        # The edge can answer 429 without the AppGantry envelope, so the
        # body is read defensively and only for logging.
        if response.headers.get("Content-Type", "").startswith("application/json"):
            code = response.json().get("error", "rate_limit_exceeded")
        else:
            code = "rate_limit_exceeded"
        if attempt == MAX_ATTEMPTS - 1:
            break
        print(f"{code}: retrying in a moment (attempt {attempt + 1})")
        time.sleep(_retry_delay(response, attempt))
    raise RuntimeError(f"Still rate limited after {MAX_ATTEMPTS} attempts")

The same shape works from a shell. Since curl 7.66, --retry treats a 429 as a retryable error and honours a Retry-After header when the response carries one, falling back to its own doubling backoff when it does not:

curl --fail-with-body --show-error --silent \
  --retry 5 --retry-max-time 120 \
  --output response.json \
  -H "Content-Type: application/json" \
  "https://api.appgantry.com/api/v1/builds?organization_id=${APPGANTRY_ORG_ID}&project_id=${APPGANTRY_PROJECT_ID}"

Two details that bite when you compress this:

  • Write to a file, not to a pipe. curl cannot rewind stdout between attempts, so --fail-with-body streaming to a pipe emits every attempt's body — two 429 envelopes glued to the front of the success body, which is exactly what your jq will choke on. --output (or plain --fail, which discards error bodies) keeps the output to the final response.
  • Leave --retry-delay off. Without it curl doubles its wait from one second; with it curl uses that fixed interval instead. Either way a Retry-After from the server wins.

Add your Authorization header to that call the same way the CI guide does.

Designing to avoid limits

Most rate-limit trouble is avoidable:

  • Don't poll for what a webhook can tell you. Subscribe to the build and release events instead of polling a list endpoint on a timer. Delivery is best-effort, so reconcile on a wide interval rather than a tight loop if a missed event is expensive.
  • Ask for bigger pages. One request with ?limit=1000 is far cheaper than ten with the default 100.
  • Cache what doesn't change. Project identifiers, channel identifiers, and organization identifiers are stable. Don't re-look them up on every CI run — put them in your CI configuration.
  • Reuse credentials. A project access token authenticates directly. Don't mint a fresh token per job.
  • Serialise CI fan-out. If fifty pipelines upload at once, stagger them.
  • Retry authentication failures at most once. A retry storm against sign-in looks exactly like credential stuffing and is limited hardest.

Uploads and downloads

Artifact bytes travel directly to and from storage over signed URLs, not through the API. A 500 MiB upload is one API call to initiate, one transfer to storage, and one API call to complete — the transfer itself doesn't consume API rate budget.

That's a good reason to use the two-phase upload exactly as documented rather than trying to route bytes through anything else.

It does mean the transfer answers to the storage provider, not to AppGantry: the PUT and the download can be throttled or refused by Azure with Azure's own status codes and headers, which are not the AppGantry error envelope. Treat a non-2xx from a signed URL as a storage error, retry it against a freshly initiated upload rather than the dead URL, and remember the ceilings that surround the transfer are a 429 rate_limit_exceeded on how many uploads a project may hold in flight and a 413 payload_too_large on the artifact's size — see Feature limits.

See also