The Steam Web API allows 100 000 requests per API key per day. The connector enforces this via 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:

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.

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:

steam.php
$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.

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

steam.php
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. ->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().

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

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.

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.

Steam serves some methods anonymously, stats()->currentPlayers() among them, so the connector sends them without the key, like any request marked SendsNoApiKey. 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.

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.

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

$connector = new SteamConnector(new SteamConfig(
apiKey: $_ENV['STEAM_API_KEY'],
rateLimitStore: $store,
));
use Saloon\RateLimitPlugin\Stores\PredisStore;
$store = new PredisStore(new Predis\Client());