# Rate limits

> How many requests each key, player and address may send per period, what a 429 looks like, and how to stay under the limits.

## The limits

Each limit counts requests in a fixed window. The window starts at the top of the period (the top of the minute or of the hour) and the count restarts when it ends.

| What is counted | Limit | Window |
|---|---|---|
| Server API, per **secret key** | 600 requests | 1 minute |
| Client API with a player token, per **player** | 120 requests | 1 minute |
| Client API without a token (catalog, token exchange), per **IP address** | 120 requests | 1 minute |
| Creating an anonymous player, per **IP address** | 30 requests | 1 hour |
| Gift code attempts (right or wrong), per **player** | 10 attempts | 15 minutes |
| Gift code attempts, client API, per **IP address** | 30 attempts | 15 minutes |
| Creator codes typed at checkout, per **player** | 30 attempts | 15 minutes |
| Creator codes typed at checkout, client API, per **IP address** | 60 attempts | 15 minutes |

Two keys do not share a counter, and neither do two players. A busy player cannot slow down the others.

The OpenAPI document (`/api/v1/openapi.json`) is public and has no rate limit.

## When you go over

The request is refused with `429 RATE_LIMITED` and nothing changes. The `Retry-After` header holds the number of seconds to wait.

```http title="A 429 answer"
HTTP/1.1 429 Too Many Requests
Retry-After: 33
Cache-Control: no-store
Content-Type: application/json; charset=utf-8

{"error":{"code":"RATE_LIMITED","message":"Too many requests"}}
```

The order of checks matters: a request is checked for authentication first, then the rate limit, then the idempotency key, then the body. A refused request still counts.

## Staying under the limits

- **Do not poll.** Read a balance when something may have changed, not on a timer. The browser SDK keeps a cached copy: `gc.balance("gems")` costs no request, and `change` events tell you when it moves.
- **Cache the catalog.** It changes when you edit it, not on every frame.
- **Use one token per player session**, and renew it only when it expires (once an hour).
- **Spread bulk work.** A job that grants rewards to thousands of players should pace itself under 600 requests a minute, with a small queue.

## Retrying a 429

Wait for `Retry-After`, then send the same request again. If it is a write, send it with the **same idempotency key**: the retry cannot count twice.

The SDKs do it for you. The server SDKs retry a `429` after `Retry-After` (up to 30 seconds), up to two times by default. The browser SDK waits up to 10 seconds; for a longer wait it stops and raises `RATE_LIMITED`, with the seconds in `error.details.retryAfter`, so a game never freezes.

<!-- tabs:start -->
```javascript tab="Node.js"
import { ErrorCode, GameCoinError } from "@apilow/gamecoin";

try {
  await gamecoin.catalog.get();
} catch (error) {
  // The SDK already waited and retried; this is what is left.
  if (error instanceof GameCoinError && error.code === ErrorCode.RATE_LIMITED) {
    console.log(`still limited: try again in ${error.retryAfter} seconds`);
  } else {
    throw error;
  }
}
```
```python tab="Python"
from gamecoin import ErrorCode, GameCoinError

try:
    gamecoin.catalog.get()
except GameCoinError as error:
    # The SDK already waited and retried; this is what is left.
    if error.code == ErrorCode.RATE_LIMITED:
        print(f"still limited: try again in {error.retry_after} seconds")
    else:
        raise
```
```php tab="PHP"
use Apilow\GameCoin\ErrorCode;
use Apilow\GameCoin\GameCoinException;

try {
    $gamecoin->catalog->get();
} catch (GameCoinException $e) {
    // The SDK already waited and retried; this is what is left.
    if ($e->errorCode === ErrorCode::RATE_LIMITED) {
        echo "still limited: try again in {$e->retryAfter} seconds\n";
    } else {
        throw $e;
    }
}
```
```go tab="Go"
_, err = client.Catalog.Get(ctx)
var gcErr *gamecoin.Error
switch {
case errors.As(err, &gcErr) && gcErr.Code == gamecoin.CodeRateLimited:
	// The SDK already waited and retried; this is what is left.
	fmt.Printf("still limited: try again in %.0f seconds\n", gcErr.RetryAfter.Seconds())
case err != nil:
	log.Fatal(err)
}
```
```csharp tab="C#"
using Apilow.GameCoin;

try
{
    await gamecoin.Catalog.GetAsync();
}
catch (GameCoinException ex) when (ex.Code == GameCoinErrorCodes.RateLimited)
{
    // The SDK already waited and retried; this is what is left.
    Console.WriteLine($"still limited: try again in {ex.RetryAfter?.TotalSeconds} seconds");
}
```
```javascript tab="Browser"
try {
  await gc.refresh();
} catch (error) {
  if (error.code === "RATE_LIMITED") {
    console.log(`try again in ${error.details.retryAfter} seconds`);
  } else {
    throw error;
  }
}
```
<!-- tabs:end -->

## Where next

- [Idempotency](/docs/concepts/idempotency): how to retry a write safely.
- [Errors](/docs/concepts/errors)
