Skip to content

Commit

Permalink
Introduce timeoutMs option for all modules (#462)
Browse files Browse the repository at this point in the history
  • Loading branch information
wKovacs64 committed Apr 28, 2024
1 parent aa90167 commit b6076f2
Show file tree
Hide file tree
Showing 22 changed files with 274 additions and 8 deletions.
4 changes: 2 additions & 2 deletions .bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
},
{
"path": "dist/cjs/pwned-password-range.cjs",
"maxSize": "1.4 kB"
"maxSize": "1.5 kB"
},
{
"path": "dist/cjs/search.cjs",
Expand All @@ -54,7 +54,7 @@
},
{
"path": "dist/esm/breached-account.js",
"maxSize": "1.1 kB"
"maxSize": "1.2 kB"
},
{
"path": "dist/esm/breaches.js",
Expand Down
5 changes: 5 additions & 0 deletions .changeset/modern-lions-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'hibp': minor
---

Add the `timeoutMs` option to all modules, allowing the consumer to specify a timeout for the underlying network request (in milliseconds). Requests that take longer than the sppecified timeout period will throw/reject. There is no default timeout, as `fetch` itself has no timeout by default and providing one would be arbitrary, unexpected, and a breaking change.
9 changes: 9 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ with an Error
| breachName | <code>string</code> | the name of a breach in the system |
| [options] | <code>object</code> | a configuration object |
| [options.baseUrl] | <code>string</code> | a custom base URL for the haveibeenpwned.com API endpoints (default: `https://haveibeenpwned.com/api/v3`) |
| [options.timeoutMs] | <code>number</code> | timeout for the request in milliseconds (default: none) |
| [options.userAgent] | <code>string</code> | a custom string to send as the User-Agent field in the request headers (default: `hibp <version>`) |

**Example**
Expand Down Expand Up @@ -138,6 +139,7 @@ an Error
| [options.apiKey] | <code>string</code> | an API key from https://haveibeenpwned.com/API/Key (default: undefined) |
| [options.domain] | <code>string</code> | a domain by which to filter the results (default: all domains) |
| [options.includeUnverified] | <code>boolean</code> | include "unverified" breaches in the results (default: true) |
| [options.timeoutMs] | <code>number</code> | timeout for the request in milliseconds (default: none) |
| [options.truncate] | <code>boolean</code> | truncate the results to only include the name of each breach (default: true) |
| [options.baseUrl] | <code>string</code> | a custom base URL for the haveibeenpwned.com API endpoints (default: `https://haveibeenpwned.com/api/v3`) |
| [options.userAgent] | <code>string</code> | a custom string to send as the User-Agent field in the request headers (default: `hibp <version>`) |
Expand Down Expand Up @@ -203,6 +205,7 @@ objects (an empty array if no breaches were found), or rejects with an Error
| [options] | <code>object</code> | a configuration object |
| [options.domain] | <code>string</code> | a domain by which to filter the results (default: all domains) |
| [options.baseUrl] | <code>string</code> | a custom base URL for the haveibeenpwned.com API endpoints (default: `https://haveibeenpwned.com/api/v3`) |
| [options.timeoutMs] | <code>number</code> | timeout for the request in milliseconds (default: none) |
| [options.userAgent] | <code>string</code> | a custom string to send as the User-Agent field in the request headers (default: `hibp <version>`) |

**Example**
Expand Down Expand Up @@ -245,6 +248,7 @@ Error
| --- | --- | --- |
| [options] | <code>object</code> | a configuration object |
| [options.baseUrl] | <code>string</code> | a custom base URL for the haveibeenpwned.com API endpoints (default: `https://haveibeenpwned.com/api/v3`) |
| [options.timeoutMs] | <code>number</code> | timeout for the request in milliseconds (default: none) |
| [options.userAgent] | <code>string</code> | a custom string to send as the User-Agent field in the request headers (default: `hibp <version>`) |

**Example**
Expand Down Expand Up @@ -282,6 +286,7 @@ Error
| [options] | <code>object</code> | a configuration object |
| [options.apiKey] | <code>string</code> | an API key from https://haveibeenpwned.com/API/Key (default: undefined) |
| [options.baseUrl] | <code>string</code> | a custom base URL for the haveibeenpwned.com API endpoints (default: `https://haveibeenpwned.com/api/v3`) |
| [options.timeoutMs] | <code>number</code> | timeout for the request in milliseconds (default: none) |
| [options.userAgent] | <code>string</code> | a custom string to send as the User-Agent field in the request headers (default: `hibp <version>`) |

**Example**
Expand Down Expand Up @@ -338,6 +343,7 @@ password data set, or rejects with an Error
| [options.addPadding] | <code>boolean</code> | ask the remote API to add padding to the response to obscure the password prefix (default: `false`) |
| [options.mode] | <code>&#x27;sha1&#x27;</code> \| <code>&#x27;ntlm&#x27;</code> | return SHA-1 or NTLM hashes (default: `sha1`) |
| [options.baseUrl] | <code>string</code> | a custom base URL for the pwnedpasswords.com API endpoints (default: `https://api.pwnedpasswords.com`) |
| [options.timeoutMs] | <code>number</code> | timeout for the request in milliseconds (default: none) |
| [options.userAgent] | <code>string</code> | a custom string to send as the User-Agent field in the request headers (default: `hibp <version>`) |

**Example**
Expand Down Expand Up @@ -382,6 +388,7 @@ the password has been exposed in a breach, or rejects with an Error
| [options] | <code>object</code> | a configuration object |
| [options.addPadding] | <code>boolean</code> | ask the remote API to add padding to the response to obscure the password prefix (default: `false`) |
| [options.baseUrl] | <code>string</code> | a custom base URL for the pwnedpasswords.com API endpoints (default: `https://api.pwnedpasswords.com`) |
| [options.timeoutMs] | <code>number</code> | timeout for the request in milliseconds (default: none) |
| [options.userAgent] | <code>string</code> | a custom string to send as the User-Agent field in the request headers (default: `hibp <version>`) |

**Example**
Expand Down Expand Up @@ -430,6 +437,7 @@ rejects with an Error
| [options.domain] | <code>string</code> | a domain by which to filter the breach results (default: all domains) |
| [options.truncate] | <code>boolean</code> | truncate the breach results to only include the name of each breach (default: true) |
| [options.baseUrl] | <code>string</code> | a custom base URL for the haveibeenpwned.com API endpoints (default: `https://haveibeenpwned.com/api/v3`) |
| [options.timeoutMs] | <code>number</code> | timeout for the request in milliseconds (default: none) |
| [options.userAgent] | <code>string</code> | a custom string to send as the User-Agent field in the request headers (default: `hibp <version>`) |

**Example**
Expand Down Expand Up @@ -481,6 +489,7 @@ subscription status object, or rejects with an Error
| [options] | <code>object</code> | a configuration object |
| [options.apiKey] | <code>string</code> | an API key from https://haveibeenpwned.com/API/Key (default: undefined) |
| [options.baseUrl] | <code>string</code> | a custom base URL for the haveibeenpwned.com API endpoints (default: `https://haveibeenpwned.com/api/v3`) |
| [options.timeoutMs] | <code>number</code> | timeout for the request in milliseconds (default: none) |
| [options.userAgent] | <code>string</code> | a custom string to send as the User-Agent field in the request headers (default: `hibp <version>`) |

**Example**
Expand Down
21 changes: 21 additions & 0 deletions src/__tests__/breach.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,27 @@ describe('breach', () => {
});
});

describe('timeoutMs option', () => {
it('aborts the request after the given value', () => {
expect.assertions(1);
const timeoutMs = 1;
server.use(
http.get('*', async () => {
await new Promise((resolve) => {
setTimeout(resolve, timeoutMs + 1);
});
return new Response(JSON.stringify(VERIFIED_BREACH));
}),
);

return expect(
breach('found', { timeoutMs }),
).rejects.toMatchInlineSnapshot(
`[TimeoutError: The operation was aborted due to timeout]`,
);
});
});

describe('userAgent option', () => {
it('is passed on as a request header', () => {
expect.assertions(1);
Expand Down
21 changes: 21 additions & 0 deletions src/__tests__/breached-account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,27 @@ describe('breachedAccount', () => {
});
});

describe('timeoutMs option', () => {
it('aborts the request after the given value', () => {
expect.assertions(1);
const timeoutMs = 1;
server.use(
http.get('*', async () => {
await new Promise((resolve) => {
setTimeout(resolve, timeoutMs + 1);
});
return new Response(JSON.stringify(BREACHED_ACCOUNT_DATA));
}),
);

return expect(
breachedAccount('breached', { timeoutMs }),
).rejects.toMatchInlineSnapshot(
`[TimeoutError: The operation was aborted due to timeout]`,
);
});
});

describe('userAgent option', () => {
it('is passed on as a request header', () => {
expect.assertions(1);
Expand Down
17 changes: 17 additions & 0 deletions src/__tests__/breaches.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,23 @@ describe('breaches', () => {
});
});

describe('timeoutMs option', () => {
it('aborts the request after the given value', () => {
expect.assertions(1);
const timeoutMs = 1;
server.use(
http.get('*', async () => {
await new Promise((resolve) => {
setTimeout(resolve, timeoutMs + 1);
});
return new Response(JSON.stringify(BREACHES));
}),
);

return expect(breaches({ timeoutMs })).rejects.toThrow();
});
});

describe('userAgent option', () => {
it('is passed on as a request header', () => {
expect.assertions(1);
Expand Down
19 changes: 19 additions & 0 deletions src/__tests__/data-classes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,25 @@ describe('dataClasses', () => {
});
});

describe('timeoutMs option', () => {
it('aborts the request after the given value', () => {
expect.assertions(1);
const timeoutMs = 1;
server.use(
http.get('*', async () => {
await new Promise((resolve) => {
setTimeout(resolve, timeoutMs + 1);
});
return new Response(JSON.stringify(DATA_CLASSES));
}),
);

return expect(dataClasses({ timeoutMs })).rejects.toMatchInlineSnapshot(
`[TimeoutError: The operation was aborted due to timeout]`,
);
});
});

describe('userAgent option', () => {
it('is passed on as a request header', () => {
expect.assertions(1);
Expand Down
21 changes: 21 additions & 0 deletions src/__tests__/paste-account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,27 @@ describe('pasteAccount', () => {
});
});

describe('timeoutMs option', () => {
it('aborts the request after the given value', () => {
expect.assertions(1);
const timeoutMs = 1;
server.use(
http.get('*', async () => {
await new Promise((resolve) => {
setTimeout(resolve, timeoutMs + 1);
});
return new Response(JSON.stringify(PASTE_ACCOUNT_DATA));
}),
);

return expect(
pasteAccount('whatever@example.com', { timeoutMs }),
).rejects.toMatchInlineSnapshot(
`[TimeoutError: The operation was aborted due to timeout]`,
);
});
});

describe('userAgent option', () => {
it('is passed on as a request header', () => {
expect.assertions(1);
Expand Down
21 changes: 21 additions & 0 deletions src/__tests__/pwned-password-range.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,27 @@ describe('pwnedPasswordRange', () => {
});
});

describe('timeoutMs option', () => {
it('aborts the request after the given value', () => {
expect.assertions(1);
const timeoutMs = 1;
server.use(
http.get('*', async () => {
await new Promise((resolve) => {
setTimeout(resolve, timeoutMs + 1);
});
return new Response(SHA1_RESPONSE_BODY);
}),
);

return expect(
pwnedPasswordRange(SHA1_PREFIX, { timeoutMs }),
).rejects.toMatchInlineSnapshot(
`[TimeoutError: The operation was aborted due to timeout]`,
);
});
});

describe('userAgent option', () => {
it('is passed on as a request header', () => {
expect.assertions(2);
Expand Down
21 changes: 21 additions & 0 deletions src/__tests__/pwned-password.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,27 @@ describe('pwnedPassword', () => {
});
});

describe('timeoutMs option', () => {
it('aborts the request after the given value', () => {
expect.assertions(1);
const timeoutMs = 1;
server.use(
http.get('*', async () => {
await new Promise((resolve) => {
setTimeout(resolve, timeoutMs + 1);
});
return new Response(SHA1_RESPONSE_BODY);
}),
);

return expect(
pwnedPassword(PASSWORD, { timeoutMs }),
).rejects.toMatchInlineSnapshot(
`[TimeoutError: The operation was aborted due to timeout]`,
);
});
});

describe('userAgent option', () => {
it('is passed on as a request header', () => {
expect.assertions(1);
Expand Down
21 changes: 21 additions & 0 deletions src/__tests__/subscription-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,27 @@ describe('subscriptionStatus', () => {
});
});

describe('timeoutMs option', () => {
it('aborts the request after the given value', () => {
expect.assertions(1);
const timeoutMs = 1;
server.use(
http.get('*', async () => {
await new Promise((resolve) => {
setTimeout(resolve, timeoutMs + 1);
});
return new Response(JSON.stringify(SUBSCRIPTION_STATUS));
}),
);

return expect(
subscriptionStatus({ timeoutMs }),
).rejects.toMatchInlineSnapshot(
`[TimeoutError: The operation was aborted due to timeout]`,
);
});
});

describe('userAgent option', () => {
it('is passed on as a request header', () => {
expect.assertions(1);
Expand Down
9 changes: 8 additions & 1 deletion src/api/haveibeenpwned/fetch-from-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ function blockedWithRayId(rayId: string) {
* @param {string} [options.baseUrl] a custom base URL for the
* haveibeenpwned.com API endpoints (default:
* `https://haveibeenpwned.com/api/v3`)
* @param {number} [options.timeoutMs] timeout for the request in milliseconds
* (default: none)
* @param {string} [options.userAgent] a custom string to send as the User-Agent
* field in the request headers (default: `hibp <version>`)
* @returns {Promise<ApiData>} a Promise which resolves to the data resulting
Expand All @@ -74,12 +76,14 @@ export async function fetchFromApi(
options: {
apiKey?: string;
baseUrl?: string;
timeoutMs?: number;
userAgent?: string;
} = {},
): Promise<ApiData> {
const {
apiKey,
baseUrl = 'https://haveibeenpwned.com/api/v3',
timeoutMs,
userAgent,
} = options;
const headers: Record<string, string> = {};
Expand All @@ -97,7 +101,10 @@ export async function fetchFromApi(
headers['User-Agent'] = `${name} ${version}`;
}

const config = { headers };
const config: RequestInit = {
headers,
...(timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
};
const url = `${baseUrl.replace(/\/$/g, '')}${endpoint}`;
const response = await fetch(url, config);

Expand Down
5 changes: 5 additions & 0 deletions src/api/pwnedpasswords/fetch-from-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ installUndiciOnNode18();
* @param {object} [options] a configuration object
* @param {string} [options.baseUrl] a custom base URL for the
* pwnedpasswords.com API endpoints (default: `https://api.pwnedpasswords.com`)
* @param {number} [options.timeoutMs] timeout for the request in milliseconds
* (default: none)
* @param {string} [options.userAgent] a custom string to send as the User-Agent
* field in the request headers (default: `hibp <version>`)
* @param {boolean} [options.addPadding] ask the remote API to add padding to
Expand All @@ -28,13 +30,15 @@ export async function fetchFromApi(
endpoint: string,
options: {
baseUrl?: string;
timeoutMs?: number;
userAgent?: string;
addPadding?: boolean;
mode?: 'sha1' | 'ntlm';
} = {},
): Promise<string> {
const {
baseUrl = 'https://api.pwnedpasswords.com',
timeoutMs,
userAgent,
addPadding = false,
mode = 'sha1',
Expand All @@ -45,6 +49,7 @@ export async function fetchFromApi(
...(userAgent ? { 'User-Agent': userAgent } : {}),
...(addPadding ? { 'Add-Padding': 'true' } : {}),
},
...(timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
};
const url = `${baseUrl.replace(/\/$/g, '')}${endpoint}?mode=${mode}`;
const response = await fetch(url, config);
Expand Down

0 comments on commit b6076f2

Please sign in to comment.