Skip to content

Rate Limits

C.O.S. API v1


Limits at a glance

Traffic type Window Limit Scope
Authenticated 60 seconds 60 requests Per organisation
Unauthenticated (/health, /version) 60 seconds 60 requests Global (all callers)

How it works

C.O.S. uses a fixed-window counter backed by Redis. Each window is a UTC calendar minute (:00 to :59).

  • Authenticated — A counter keyed on your org_id increments on every request. Once it hits 60 the API returns 429 for the rest of that minute. The counter resets at the start of the next minute.

  • Unauthenticated — A single global counter across all callers increments on every request to /v1/health and /v1/version. The global limit protects the service from unauthenticated flood traffic.

Fail-open. If Redis becomes unavailable, the counter check is skipped and requests are allowed through rather than returning 429 responses. A WARNING is logged server-side. During a Redis outage, rate limits are temporarily unenforced — callers will not receive 429 errors even if they exceed the normal threshold.


Fixed-window burst note

Because the window is calendar-aligned rather than rolling, up to 2× the stated limit can be served in a short period spanning a window boundary. For example: 60 requests in the last second of minute N, and 60 requests in the first second of minute N+1, are each counted as 60 in their own windows and are both allowed. This is an accepted characteristic of the current implementation.


Response headers

Every response includes:

Header Description
X-RateLimit-Limit Your limit for this window
X-RateLimit-Remaining Requests remaining in the current window
X-RateLimit-Reset UTC epoch seconds when the current window ends

On a 429 response only:

Header Description
Retry-After Seconds until the next window starts

429 response body

Rate limit errors use RFC 7807 Problem Details with content type application/problem+json:

{
  "type": "https://cosprotocol.io/errors/rate-limit-exceeded",
  "title": "Rate Limit Exceeded",
  "status": 429,
  "detail": "Organisation rate limit exceeded. Retry after 1 second.",
  "cos_error_code": "RATE_LIMIT_EXCEEDED",
  "retry_after": 1,
  "instance": "/v1/organisations/7c9e6679-7425-40de-944b-e07fc1f90ae7"
}

The retry_after field duplicates the Retry-After header value.


Retry guidance

When you receive a 429:

  1. Read the Retry-After header (or retry_after in the body).
  2. Wait that many seconds before retrying.
  3. Do not retry immediately — requests without waiting will continue to be rejected for the rest of the window.

Python:

import time
import requests

def get_with_retry(url, headers, max_retries=3):
    for attempt in range(max_retries):
        resp = requests.get(url, headers=headers)
        if resp.status_code != 429:
            return resp
        retry_after = int(resp.headers.get("Retry-After", 60))
        if attempt < max_retries - 1:
            time.sleep(retry_after)
    return resp  # return final 429 to caller

curl:

curl --retry 3 --retry-delay 2 \
  -H "Authorization: Bearer $COS_API_KEY" \
  https://cosprotocol.io/v1/organisations/$ORG_ID

Staying within the limit

At 60 requests per minute you have ~1 request per second sustained. If your integration iterates large result sets (e.g. all members across many organisations), add a short sleep between pages:

import time, requests

cursor = None
while True:
    params = {"cursor": cursor} if cursor else {}
    resp = requests.get(
        f"https://cosprotocol.io/v1/organisations/{ORG_ID}/members",
        headers={"Authorization": f"Bearer {API_KEY}"},
        params=params,
    )
    data = resp.json()
    for member in data["data"]:
        process(member)
    cursor = data["meta"].get("next_cursor")
    if not cursor:
        break
    time.sleep(1)  # one page per second stays within 60/min

Roadmap

BUG-005 tracks a planned improvement to add per-IP limiting for unauthenticated endpoints (Phase 60 hard deadline). When shipped, the global counter will be complemented by a per-source-IP counter. The limit values above will not decrease.