Gaggle Logo
Gaggle Developers

Rate limits and errors

Rate limits

Limits are counted per minute:

LimitPer minute
Your app, across all pilots600 requests
Your app for one pilot60 requests

The limits are approximate. When you go over, the API answers 429 with a Retry-After header (seconds):

HTTP/1.1 429 Too Many Requests
Retry-After: 12
Content-Type: application/json

{"error":"rate_limited"}

Retry-After is currently 60 seconds. Wait that long, then retry. Do not retry in a tight loop. Back off if you are still limited.

First-time track downloads are throttled separately from the limits above, so a backfill cannot flood Gaggle. If you ask for too many tracks that Gaggle has not served before, track.gpx answers 429 with Retry-After. Spread backfills out over time, and prefer webhooks to polling. Tracks you have downloaded before are not throttled this way.

Staying well below the limits

  • Use webhooks, then fetch only the flight that changed.
  • Sync incrementally with updatedSince instead of listing everything.
  • Page with limit=100.
  • Cache with ETags (below). A 304 is cheap for everyone.
  • Download a flight’s track once. Tracks of finished flights do not change unless the flight is edited.

ETags

Every GET response has an ETag. Keep it with the cached copy and send it back exactly as you received it (quotes included, a single value):

curl -i https://connect.flygaggle.com/v1/stats \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'If-None-Match: "a1b2c3"'

If nothing changed you get 304 Not Modified with no body, and you keep your copy. Otherwise you get 200 with a new ETag.

async function getJson(url, accessToken, cache) {
  const headers = { Authorization: `Bearer ${accessToken}` };
  const cached = cache.get(url);
  if (cached) headers["If-None-Match"] = cached.etag;

  const res = await fetch(url, { headers });
  if (res.status === 304) return cached.body;
  if (!res.ok) throw new Error(`Gaggle answered ${res.status}`);

  const body = await res.json();
  cache.set(url, { etag: res.headers.get("etag"), body });
  return body;
}

Errors

Errors from the API use this shape. message and scope are only present when they apply.

{ "error": "insufficient_scope", "scope": "tracks" }
StatuserrorMeaningWhat to do
400invalid_requestA parameter is missing or invalidFix the request
401invalid_tokenThe access token is missing, expired or revoked. A response without a token has an empty body and a WWW-Authenticate header.Refresh the token. If that fails, the pilot must connect again.
403insufficient_scopeThe pilot has not enabled the scope in scopeRead /v1/me. Do not ask again; the pilot can switch it on later.
403provider_disabledGaggle has switched your app offContact us
404not_foundNo such resource, or it is not visible to youThe flight may be deleted, at a sharing level you cannot see, or outside the history window. For track.gpx it may also have no track.
405invalid_requestAnything other than GET or HEADUse GET
429rate_limitedToo many requests, or too many first-time track downloadsWait Retry-After seconds
502bridge_unavailableA track could not be fetched right nowRetry shortly

The OAuth endpoints (/oauth/token, /oauth/revoke) use the standard OAuth error shape instead: {"error": "invalid_grant", "error_description": "..."}.

Handling 401

async function gaggleFetch(path, session) {
  let res = await fetch(`https://connect.flygaggle.com${path}`, {
    headers: { Authorization: `Bearer ${session.accessToken}` },
  });
  if (res.status === 401) {
    await refreshTokens(session); // POST /oauth/token with grant_type=refresh_token; save the NEW refresh token
    res = await fetch(`https://connect.flygaggle.com${path}`, {
      headers: { Authorization: `Bearer ${session.accessToken}` },
    });
  }
  return res;
}

Handling 403 insufficient_scope

The pilot can switch a permission off at any time and a connection.updated webhook tells you when they do. Stop calling the endpoint that needs it, tell the pilot in your own product what is missing, and carry on with what is still allowed.