Repository: Redocly/redocly-cli
Pull request #1150 (2023-07-04 13:56:31)

fix: add timeout for unnecessary calls
## What/Why/How?

Adding fetch request timeout to prevent hanging the tool when executing commands. 

## Reference

https://github.com/Redocly/redocly-cli/issues/1146

## Testing

## Screenshots (optional)

## Check yourself

- [x] Code is linted
- [x] Tested with redoc/reference-docs/workflows
- [x] All new/updated code is covered with tests

## Security

- [x] Security impact of change has been considered
- [x] Code follows company security practices and guidelines


github-actions[bot]: | Command | Mean [s] | Min [s] | Max [s] | Relative |
|:---|---:|---:|---:|---:|
| `REDOCLY_TELEMETRY=off redocly lint packages/core/src/benchmark/benches/rebilly.yaml` | 1.463 ± 0.038 | 1.413 | 1.535 | 1.00 |
| `REDOCLY_TELEMETRY=off redocly-next lint packages/core/src/benchmark/benches/rebilly.yaml` | 1.473 ± 0.065 | 1.418 | 1.640 | 1.01 ± 0.05 |

<!-- thollander/actions-comment-pull-request "hyperfine-comparison" -->
github-actions[bot]: <!-- jest coverage report action for options with hash 3339ae53a38c44382c0fb4491bdb282d -->

## Coverage report 

❌ An unexpected error occurred. For more details, [check console](https://github.com/Redocly/redocly-cli/actions/runs/5455549047) 
```
Error: The process '/opt/hostedtoolcache/node/18.16.1/x64/bin/npm' failed with exit code 1
```

| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | Category   | Percentage | Covered / Total |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :--------- | :--------- | :-------------: |
|                                                                           🟡                                                                           | Statements | 74.3%      |    3709/4992    |
|                                                                           🟡                                                                           | Branches   | 65.38%     |    2060/3151    |
|                                                                           🟡                                                                           | Functions  | 66.45%     |     616/927     |
|                                                                           🟡                                                                           | Lines      | 74.38%     |    3460/4652    |

<details><summary>Show new covered files 🐣</summary>
<br/>

| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | File                                                                           | Statements | Branches | Functions | Lines  |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------- | :--------- | :------- | :-------- | :----- |
|                                                                           🔴                                                                           | <div title="cli/src/fetch-with-timeout.ts">`...` / fetch-with-timeout.ts</div> | 25%        | 0%       | 0%        | 27.27% |

</details>


## Test suite run failed
Failed tests: 0/614. Failed suites: 0/89.

<p align="right">Report generated by <a href="https://github.com/ArtiomTr/jest-coverage-report-action">🧪jest coverage report action</a> from a7a564701475f9cca608945731108cd0981ed87e</p>


Patch:
diff --git a/packages/cli/src/fetch-with-timeout.ts b/packages/cli/src/fetch-with-timeout.ts
new file mode 100644
index 00000000..2ff94371
--- /dev/null
+++ b/packages/cli/src/fetch-with-timeout.ts
@@ -0,0 +1,17 @@
+import nodeFetch from 'node-fetch';
+
+const TIMEOUT = 3000;
+
+export default async (url: string, options = {}) => {
+  if (!AbortController) {
+    return nodeFetch(url, options);
+  }
+
+  const controller = new AbortController();
+  const timeout = setTimeout(() => {
+    controller.abort();
+  }, TIMEOUT);
+  const res = await nodeFetch(url, { signal: controller.signal, ...options });
+  clearTimeout(timeout);
+  return res;
+};
diff --git a/packages/cli/src/update-version-notifier.ts b/packages/cli/src/update-version-notifier.ts
index 11102966..704abe0f 100644
--- a/packages/cli/src/update-version-notifier.ts
+++ b/packages/cli/src/update-version-notifier.ts
@@ -2,7 +2,7 @@ import { tmpdir } from 'os';
 import { join } from 'path';
 import { existsSync, writeFileSync, readFileSync, statSync } from 'fs';
 import { compare } from 'semver';
-import fetch from 'node-fetch';
+import fetch from './fetch-with-timeout';
 import { cyan, green, yellow } from 'colorette';
 import { cleanColors } from './utils';
 
diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts
index 3062b3eb..f1a7f810 100644
--- a/packages/cli/src/utils.ts
+++ b/packages/cli/src/utils.ts
@@ -1,4 +1,4 @@
-import fetch from 'node-fetch';
+import fetch from './fetch-with-timeout';
 import { basename, dirname, extname, join, resolve, relative, isAbsolute } from 'path';
 import { blue, gray, green, red, yellow } from 'colorette';
 import { performance } from 'perf_hooks';


Test patch:
diff --git a/packages/cli/src/__tests__/fetch-with-timeout.test.ts b/packages/cli/src/__tests__/fetch-with-timeout.test.ts
new file mode 100644
index 00000000..598fc82e
--- /dev/null
+++ b/packages/cli/src/__tests__/fetch-with-timeout.test.ts
@@ -0,0 +1,35 @@
+import fetchWithTimeout from '../fetch-with-timeout';
+import nodeFetch from 'node-fetch';
+
+jest.mock('node-fetch');
+
+describe('fetchWithTimeout', () => {
+  afterEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it('should use bare node-fetch if AbortController is not available', async () => {
+    // @ts-ignore
+    global.AbortController = undefined;
+    // @ts-ignore
+    global.setTimeout = jest.fn();
+    await fetchWithTimeout('url', { method: 'GET' });
+
+    expect(nodeFetch).toHaveBeenCalledWith('url', { method: 'GET' });
+
+    expect(global.setTimeout).toHaveBeenCalledTimes(0);
+  });
+
+  it('should call node-fetch with signal if AbortController is  available', async () => {
+    global.AbortController = jest.fn().mockImplementation(() => ({ signal: 'something' }));
+    // @ts-ignore
+    global.setTimeout = jest.fn();
+
+    global.clearTimeout = jest.fn();
+    await fetchWithTimeout('url');
+
+    expect(global.setTimeout).toHaveBeenCalledTimes(1);
+    expect(nodeFetch).toHaveBeenCalledWith('url', { signal: 'something' });
+    expect(global.clearTimeout).toHaveBeenCalledTimes(1);
+  });
+});