stripe-node endpointFetch headers: passing them through is not the same as using them

A stripe-node change stops the endpointFetch HTTP client from discarding response headers. Two follow-ups: retry-after does not affect backoff, and a plain object makes header lookups case-sensitive.

Category
Code review
Published
Reading time
2 min read

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:

src/net/EndpointFetchHttpClient.ts
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

Diff
-    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 returns Request-Id or Stripe-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:

TypeScript
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 Headers object. 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

More

Theme