valve_

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:

  1. 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.
  2. It cancels the unused response body to free the connection.
  3. It queues the request again at its original position, and releases its concurrency slot while waiting.
  4. 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.

HeaderRead whenPause
Retry-After (seconds or HTTP date)Status 429 or 503The given duration
IETF RateLimit: r=…;t=…Any status, r is 0t seconds
RateLimit-Remaining and RateLimit-ResetAny status, remaining is 0The reset duration
x-ratelimit-remaining, x-ratelimit-reset (and x-rate-limit-*)Any status, remaining is 0Until 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.

On this page