Repository navigation
Fix response-body failures, cancellation and retry cleanup (1.0.1) #63
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
d3019b7
21657e0
7ebb4ea
fdcb6e1
014c8e8
72244ee
c73a9bf
faa887c
df8b49f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -19,6 +19,7 @@ import { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| GlassnodeValidationError, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } from './errors.js'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { readErrorDetail } from './error-detail.js'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { isGlassnodeError, isUnpaidResponse } from './response-safety.js'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { redactApiKey, redactSecrets } from './redact.js'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| AssetMetadataResponse, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -524,6 +525,7 @@ export class GlassnodeAPI { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private retryDelay: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private maxRetryDelay: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private timeout?: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private x402: boolean; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Create a new Glassnode API client | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -546,6 +548,7 @@ export class GlassnodeAPI { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.logger = validatedConfig.logger; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.hooks = validatedConfig.hooks; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.fetchFn = validatedConfig.fetch ?? globalThis.fetch; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.x402 = validatedConfig.x402; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // The default depends on `x402`, so it is applied here rather than in the schema (which stays | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // a plain object schema): a retry through a caller-supplied payment fetch could pay twice. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.maxRetries = | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -647,7 +650,7 @@ export class GlassnodeAPI { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return finish(await this.send(url, keyInHeader, options, trace)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (error instanceof GlassnodeError) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (isGlassnodeError(error)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const status = errorStatus(error); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const durationMs = trace.lastDurationMs; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.emit('onError', () => ({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -720,7 +723,7 @@ export class GlassnodeAPI { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ? combineSignals(signal, AbortSignal.timeout(timeout)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| : { signal: AbortSignal.timeout(timeout), dispose: () => {} }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Transport step — the only part that is retried on failure. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Transport to the headers; successful response-body transfers are checked below too. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let response: Response; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const sentAt = performance.now(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -743,7 +746,7 @@ export class GlassnodeAPI { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Already classified by a library-aware fetch (e.g. a GlassnodePaymentError from | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // createX402Fetch, which also covers an abort after a payment was sent): surface it | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // unchanged and never retry it. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (error instanceof GlassnodeError) throw error; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (isGlassnodeError(error)) throw error; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Cancelled by the caller: never retried, whatever the fetch rejected with. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (signal?.aborted) throw aborted(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Network/transport failure (including a timeout abort) — retryable. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -781,25 +784,80 @@ export class GlassnodeAPI { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| lastError = error; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Honour the server's Retry-After (e.g. on 429) for the next wait, if present. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| retryAfterMs = this.parseRetryAfter(response); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // A discarded streaming body still owns a connection. Do not wait to drain it. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| void response.body?.cancel().catch(() => {}); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Cleanup must not replace the HTTP failure that caused the retry. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Surface the server's error body (e.g. "Resolution 1h is not allowed") in the message, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // with the API key masked: a server or proxy may echo the request URL or the key. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const detail = await readErrorDetail(response, [this.apiKey]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (signal?.aborted) throw aborted(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw detail ? new GlassnodeApiError(response.status, statusText, detail) : error; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Success. Parsing a 200 body is NOT a transient error, so it is thrown, never retried. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // A minimal custom fetch may return a Response-like object with only json(), which 1.0.0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // accepted. Parse it there: its failures cannot be told apart, so none is retried. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (typeof response.text !== 'function') { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return (await response.json()) as unknown; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (parseError) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (signal?.aborted) throw aborted(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new GlassnodeValidationError( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'Glassnode API error: failed to parse response body as JSON', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { cause: parseError, endpoint } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Read bytes separately from parsing: polyfills can wrap invalid JSON in FetchError, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // so response.json() cannot reliably distinguish malformed JSON from failed transfers. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let readableBody = false; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let text: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return (await response.json()) as unknown; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+821
to
827
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const failure = describeTransportFailure(parseError); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+829
to
+842
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Line 840 checks If the per-attempt timeout signal aborts while reading a body from a custom fetch/polyfill (where
Suggested change
(Minor related note: in the |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // A plain payment fetch may already have charged for this successful response. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Only a response positively identified as unpaid is safe to repeat in x402 mode. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (attempt < this.maxRetries && (!this.x402 || isUnpaidResponse(response))) continue; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw lastError; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new GlassnodeValidationError( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'Glassnode API error: failed to parse response body as JSON', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { cause: parseError, endpoint } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return JSON.parse(text) as unknown; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (cause) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new GlassnodeValidationError( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'Glassnode API error: failed to parse response body as JSON', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { cause, endpoint } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| attemptSignal.dispose(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import type { GlassnodeError } from './errors.js'; | ||
|
|
||
| // Global symbols survive multiple installed copies and separate CJS/ESM entries. | ||
| const SAFE_BODY_RETRY = Symbol.for('glassnode-api.x402.safe-body-retry'); | ||
|
|
||
| /** Only createX402Fetch knows that a response arrived before any payment was sent. */ | ||
| export function markUnpaidResponse(response: Response): Response { | ||
| Object.defineProperty(response, SAFE_BODY_RETRY, { value: true }); | ||
| return response; | ||
| } | ||
|
Comment on lines
+7
to
+10
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: if a custom |
||
|
|
||
| export function isUnpaidResponse(response: Response): boolean { | ||
| return (response as Response & { [SAFE_BODY_RETRY]?: boolean })[SAFE_BODY_RETRY] === true; | ||
| } | ||
|
|
||
| const ERROR_NAMES = new Set([ | ||
| 'GlassnodeError', | ||
| 'GlassnodeApiError', | ||
| 'GlassnodeNetworkError', | ||
| 'GlassnodeAbortError', | ||
| 'GlassnodeValidationError', | ||
| 'GlassnodeConfigError', | ||
| 'GlassnodeInputError', | ||
| 'GlassnodePaymentError', | ||
| ]); | ||
|
|
||
| /** Preserve library classifications even when an error comes from another package copy. */ | ||
| export function isGlassnodeError(error: unknown): error is GlassnodeError { | ||
| return ( | ||
| typeof error === 'object' && | ||
| error !== null && | ||
| 'name' in error && | ||
| typeof error.name === 'string' && | ||
| ERROR_NAMES.has(error.name) | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
fetchisnode-fetchv2,response.bodyis a NodeReadablestream (which has.destroy(), not.cancel()).response.body?.cancel()throws aTypeError(swallowed bycatch), leaving the underlying socket/stream unclosed on discarded429/5xxresponses.