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:
- Honour
Retry-Afterif 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. - Back off exponentially. Double the wait each attempt, from a base of about a second, up to a ceiling of a minute or so.
- 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.
- Cap the attempts. Five or so, then fail the job with a clear message. Retrying forever turns a transient limit into an outage.
- 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-bodystreaming to a pipe emits every attempt's body — two 429 envelopes glued to the front of the success body, which is exactly what yourjqwill choke on.--output(or plain--fail, which discards error bodies) keeps the output to the final response. - Leave
--retry-delayoff. Without it curl doubles its wait from one second; with it curl uses that fixed interval instead. Either way aRetry-Afterfrom 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=1000is 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¶
- Errors: the full status and code taxonomy, including the 429 bodies.
- API conventions: pagination and retry semantics.
- API surface map: which surface an operation lives on, and why 429 is not declared per operation.
- Account & data management: the export limits in context.
- CI uploads: a well-behaved automated client.