Skip to content

Fix response-body failures, cancellation and retry cleanup (1.0.1) - #63

Open
planadecu wants to merge 9 commits into
mainfrom
fix/response-body-retries
Open

planadecu wants to merge 9 commits into
mainfrom
fix/response-body-retries

Conversation

@planadecu

@planadecu planadecu commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Successful headers could be followed by a timeout or connection reset reported as a validation error. Caller cancellation during a terminal error body was swallowed, and discarded retryable responses retained their bodies.

Read response text separately from JSON parsing: body-transfer failures follow the network policy, while malformed JSON always fails validation, including with node-fetch v2. Cancel discarded 429/5xx bodies without waiting on cleanup and preserve caller cancellation after error-detail reading.

With createX402Fetch, paid-body failures remain never-retried payment errors. In x402 mode, successful body transfers can only be retried when createX402Fetch positively identifies the response as unpaid; a plain payment fetch's successful body failure is never retried, even with an explicit maxRetries. Library error classifications also survive separate package copies. Version and changelog are prepared for 1.0.1.

Fixes #59
Fixes #60
Fixes #61

Validation:

  • Full coverage suite: 727 passed, 1 skipped; coverage thresholds met.
  • Real node-fetch 2.7.0 plus malformed HTTP 200: one request, GlassnodeValidationError, no retry hook.
  • Thirty runs of response-body and x402 regressions with three concurrent workers: all passed.
  • Local HTTP tests cover stalled/reset bodies, header-synchronized caller cancellation, and a final retry's whole-call cancellation. Plain payment fetch body failures make one payment attempt.
  • x402 tests verify one signed payment after paid-body timeout, cancellation, or reset; unpaid-body retries; Request signal timeout classification; metadata/cloning and cancellation propagation.
  • Lint, formatting, test/example/consumer type-checks, Node/browser builds, TypeDoc and changelog-heading checks passed.

Follow-up fixes (72244ee):

  • createX402Fetch over a base fetch whose body is a Node stream (node-fetch v2) no longer throws after the payment was sent. Before, that TypeError looked like a retryable transport failure, and with maxRetries: 2 one call signed three payments (regression test added). Such bodies are returned unwrapped; GlassnodeAPI never retries a paid x402 body failure.
  • A custom fetch returning only json() (no text()) works again as in 1.0.0, instead of failing as malformed JSON in a patch release.

@planadecu
planadecu marked this pull request as ready for review October 13, 2026 21:33
…es working

createX402Fetch wrapped every paid 2xx body with getReader(), which a
Node-stream body (node-fetch v2) lacks. The TypeError surfaced after the
payment as a retryable transport failure, so maxRetries signed new
payments. Such bodies are now returned unwrapped; GlassnodeAPI already
never retries a paid x402 body failure.

Responses without text() (a minimal custom fetch exposing only json(),
accepted by 1.0.0) are parsed with json() again instead of failing as
malformed JSON.

@martende martende left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid fix overall — separating response.text() from JSON.parse(text), guarding paid x402 streams, and tagging unpaid responses with Symbol.for('glassnode-api.x402.safe-body-retry') cleanly addresses #59, #60, and #61.

Left a few inline comments on a test helper bug in test/x402.spec.ts and a couple of edge cases in guardPaidBody / GlassnodeAPI.send.

Comment thread test/x402.spec.ts
Comment on lines +647 to +689
function nodeStreamResponse(text: () => Promise<string>): Response {
const body = Readable.from([]);
return {
ok: true,
status: 200,
statusText: 'OK',
headers: new Headers(),
body,
bodyUsed: false,
text,
} as unknown as Response;
}

it('paid request -> 200 with a Node-stream body: success, one payment', async () => {
const baseFetch = scripted(() =>
nodeStreamResponse(async () => JSON.stringify(['/market/price_usd_close']))
);
const signTypedData = vi.fn(async (): Promise<`0x${string}`> => FAKE_SIGNATURE);
const paidFetch = await createX402Fetch({
account: fakeAccount(signTypedData),
fetch: baseFetch as typeof fetch,
});
const result = await paidApi(paidFetch, { maxRetries: 2 }).getMetricList();
expect(result).toEqual(['/market/price_usd_close']);
expect(signTypedData).toHaveBeenCalledTimes(1);
});

it('paid request -> 200 with a Node-stream body that fails in transit: one payment, not retried', async () => {
const baseFetch = scripted(() =>
nodeStreamResponse(async () => {
throw new TypeError('terminated');
})
);
const signTypedData = vi.fn(async (): Promise<`0x${string}`> => FAKE_SIGNATURE);
const paidFetch = await createX402Fetch({
account: fakeAccount(signTypedData),
fetch: baseFetch as typeof fetch,
});
const err = await caught(paidApi(paidFetch, { maxRetries: 2 }).getMetricList());
expect(err).toBeInstanceOf(GlassnodeError);
expect(signTypedData).toHaveBeenCalledTimes(1);
expect(baseFetch.mock.calls.filter(isPaidCall)).toHaveLength(1);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nodeStreamResponse initializes bodyUsed: false and never flips it to true when text() is called (unlike real node-fetch v2, which sets bodyUsed = true as soon as consumption starts).

Because bodyUsed remains false, bodyReadStarted (readableBody && response.bodyUsed) in src/glassnode-api.ts:829 evaluates to false, so 'paid request -> 200 with a Node-stream body that fails in transit: one payment, not retried' actually falls through to GlassnodeValidationError (line 848) instead of GlassnodeNetworkError (line 836) and never exercises the (!this.x402 || isUnpaidResponse(response)) retry guard on line 845 (expect(err).toBeInstanceOf(GlassnodeError) masks this).

Suggested change
function nodeStreamResponse(text: () => Promise<string>): Response {
const body = Readable.from([]);
return {
ok: true,
status: 200,
statusText: 'OK',
headers: new Headers(),
body,
bodyUsed: false,
text,
} as unknown as Response;
}
it('paid request -> 200 with a Node-stream body: success, one payment', async () => {
const baseFetch = scripted(() =>
nodeStreamResponse(async () => JSON.stringify(['/market/price_usd_close']))
);
const signTypedData = vi.fn(async (): Promise<`0x${string}`> => FAKE_SIGNATURE);
const paidFetch = await createX402Fetch({
account: fakeAccount(signTypedData),
fetch: baseFetch as typeof fetch,
});
const result = await paidApi(paidFetch, { maxRetries: 2 }).getMetricList();
expect(result).toEqual(['/market/price_usd_close']);
expect(signTypedData).toHaveBeenCalledTimes(1);
});
it('paid request -> 200 with a Node-stream body that fails in transit: one payment, not retried', async () => {
const baseFetch = scripted(() =>
nodeStreamResponse(async () => {
throw new TypeError('terminated');
})
);
const signTypedData = vi.fn(async (): Promise<`0x${string}`> => FAKE_SIGNATURE);
const paidFetch = await createX402Fetch({
account: fakeAccount(signTypedData),
fetch: baseFetch as typeof fetch,
});
const err = await caught(paidApi(paidFetch, { maxRetries: 2 }).getMetricList());
expect(err).toBeInstanceOf(GlassnodeError);
expect(signTypedData).toHaveBeenCalledTimes(1);
expect(baseFetch.mock.calls.filter(isPaidCall)).toHaveLength(1);
});
function nodeStreamResponse(text: () => Promise<string>): Response {
const body = Readable.from([]);
const response = {
ok: true,
status: 200,
statusText: 'OK',
headers: new Headers(),
body,
bodyUsed: false,
text: () => {
response.bodyUsed = true;
return text();
},
};
return response as unknown as Response;
}
it('paid request -> 200 with a Node-stream body: success, one payment', async () => {
const baseFetch = scripted(() =>
nodeStreamResponse(async () => JSON.stringify(['/market/price_usd_close']))
);
const signTypedData = vi.fn(async (): Promise<`0x${string}`> => FAKE_SIGNATURE);
const paidFetch = await createX402Fetch({
account: fakeAccount(signTypedData),
fetch: baseFetch as typeof fetch,
});
const result = await paidApi(paidFetch, { maxRetries: 2 }).getMetricList();
expect(result).toEqual(['/market/price_usd_close']);
expect(signTypedData).toHaveBeenCalledTimes(1);
});
it('paid request -> 200 with a Node-stream body that fails in transit: one payment, not retried', async () => {
const baseFetch = scripted(() =>
nodeStreamResponse(async () => {
throw new TypeError('terminated');
})
);
const signTypedData = vi.fn(async (): Promise<`0x${string}`> => FAKE_SIGNATURE);
const paidFetch = await createX402Fetch({
account: fakeAccount(signTypedData),
fetch: baseFetch as typeof fetch,
});
const err = await caught(paidApi(paidFetch, { maxRetries: 2 }).getMetricList());
expect(err).toBeInstanceOf(GlassnodeNetworkError);
expect(signTypedData).toHaveBeenCalledTimes(1);
expect(baseFetch.mock.calls.filter(isPaidCall)).toHaveLength(1);
});

Comment thread src/glassnode-api.ts
Comment on lines +829 to +842
const bodyReadStarted = readableBody && response.bodyUsed;
const explicitAbort =
typeof parseError === 'object' &&
parseError !== null &&
ABORT_NAMES.has(String((parseError as { name?: unknown }).name));
if (failure && (bodyReadStarted || explicitAbort)) {
retryAfterMs = undefined;
lastError = new GlassnodeNetworkError(
`Glassnode API error: ${this.redact(failure.message)}`,
{
cause: parseError,
timedOut: failure.timedOut || (attemptSignal.signal?.aborted ?? false),
}
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line 840 checks timedOut: failure.timedOut || (attemptSignal.signal?.aborted ?? false), but the if condition on line 834 only checks if (failure && (bodyReadStarted || explicitAbort)).

If the per-attempt timeout signal aborts while reading a body from a custom fetch/polyfill (where response.body is absent or bodyUsed isn't flipped) and response.text() rejects with a generic TypeError('terminated') rather than AbortError/TimeoutError, bodyReadStarted || explicitAbort is false and the timeout is misclassified as a non-retryable GlassnodeValidationError.

Suggested change
const bodyReadStarted = readableBody && response.bodyUsed;
const explicitAbort =
typeof parseError === 'object' &&
parseError !== null &&
ABORT_NAMES.has(String((parseError as { name?: unknown }).name));
if (failure && (bodyReadStarted || explicitAbort)) {
retryAfterMs = undefined;
lastError = new GlassnodeNetworkError(
`Glassnode API error: ${this.redact(failure.message)}`,
{
cause: parseError,
timedOut: failure.timedOut || (attemptSignal.signal?.aborted ?? false),
}
);
const bodyReadStarted = readableBody && response.bodyUsed;
const attemptTimedOut = attemptSignal.signal?.aborted ?? false;
const explicitAbort =
typeof parseError === 'object' &&
parseError !== null &&
ABORT_NAMES.has(String((parseError as { name?: unknown }).name));
if (failure && (bodyReadStarted || explicitAbort || attemptTimedOut)) {
retryAfterMs = undefined;
lastError = new GlassnodeNetworkError(
`Glassnode API error: ${this.redact(failure.message)}`,
{
cause: parseError,
timedOut: failure.timedOut || attemptTimedOut,
}
);

(Minor related note: in the fetchFn catch block slightly above at line 758, timedOut: failure.timedOut could also use failure.timedOut || (attemptSignal.signal?.aborted ?? false) for the same reason.)

Comment thread src/x402.ts
Comment on lines +200 to +206
return response.ok && typeof response.body?.getReader === 'function'
? guardPaidBody(
response,
keys,
init?.signal ?? (input instanceof Request ? input.signal : undefined)
)
: response;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two subtle points here:

  1. Post-payment synchronous throw protection: guardPaidBody runs after paymentSent === true outside the try / catch. If response.body is already locked/consumed (response.body.locked / response.bodyUsed) or new Response(body, ...) throws TypeError, that error would escape createX402Fetch unclassified and cause GlassnodeAPI (when maxRetries > 0) to retry and sign a second payment. Checking !response.bodyUsed && !response.body.locked (and/or wrapping guardPaidBody construction in try / catch falling back to return response) guarantees it can never throw synchronously after payment.
  2. Cross-realm Request signal: input instanceof Request can be false for cross-realm Requests (which the rest of src/x402.ts supports via duck-typing in noRedirect / hasPaymentHeader). Using a duck-typed signal fallback keeps it consistent.

Comment thread src/x402.ts
Comment on lines +212 to +243
const reader = response.body!.getReader();
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const { done, value } = await reader.read();
if (done) {
reader.releaseLock();
controller.close();
} else controller.enqueue(value);
} catch (error) {
reader.releaseLock();
const paymentError = toPaidTransportError(error, keys);
// Some fetches reject body reads with AbortError even for a timeout signal.
if (signal?.aborted && signal.reason?.name === 'TimeoutError' && !paymentError.timedOut) {
controller.error(
new GlassnodePaymentError(paymentError.message, {
cause: error,
paymentMayHaveSettled: true,
timedOut: true,
})
);
} else controller.error(paymentError);
}
},
async cancel(reason) {
try {
await reader.cancel(reason);
} finally {
reader.releaseLock();
}
},
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If guarded.body.cancel() is called while await reader.read() is pending in pull(), reader.cancel(reason) resolves the pending reader.read() with { done: true, value: undefined }. pull() then resumes and calls controller.close() on a stream controller that was already closed by cancel(), which throws TypeError [ERR_INVALID_STATE]: Invalid state: Controller is already closed into catch (error) and triggers toPaidTransportError + controller.error(...).

Tracking a cancelled flag in cancel() avoids closing/enqueuing or erroring after cancellation:

Suggested change
const reader = response.body!.getReader();
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const { done, value } = await reader.read();
if (done) {
reader.releaseLock();
controller.close();
} else controller.enqueue(value);
} catch (error) {
reader.releaseLock();
const paymentError = toPaidTransportError(error, keys);
// Some fetches reject body reads with AbortError even for a timeout signal.
if (signal?.aborted && signal.reason?.name === 'TimeoutError' && !paymentError.timedOut) {
controller.error(
new GlassnodePaymentError(paymentError.message, {
cause: error,
paymentMayHaveSettled: true,
timedOut: true,
})
);
} else controller.error(paymentError);
}
},
async cancel(reason) {
try {
await reader.cancel(reason);
} finally {
reader.releaseLock();
}
},
});
const reader = response.body!.getReader();
let cancelled = false;
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const { done, value } = await reader.read();
if (cancelled) return;
if (done) {
reader.releaseLock();
controller.close();
} else controller.enqueue(value);
} catch (error) {
reader.releaseLock();
if (cancelled) return;
const paymentError = toPaidTransportError(error, keys);
// Some fetches reject body reads with AbortError even for a timeout signal.
if (signal?.aborted && signal.reason?.name === 'TimeoutError' && !paymentError.timedOut) {
controller.error(
new GlassnodePaymentError(paymentError.message, {
cause: error,
paymentMayHaveSettled: true,
timedOut: true,
})
);
} else controller.error(paymentError);
}
},
async cancel(reason) {
cancelled = true;
try {
await reader.cancel(reason);
} finally {
reader.releaseLock();
}
},
});

Comment thread src/glassnode-api.ts
Comment on lines +788 to +792
try {
void response.body?.cancel().catch(() => {});
} catch {
// Cleanup must not replace the HTTP failure that caused the retry.
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When fetch is node-fetch v2, response.body is a Node Readable stream (which has .destroy(), not .cancel()). response.body?.cancel() throws a TypeError (swallowed by catch), leaving the underlying socket/stream unclosed on discarded 429/5xx responses.

Suggested change
try {
void response.body?.cancel().catch(() => {});
} catch {
// Cleanup must not replace the HTTP failure that caused the retry.
}
try {
if (typeof response.body?.cancel === 'function') {
void response.body.cancel().catch(() => {});
} else {
(response.body as { destroy?: () => void } | null | undefined)?.destroy?.();
}
} catch {
// Cleanup must not replace the HTTP failure that caused the retry.
}

Comment thread src/glassnode-api.ts
Comment on lines +821 to 827
readableBody = !!response.body && !response.bodyUsed && !response.body.locked;
text = await response.text();
} catch (parseError) {
// A paid response stream classifies its own failures; never repeat its payment.
if (isGlassnodeError(parseError)) throw parseError;
// Reading the body was cut off by the caller's abort: that is not a malformed body.
if (signal?.aborted) throw aborted();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: trace.lastDurationMs is recorded when headers arrive (line 771) and isn't updated if response.text() (or readErrorDetail on line 797) fails after a slow/stalled body transfer. Updating trace.lastDurationMs = performance.now() - sentAt in the catch path before emitting onRetry / throwing to onError would make hook durationMs reflect the actual time spent before the attempt failed.

Comment thread src/response-safety.ts
Comment on lines +7 to +10
export function markUnpaidResponse(response: Response): Response {
Object.defineProperty(response, SAFE_BODY_RETRY, { value: true });
return response;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: if a custom fetch or test double returns a frozen Response-like object (Object.freeze(...)), Object.defineProperty will throw a TypeError. Wrapping in try { ... } catch {} makes markUnpaidResponse safe on non-extensible responses (failing closed to isUnpaidResponse === false).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants