Feedback readers
The functions that turn provider response headers into shared pauses, and how to write your own.
A feedback reader takes a response and returns what it learned. It must be pure and fast, and it must not read the body.
type Feedback = (response: ResponseLike) => FeedbackSignal | undefined;
interface ResponseLike {
readonly status: number;
readonly headers: HeadersLike; // A Headers object or a plain record
}
interface FeedbackSignal {
pauseMs?: number; // Pause every process for this long.
remaining?: number; // Units left in the provider's window.
resetMs?: number; // Time until the window resets.
}How a signal becomes a pause
- If
pauseMsis greater than 0, valve pauses forpauseMs. - Otherwise, if
remainingis 0 or less, valve pauses forresetMs. - Otherwise, if the status is 429, valve applies exponential backoff.
- The pause is capped at
maxPause.
Built-in readers
Import them from valvejs/headers.
| Reader | Reads |
|---|---|
retryAfter() | Retry-After as seconds or an HTTP date, on 429 and 503 only. |
ietfRateLimit() | IETF RateLimit: r=…;t=…, plus legacy RateLimit-Remaining and RateLimit-Reset. |
xRateLimit(options?) | x-ratelimit-remaining and x-ratelimit-reset, or the x-rate-limit-* spelling. |
combine(...readers) | Keeps the longest explicit pause and exhausted quota window; keeps remaining/reset paired. |
defaultFeedback | combine(retryAfter(), ietfRateLimit(), xRateLimit()). |
ietfRateLimit() ignores a RateLimit field longer than 8192 characters and
falls back to the legacy fields.
Conflicting quotas
combine() treats each reader's quota as applicable to the observed request.
It selects an exhausted quota (remaining <= 0) over an unexhausted one. If
several quotas are exhausted, it selects the one with the longest resetMs.
Missing resets count as zero for selection; equal windows keep reader order.
Without an exhausted quota, the first reader with either remaining or
resetMs wins. Both fields always come from that same reader, including when
one field is missing.
Explicit pauseMs values are combined by taking the longest. A positive
explicit pause still takes precedence over quota resets, preserving
Retry-After handling. For example, Retry-After: 1 on a 429 or 503 selects
a one-second pause even if an exhausted quota reports a longer reset. The
IETF draft's client behavior
requires Retry-After precedence over its effective window. An exhausted
quota without its own usable reset does not create a pause on a successful
response; a 429 still uses fallback backoff. All feedback pauses remain
capped by maxPause.
For example, RateLimit: r=10;t=60 together with
X-RateLimit-Remaining: 0 and X-RateLimit-Reset: 120 yields
{ remaining: 0, resetMs: 120000 }. The next admission waits 120 seconds,
even after a 200 response. The unexhausted quota's 60-second window is ignored.
This is a conservative policy for independent quotas or conflicting encodings
of one quota. If the provider documents one header family as authoritative,
select it explicitly, for example feedback: combine(retryAfter(), xRateLimit()).
Readers do not discover or route quota scopes: the resulting pause applies to
this valve's ID. See Choose provider quota scopes.
Compatibility change: earlier combine() calls kept the first remaining
and first reset independently. Later exhausted readers now take precedence,
and missing fields are no longer filled from a different quota reader.
Standalone readers keep their existing parsing behavior.
xRateLimit options
| Option | Default | Description |
|---|---|---|
remaining | x-ratelimit-remaining, then x-rate-limit-remaining | Header name for remaining. |
reset | x-ratelimit-reset, then x-rate-limit-reset | Header name for reset. |
resetUnit | 'auto' | How to read the reset value. |
resetUnit is one of 'auto', 'seconds', 'epoch-seconds', 'epoch-ms',
'ms', or 'duration'. With 'auto', numbers above 10¹² are epoch
milliseconds, numbers above 10⁹ are epoch seconds, smaller numbers are relative
seconds, and non-numbers are parsed as Go-style durations such as 1m30s.
Epoch values are compared with the response Date header when present, and
with the local clock otherwise.
Customize feedback
Read a provider's own headers alongside the defaults:
import { combine, defaultFeedback, xRateLimit } from 'valvejs/headers';
const feedback = combine(
defaultFeedback,
xRateLimit({
remaining: 'x-app-calls-left',
reset: 'x-app-reset-ms',
resetUnit: 'ms',
}),
);
createValve({ id: 'partner', rate, feedback });Write a reader for a non-standard signal:
import type { Feedback } from 'valvejs';
import { combine, defaultFeedback } from 'valvejs/headers';
// The provider returns 420 with a "Wait-Seconds" header when throttling.
const waitSeconds: Feedback = (response) => {
if (response.status !== 420) return undefined;
const value = new Headers(response.headers as HeadersInit).get(
'wait-seconds',
);
const seconds = Number(value);
return value !== null && Number.isFinite(seconds)
? { pauseMs: seconds * 1000 }
: undefined;
};
createValve({
id: 'partner',
rate,
feedback: combine(defaultFeedback, waitSeconds),
});Errors thrown by a reader are caught and reported as warning events.
Set feedback: false to disable header reading. 429 backoff still applies.