The problem
stripe-node sends requests through pluggable HTTP clients. EndpointFetchHttpClient is used when the SDK runs inside Stripe's extensibility runtimes, which expose an endpointFetch function instead of fetch. Its response wrapper threw the headers away:
constructor(res: EndpointFetchResponse) {
super(res.status, {});
this._res = res;
}RequestSender reads several response headers: the request id, stripe-should-retry, stripe-account, idempotency-key, stripe-notice. With an empty object, all of them were missing in these runtimes.
The proposed fix
- super(res.status, {});
+ super(res.status, res.headers ?? {});The error path gets the same treatment, and the changelog says the change preserves "response metadata and retry signals". A small, useful change.
What I found in review
I checked each claim against RequestSender.ts on master.
retry-after reaches the error, not the backoff
_shouldRetry reads stripe-should-retry from the response headers, so that signal now works. The delay is different: _getSleepTimeInMS computes exponential backoff with jitter from the configured initial and maximum delays and never looks at retry-after. With this change, retry-after shows up in error.headers but does not change how long the SDK waits. The changelog wording invites the opposite reading, so I suggested adjusting it.
Header names are case-insensitive; a plain object is not
HTTP field names are case-insensitive (RFC 9110). The other clients normalize them for free: Node's http module lowercases incoming header names, and FetchHttpClient builds its object from a Headers instance, which iterates in lower case. endpointFetch returns a plain record. From the inline comment:
If the runtime ever returnsRequest-IdorStripe-Should-Retry, every lookup misses without an error. Lowercasing the keys once here (and for the rejection path) would make that independent of the runtime, and a test with mixed-case names would pin it.
A normalization at the adapter boundary is a few lines:
const lowerCaseKeys = (headers: Record<string, string> = {}) =>
Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]))The test fixtures in the pull request use only lower-case names, which is why they pass either way.
What happened next
Takeaways
- An adapter that converts a foreign response into your internal shape is the place to normalize it. Every consumer after it can then assume one convention.
- A plain object is not a
Headersobject. Case-insensitive lookup is a property of the type, not of the data. - Changelogs are part of the interface. "Retry signals" means something specific to whoever is debugging a 429 at night.
References
Code and history behind this post
- review Reviewed: #2848
- comment Commented on stripe/stripe-node
- review Reviewed: Preserve endpointFetch response headers