# Rate limiting

The Steam Web API allows **100 000 requests per API key per day**. The connector
enforces this via [`saloonphp/rate-limit-plugin`](https://github.com/saloonphp/rate-limit-plugin)
and throws `SteamRateLimitException` once the budget is spent — locally, before a
request leaves your process. The exception carries the `Limit` that tripped:

```php
use Fkrzski\SteamApiSdk\Exceptions\SteamRateLimitException;

try {
    $connector->send($request)->dto();
} catch (SteamRateLimitException $e) {
    $seconds = $e->limit->getRemainingSeconds();
}
```

The budget resets at 00:00 UTC whatever `date.timezone` says, so `getRemainingSeconds()`
counts down to the next midnight UTC. Valve documents no reset time; UTC is an assumption.

Every answer Steam sends counts — a `4xx`, `5xx` or `429` as much as a `200` — through
`send()`, `sendAsync()` and `pool()` alike. A request that got no answer counts nothing,
though Steam may still have billed one that timed out. Steam bills the budget to the key, so
by default a connector without one counts nothing locally, and neither does a
[request sent without the key](#requests-sent-without-the-key).

## After a 429

When Steam itself answers `429`, the connector raises the same exception and refuses every
request locally until Steam's `Retry-After` passes. Without the header it holds them back for
`tooManyRequestsCooldown` seconds, 60 unless you set it:

```php title="steam.php" "tooManyRequestsCooldown: 300"
$connector = new SteamConnector(new SteamConfig(
    apiKey: $_ENV['STEAM_API_KEY'],
    tooManyRequestsCooldown: 300,
));
```

A `Retry-After` from Steam still wins. The cool-down applies with a key or without and
whatever `rateLimits` holds, `[]` included, and it lives in the store next to the budget, per
key, with requests sent without the key locked apart. A value under 1 throws
`InvalidRateLimitException` as the config is built.

<Aside type="caution" title="A pool cannot recall what it sent">
Requests a `pool()` already has in flight when a `429` arrives still reach Steam. Only the
ones it starts afterwards are refused locally.
</Aside>

## Your own limits

`rateLimits` swaps the daily budget for your own `Limit`s — a key with a bigger budget, a
share of one that several apps use, a window per second:

```php title="steam.php" "rateLimits:"
use Saloon\RateLimitPlugin\Limit;

$connector = new SteamConnector(new SteamConfig(
    apiKey: $_ENV['STEAM_API_KEY'],
    rateLimits: [
        Limit::allow(500_000)
            ->everySeconds(86_400, 'utc_midnight')
            ->setExpiryTimestamp(new DateTimeImmutable('tomorrow', new DateTimeZone('UTC'))->getTimestamp()),
        Limit::allow(10)->everySeconds(1)->sleep(),
    ],
));
```

The list replaces the default whole, so a per-second limit alone leaves the day unmetered.
The first line keeps a daily budget that resets at 00:00 UTC, like the default. `[]` meters
nothing locally, and a `429` from Steam still throws either way.

The limits apply without an API key too, and still count per key, so connectors sharing a
store don't drain each other; requests sent without the key
[count them apart](#requests-sent-without-the-key). `->sleep()` waits for the window to pass
instead of throwing, blocking the process meanwhile, in `pool()` as well. Two limits with one
name throw `InvalidRateLimitException` as the config is built; give one of them a `->name()`.

<Aside type="caution" title="A window counted to a moment freezes with the config">
`setExpiryTimestamp()`, `everySeconds()` fed with `time()`, `untilMidnightTonight()` and
`untilEndOfMonth()` fix their moment when the `Limit` is built. In a process that keeps one
connector for hours, build the config per job, or each window runs past the moment it was
meant to end.
</Aside>

## Checking before you send

The plugin's own methods read the store without sending anything, for a job that would
rather wait than fail:

```php "0.9"
if ($connector->hasReachedRateLimit(0.9)) {
    $seconds = $connector->getExceededLimit(0.9)->getRemainingSeconds();
}
```

The argument is the share of a limit already spent, so `0.9` answers once 90 000 of the
100 000 are gone; without it a limit has to be spent whole. Both see the lock after a `429` too.
They read the key's counters; for requests sent without it, ask a connector built without a key
on the same store.

`$connector->useRateLimitPlugin(false)` turns the limiter off altogether: nothing is counted
or refused locally, and a `429` surfaces as `SteamApiException` with code `429` rather than
`SteamRateLimitException`. To stop counting but keep the lock, pass `rateLimits: []` instead.

## Multiple API keys

The budget is tracked per API key, so one process — or one shared store — can serve
several keys without them draining each other's quota.

<Aside type="tip" title="Your key never reaches the store">
The limiter key is `SteamConnector:` plus a **SHA-256 hash** of the API key, so a shared
store holds counters, never credentials.
</Aside>

## Requests sent without the key

Steam serves some methods anonymously, `stats()->currentPlayers()` among them, so the connector
sends them without the key, like any request marked
[`SendsNoApiKey`](/php-steam-api-sdk/custom-requests#the-api-key). Steam bills them to no key,
so the connector limits them as a connector built without one would: they never touch the daily
budget, go through while the key is spent or locked, and a `429` on one of them locks only
requests without the key, under `SteamConnector:anonymous`.

`rateLimits` count them apart too, so a window per second lets that many through with the key
and as many again without it.

## Sharing the budget across processes

The default `MemoryStore` keeps its backing array static, so the budget is process-wide.
That is fine for a script, but every FPM or queue worker then gets a counter of its own
and the real quota can be overrun several times over. In tests, a new `SteamFake`
[clears it](/php-steam-api-sdk/testing#rate-limits-in-tests).

Inject a shared store instead — any `Saloon\RateLimitPlugin\Contracts\RateLimitStore`
is accepted:

```php
$connector = new SteamConnector(new SteamConfig(
    apiKey: $_ENV['STEAM_API_KEY'],
    rateLimitStore: $store,
));
```

<Tabs syncKey="steam-rate-limit-store">
  <TabItem label="Redis (Predis)">
    ```php
    use Saloon\RateLimitPlugin\Stores\PredisStore;

    $store = new PredisStore(new Predis\Client());
    ```
  </TabItem>
  <TabItem label="Redis (ext-redis)">
    ```php
    use Saloon\RateLimitPlugin\Stores\RedisStore;

    $redis = new Redis();
    $redis->connect('127.0.0.1');

    $store = new RedisStore($redis);
    ```
  </TabItem>
  <TabItem label="PSR-16 cache">
    ```php
    use Saloon\RateLimitPlugin\Stores\PsrStore;

    // $cache is any Psr\SimpleCache\CacheInterface implementation.
    $store = new PsrStore($cache);
    ```
  </TabItem>
  <TabItem label="Filesystem">
    ```php
    use Saloon\RateLimitPlugin\Stores\FileStore;

    $store = new FileStore(__DIR__.'/storage/steam-rate-limit');
    ```
  </TabItem>
</Tabs>

<Aside type="caution" title="A per-process store is not a shared one">
`MemoryStore` and a `FileStore` on ephemeral disk both look fine in development and stop
working the moment you scale out. Pick a store every process genuinely reaches.
</Aside>
