From a79ca0ee9aac6b8ecb53a0184197eefc628015bb Mon Sep 17 00:00:00 2001 From: Sebastian Noack Date: Wed, 6 May 2026 13:22:56 -0400 Subject: [PATCH] Abort timed-out requests with TimeoutError DOMException This mimics the intrinsic behavior of AbortSignal.timeout() which we plan to eventually switch to. --- README.md | 2 +- eslint.config.mjs | 1 + hubkit.js | 67 ++++++++++++++++++++++++++--------------------- package.json | 2 +- 4 files changed, 40 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index e9b1192..99554d4 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ include: in NodeJS. * `host`: The URL to prepend to all request paths; defaults to `https://api.github.com`. * `graphHost`: The URL to use for all GraphQL requests; defaults to using the value of `host` which works fine for `github.com`, but you'll need to set a separate value when working with GitHub Enterprise. -* `timeout`: The timeout in milliseconds to apply to the request; none by default. If the timeout is reached, the request will abort with an `AbortError`. +* `timeout`: The timeout in milliseconds to apply to the request; none by default. If the timeout is reached, the request will abort with a `TimeoutError`. * `cache`: An instance of [LRUCache](https://github.com/isaacs/node-lru-cache). The objects inserted into the cache will be of the form `{value: {...}, eTag: 'abc123', status: 200, headers: {...}, size: 1763, expiry: 1770853094}`. diff --git a/eslint.config.mjs b/eslint.config.mjs index ffc01ee..9ccae6e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -10,6 +10,7 @@ export default [ AbortController: false, btoa: false, clearTimeout: false, + DOMException: false, fetch: false, Promise: false, setTimeout: false, diff --git a/hubkit.js b/hubkit.js index 5cc5ab6..411a55d 100644 --- a/hubkit.js +++ b/hubkit.js @@ -196,8 +196,8 @@ if (typeof require !== 'undefined') { res.headers.get('x-ratelimit-remaining') === '0' && res.headers.get('x-ratelimit-reset')) { try { - error.retryDelay = - Math.max(0, parseInt(res.headers.get('x-ratelimit-reset'), 10) * 1000 - Date.now()); + const reset = parseInt(res.headers.get('x-ratelimit-reset'), 10); + error.retryDelay = Math.max(0, reset * 1000 - Date.now()); if (!options.timeout || error.retryDelay < options.timeout) value = Hubkit.RETRY; } catch { // ignore, don't retry request @@ -468,7 +468,13 @@ if (typeof require !== 'undefined') { function onError(error) { error.originalMessage = error.message; - error.message = formatError('Hubkit', error.message); + const message = formatError('Hubkit', error.message); + try { + error.message = message; + } catch { + // DOMException (e.g. TimeoutError) has a read-only 'message' property. + Object.defineProperty(error, 'message', {value: message, writable: true}); + } error.fingerprint = ['Hubkit', options.method, options.pathPattern, error.originalMessage]; handleError(error); @@ -657,38 +663,39 @@ if (typeof require !== 'undefined') { } async function fetchResponse(config, options) { + const init = {method: config.method, headers: config.headers}; + if (config.body) { + init.body = JSON.stringify(config.body); + init.headers['Content-Type'] = 'application/json'; + } + const url = new URL(config.url); + for (const key in config.params) url.searchParams.set(key, config.params[key]); let timeoutId; + if (config.timeout) { + // TODO: Switch to AbortSignal.timeout once widely supported. + const controller = new AbortController(); + init.signal = controller.signal; + timeoutId = setTimeout(() => { + controller.abort( + new DOMException('The operation was aborted due to timeout', 'TimeoutError')); + }, config.timeout); + } + let response, rawData; try { - const init = {method: config.method, headers: config.headers}; - if (config.body) { - init.body = JSON.stringify(config.body); - init.headers['Content-Type'] = 'application/json'; - } - if (config.timeout) { - const controller = new AbortController(); - timeoutId = setTimeout(() => controller.abort(), config.timeout); - init.signal = controller.signal; - } - - const url = new URL(config.url); - for (const key in config.params) url.searchParams.set(key, config.params[key]); - let response, rawData; - try { - response = await fetch(url, init); - rawData = await readResponseBody(response, options); - } catch (error) { - error.networkFailure = true; - throw error; - } - return { - status: response.status, - headers: response.headers, - data: parseResponseData(rawData, response.headers, options), - rawData - }; + response = await fetch(url, init); + rawData = await readResponseBody(response, options); + } catch (error) { + error.networkFailure = true; + throw error; } finally { if (timeoutId) clearTimeout(timeoutId); } + return { + status: response.status, + headers: response.headers, + data: parseResponseData(rawData, response.headers, options), + rawData + }; } function readResponseBody(response, options) { diff --git a/package.json b/package.json index 5ccafbc..2358e00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hubkit", - "version": "7.0.0", + "version": "7.1.0", "description": "GitHub API library for JavaScript, promise-based, for both NodeJS and the browser", "main": "index.js", "types": "index.d.ts",