Respect provider feedback
Read rate limit headers and pause every worker when the provider asks you to slow down.
A provider knows its own counters better than you do. valve reads the rate limit headers on each response and, when the provider asks for a pause, writes it to the store so every worker sharing the id and store waits.
Feedback pauses the configured Valve ID; it does not infer which provider quota a response describes. Use the same ID and store for a shared quota, and separate IDs for independent quotas. See Choose provider quota scopes for credential-wide, endpoint, and model examples.
With fetch
wrapFetch observes every response automatically.
const partnerFetch = partner.wrapFetch(fetch, { retries: 2 });
const res = await partnerFetch('https://api.partner.example/v2/orders');Every response updates provider feedback, even when the request cannot retry.
Automatic retries default to GET, HEAD, OPTIONS, PUT and DELETE. The method
comes from init.method, then the Request method, then GET.
For an eligible request receiving a 429, or a 503 with a valid Retry-After:
- valve processes provider feedback and establishes a shared pause when a positive delay is available. A 429 without a positive provider delay uses exponential backoff.
- It cancels the unused response body to free the connection.
- It queues the request again at its original position, and releases its concurrency slot while waiting.
- Once the configured retry limit is exhausted, it returns the final response unchanged.
If the retry cannot be queued, because the valve was closed without draining or
maxQueue is full, valve returns the rate limit response unread instead of
rejecting. close({ drain: true }) lets accepted requests finish their retries.
The retries option specifies additional attempts after the initial request.
The default of 2 permits up to three total requests.
Set retries: 0 to read feedback without retrying. Network errors and other
HTTP failures are never retried.
A malformed or empty Retry-After on a 503 is treated as absent, so the
response is returned without retrying or consuming its body. Zero seconds and
expired HTTP dates are valid and allow a retry without a header-based pause.
A 429 without a positive provider delay still uses exponential backoff.
Requests with side effects
POST, PATCH and other methods return the first response by default, with its
body available to the caller. Their responses still establish shared pauses.
An Idempotency-Key header does not enable retries: providers differ in how
they handle keys, committed operations and error responses.
After verifying the provider's idempotency contract for a specific operation, create a dedicated wrapper to opt in:
const createOrderFetch = partner.wrapFetch(fetch, {
retries: 2,
retryUnsafeMethods: true,
});
const res = await createOrderFetch('https://api.partner.example/v2/orders', {
method: 'POST',
headers: { 'Idempotency-Key': orderOperationId },
body: JSON.stringify(order),
});The caller must ensure replaying the operation is safe, including reusing the
same key and payload if the provider requires them. PUT and DELETE also need
provider-specific review despite their HTTP idempotent semantics. This option
applies to every request through that wrapper; keep it scoped to operations
you have verified. It does not override retries: 0 or body replay restrictions.
Compatibility change: earlier versions retried all methods automatically.
POST and PATCH callers now receive the first response unless they explicitly
opt in. Setting only retries no longer enables retries for these methods.
Request bodies
A ReadableStream passed as init.body is sent once and never retried.
Strings, byte buffers and other replayable init.body values keep their usual
retry behavior.
Every Request body is exposed as a stream, even when constructed from a
string or bytes. Before retrying an inherited Request body, valve reads a
probe clone inside the admitted job. Bodies up to maxReplayBodySize
(default 65,536 bytes, or 64 KiB) are cloned for each attempt. Larger bodies
are sent once, and their first response is returned with its body available.
Probing also stops after 1,024 chunks, including empty chunks, to bound queued
chunk objects. Bodies exceeding either limit are sent once.
All responses still update provider feedback and shared cooldowns.
The probe stops and cancels its branch when it crosses the byte limit, before
fetch starts. It can retain the limit plus one source chunk and stream
prefetch; source chunk backing storage and fetch's own buffers are outside valve's
control. A small, slow body must finish probing before upload begins. Probing
holds the concurrency slot and honors the running request's abort signal.
The queue deadline maxWait ends at admission, so use an abort signal to
bound running time. Stream sources should also honor that signal themselves.
For streaming uploads that must start immediately, disable probing:
const uploadFetch = partner.wrapFetch(fetch, { maxReplayBodySize: 0 });
const res = await uploadFetch(uploadRequest);This sends inherited Request bodies once without cloning. Bodyless requests
can still retry. retries: 0 disables retries and probing for every request.
You can raise maxReplayBodySize to a finite integer byte limit for known
payloads; consider the per-request memory cost and concurrency together.
Infinity is rejected. An explicit non-null init.body replaces the
inherited body, so valve does not probe or clone the unused Request body.
Compatibility change: inherited Request bodies larger than the limit
previously retried through unbounded cloning. They now return the first
response. Retried small bodies leave the original Request unconsumed;
oversized bodies and bodies sent without probing are consumed by fetch.
With any other client
Use the observe run option to map a result to { status, headers }:
const res = await partner.run(() => got(url, { throwHttpErrors: false }), {
observe: (r) => ({ status: r.statusCode, headers: r.headers }),
});observe only sees results that resolve. If your client throws on 4xx, either
disable that behavior or call valve.observe() from your error handler. See
HTTP clients for axios, got, and undici.
You can also call valve.observe(response) directly at any time. It returns
void and writes the pause in the background.
Which headers are read
The default reader, defaultFeedback, combines three readers and honors the
longest explicit pause. Without a positive explicit pause, it uses the longest
exhausted quota reset. Remaining and reset values stay paired within each
reader: an unexhausted earlier family cannot hide an exhausted later family.
A positive Retry-After keeps precedence over quota resets.
If your provider documents one family as authoritative, select its reader
explicitly. See Conflicting quotas.
| Header | Read when | Pause |
|---|---|---|
Retry-After (seconds or HTTP date) | Status 429 or 503 | The given duration |
IETF RateLimit: r=…;t=… | Any status, r is 0 | t seconds |
RateLimit-Remaining and RateLimit-Reset | Any status, remaining is 0 | The reset duration |
x-ratelimit-remaining, x-ratelimit-reset (and x-rate-limit-*) | Any status, remaining is 0 | Until the reset |
A response that reports zero remaining quota pauses the valve even when its status is 200, so the next request does not have to fail first.
Retry-After HTTP dates are compared with the response Date header when it
is present. x-ratelimit-reset accepts epoch seconds, epoch milliseconds,
relative seconds, or Go-style durations such as 1m30s.
The IETF reader follows draft-ietf-httpapi-ratelimit-headers-11, sections 4.1.1 and 4.1.2. The draft can still change.
To change which headers are read, see Feedback readers.
When there is no useful header
A 429 without a positive provider delay triggers exponential backoff: 1s, 2s,
4s, and so on, capped at 30s. Configure it with backoff: { base, max }.
Each createValve() instance tracks its own consecutive 429 responses. A 429
with a positive provider delay still increments the counter, but uses that
delay instead of fallback backoff. Any observed non-429 resets only that
instance's counter. Pause expiry and manual resume() do not reset it.
feedback: false turns header reading off but keeps 429 backoff.
Local escalation and shared cooldowns
Backoff escalation is intentionally local; cooldown enforcement belongs to
the shared store. Workers using the same id and shared store respect the
longest established cooldown. A shorter pause from another worker cannot
shorten it, and a non-429 response does not clear it.
If 429 responses rotate across workers, escalation can be slower than if one instance receives them all. For example, the first headerless 429 on each of three instances requests a 1s pause, rather than advancing one shared sequence through 1s, 2s, and 4s.
Workers can wake together when a cooldown expires. Every queued request must still pass store admission, so configured rate rules continue to apply. Simultaneous wakeups can cause contention in store acquisition. A valve configured only with concurrency limits can start several requests together; configure a rate rule when the provider requires spacing or a request budget.
observe() writes pauses in the background. Enforcement across workers begins
once the store records the pause. A new pause does not cancel requests already
admitted or running.
Cap pauses
maxPause (default '15m') caps any single pause that comes from feedback or
backoff, protecting you from a misread or hostile header. A capped pause emits
a warning event. Manual valve.pause() calls are not capped.
Pause and resume manually
await partner.pause('2m'); // Every worker sharing the id waits 2 minutes.
await partner.resume(); // Ends the pause everywhere.Unlike observe, pause resolves once the store has recorded the pause.