Repository: Redocly/redocly-cli
Pull request #1676 (2024-08-22 12:40:33)

chore: send user-agent to Reunite
## What/Why/How?
Send `user-agent` header to Reunite for sake of `push` and `push-status` commands telemetry (command usage per version).

## Reference
Internal request
## Testing

## Screenshots (optional)

## Check yourself

- [x] Code changed? - Tested with redoc/reference-docs/workflows (internal)
- [x] All new/updated code is covered with tests
- [ ] New package installed? - Tested in different environments (browser/node)

## Security

- [x] Security impact of change has been considered
- [x] Code follows company security practices and guidelines


changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: 8e8a9f47486cd1d2884bb0ced619ce4607f3e5cb

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/Redocly/redocly-cli/new/chore/send-user-agent-to-reunite?filename=.changeset/three-wasps-type.md&value=---%0A%22%40redocly%2Fcli%22%3A%20patch%0A---%0A%0Achore%3A%20send%20user-agent%20to%20Reunite%0A)


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/10508267143) 
```
Error: The process '/opt/hostedtoolcache/node/20.16.0/x64/bin/npm' failed with exit code 2
```

| <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 | 77.74%     |    4759/6122    |
|                                                                           🟡                                                                           | Branches   | 66.15%     |    1974/2984    |
|                                                                           🟡                                                                           | Functions  | 72.04%     |     786/1091    |
|                                                                           🟡                                                                           | Lines      | 77.9%      |    4491/5765    |


<details><summary>Show files with reduced coverage 🔻</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/cms/commands/push-status.ts">`...` / push-status.ts</div> | <div title="Base coverage is: 91.57%">92.59% (+1.03% 🔼)</div> | 76.56%   | 100%      | <div title="Base coverage is: 91.57%">92.59% (+1.03% 🔼)</div> |

</details>

## Test suite run success
768 tests passing in 108 suites.

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

github-actions[bot]: | Command | Mean [s] | Min [s] | Max [s] | Relative |
|:---|---:|---:|---:|---:|
| `redocly lint packages/core/src/benchmark/benches/rebilly.yaml` | 1.003 ± 0.019 | 0.976 | 1.030 | 1.00 |
| `redocly-next lint packages/core/src/benchmark/benches/rebilly.yaml` | 1.005 ± 0.019 | 0.975 | 1.051 | 1.00 ± 0.03 |

<!-- thollander/actions-comment-pull-request "latest-vs-next-comparison" -->
roman-sainchuk: Tested in Workflows. `push` command works as expected.

Patch:
diff --git a/packages/cli/src/cms/api/api-client.ts b/packages/cli/src/cms/api/api-client.ts
index 3a5183db..bdf790bf 100644
--- a/packages/cli/src/cms/api/api-client.ts
+++ b/packages/cli/src/cms/api/api-client.ts
@@ -1,7 +1,8 @@
-import fetch from 'node-fetch';
 import * as FormData from 'form-data';
-import { getProxyAgent } from '@redocly/openapi-core';
-import fetchWithTimeout from '../../utils/fetch-with-timeout';
+import fetchWithTimeout, {
+  type FetchWithTimeoutOptions,
+  DEFAULT_FETCH_TIMEOUT,
+} from '../../utils/fetch-with-timeout';
 
 import type { Response } from 'node-fetch';
 import type { ReadStream } from 'fs';
@@ -12,41 +13,76 @@ import type {
   UpsertRemoteResponse,
 } from './types';
 
-class RemotesApiClient {
-  constructor(private readonly domain: string, private readonly apiKey: string) {}
+export class ReuniteApiError extends Error {
+  constructor(message: string, public status: number) {
+    super(message);
+  }
+}
 
-  private async getParsedResponse<T>(response: Response): Promise<T> {
+class ReuniteBaseApiClient {
+  constructor(protected version: string, protected command: string) {}
+
+  protected async getParsedResponse<T>(response: Response): Promise<T> {
     const responseBody = await response.json();
 
     if (response.ok) {
       return responseBody as T;
     }
 
-    throw new Error(responseBody.title || response.statusText);
+    throw new ReuniteApiError(
+      `${responseBody.title || response.statusText || 'Unknown error'}.`,
+      response.status
+    );
   }
 
-  async getDefaultBranch(organizationId: string, projectId: string) {
-    const response = await fetchWithTimeout(
-      `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/source`,
-      {
-        method: 'GET',
-        headers: {
-          'Content-Type': 'application/json',
-          Authorization: `Bearer ${this.apiKey}`,
-        },
-      }
-    );
+  protected request(url: string, options: FetchWithTimeoutOptions) {
+    const headers = {
+      ...options.headers,
+      'user-agent': `redocly-cli/${this.version.trim()} ${this.command}`,
+    };
 
-    if (!response) {
-      throw new Error(`Failed to get default branch.`);
-    }
+    return fetchWithTimeout(url, {
+      ...options,
+      headers,
+    });
+  }
+}
 
+class RemotesApiClient extends ReuniteBaseApiClient {
+  constructor(
+    private readonly domain: string,
+    private readonly apiKey: string,
+    version: string,
+    command: string
+  ) {
+    super(version, command);
+  }
+
+  async getDefaultBranch(organizationId: string, projectId: string) {
     try {
+      const response = await this.request(
+        `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/source`,
+        {
+          timeout: DEFAULT_FETCH_TIMEOUT,
+          method: 'GET',
+          headers: {
+            'Content-Type': 'application/json',
+            Authorization: `Bearer ${this.apiKey}`,
+          },
+        }
+      );
+
       const source = await this.getParsedResponse<ProjectSourceResponse>(response);
 
       return source.branchName;
     } catch (err) {
-      throw new Error(`Failed to fetch default branch: ${err.message || 'Unknown error'}`);
+      const message = `Failed to fetch default branch. ${err.message}`;
+
+      if (err instanceof ReuniteApiError) {
+        throw new ReuniteApiError(message, err.status);
+      }
+
+      throw new Error(message);
     }
   }
 
@@ -58,31 +94,34 @@ class RemotesApiClient {
       mountBranchName: string;
     }
   ): Promise<UpsertRemoteResponse> {
-    const response = await fetchWithTimeout(
-      `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes`,
-      {
-        method: 'POST',
-        headers: {
-          'Content-Type': 'application/json',
-          Authorization: `Bearer ${this.apiKey}`,
-        },
-        body: JSON.stringify({
-          mountPath: remote.mountPath,
-          mountBranchName: remote.mountBranchName,
-          type: 'CICD',
-          autoMerge: true,
-        }),
-      }
-    );
-
-    if (!response) {
-      throw new Error(`Failed to upsert.`);
-    }
-
     try {
+      const response = await this.request(
+        `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes`,
+        {
+          timeout: DEFAULT_FETCH_TIMEOUT,
+          method: 'POST',
+          headers: {
+            'Content-Type': 'application/json',
+            Authorization: `Bearer ${this.apiKey}`,
+          },
+          body: JSON.stringify({
+            mountPath: remote.mountPath,
+            mountBranchName: remote.mountBranchName,
+            type: 'CICD',
+            autoMerge: true,
+          }),
+        }
+      );
+
       return await this.getParsedResponse<UpsertRemoteResponse>(response);
     } catch (err) {
-      throw new Error(`Failed to upsert remote: ${err.message || 'Unknown error'}`);
+      const message = `Failed to upsert remote. ${err.message}`;
+
+      if (err instanceof ReuniteApiError) {
+        throw new ReuniteApiError(message, err.status);
+      }
+
+      throw new Error(message);
     }
   }
 
@@ -110,46 +149,61 @@ class RemotesApiClient {
     }
 
     payload.isMainBranch && formData.append('isMainBranch', 'true');
-
-    const response = await fetch(
-      `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/pushes`,
-      {
-        method: 'POST',
-        headers: {
-          Authorization: `Bearer ${this.apiKey}`,
-        },
-        body: formData,
-        agent: getProxyAgent(),
-      }
-    );
-
     try {
+      const response = await this.request(
+        `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/pushes`,
+        {
+          method: 'POST',
+          headers: {
+            Authorization: `Bearer ${this.apiKey}`,
+          },
+          body: formData,
+        }
+      );
+
       return await this.getParsedResponse<PushResponse>(response);
     } catch (err) {
-      throw new Error(`Failed to push: ${err.message || 'Unknown error'}`);
-    }
-  }
+      const message = `Failed to push. ${err.message}`;
 
-  async getRemotesList(organizationId: string, projectId: string, mountPath: string) {
-    const response = await fetchWithTimeout(
-      `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes?filter=mountPath:/${mountPath}/`,
-      {
-        method: 'GET',
-        headers: {
-          'Content-Type': 'application/json',
-          Authorization: `Bearer ${this.apiKey}`,
-        },
+      if (err instanceof ReuniteApiError) {
+        throw new ReuniteApiError(message, err.status);
       }
-    );
 
-    if (!response) {
-      throw new Error(`Failed to get remotes list.`);
+      throw new Error(message);
     }
+  }
 
+  async getRemotesList({
+    organizationId,
+    projectId,
+    mountPath,
+  }: {
+    organizationId: string;
+    projectId: string;
+    mountPath: string;
+  }) {
     try {
+      const response = await this.request(
+        `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes?filter=mountPath:/${mountPath}/`,
+        {
+          timeout: DEFAULT_FETCH_TIMEOUT,
+          method: 'GET',
+          headers: {
+            'Content-Type': 'application/json',
+            Authorization: `Bearer ${this.apiKey}`,
+          },
+        }
+      );
+
       return await this.getParsedResponse<ListRemotesResponse>(response);
     } catch (err) {
-      throw new Error(`Failed to get remote list: ${err.message || 'Unknown error'}`);
+      const message = `Failed to get remote list. ${err.message}`;
+
+      if (err instanceof ReuniteApiError) {
+        throw new ReuniteApiError(message, err.status);
+      }
+
+      throw new Error(message);
     }
   }
 
@@ -162,25 +216,28 @@ class RemotesApiClient {
     projectId: string;
     pushId: string;
   }) {
-    const response = await fetchWithTimeout(
-      `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/pushes/${pushId}`,
-      {
-        method: 'GET',
-        headers: {
-          'Content-Type': 'application/json',
-          Authorization: `Bearer ${this.apiKey}`,
-        },
-      }
-    );
-
-    if (!response) {
-      throw new Error(`Failed to get push status.`);
-    }
-
     try {
+      const response = await this.request(
+        `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/pushes/${pushId}`,
+        {
+          timeout: DEFAULT_FETCH_TIMEOUT,
+          method: 'GET',
+          headers: {
+            'Content-Type': 'application/json',
+            Authorization: `Bearer ${this.apiKey}`,
+          },
+        }
+      );
+
       return await this.getParsedResponse<PushResponse>(response);
     } catch (err) {
-      throw new Error(`Failed to get push status: ${err.message || 'Unknown error'}`);
+      const message = `Failed to get push status. ${err.message}`;
+
+      if (err instanceof ReuniteApiError) {
+        throw new ReuniteApiError(message, err.status);
+      }
+
+      throw new Error(message);
     }
   }
 }
@@ -188,8 +245,18 @@ class RemotesApiClient {
 export class ReuniteApiClient {
   remotes: RemotesApiClient;
 
-  constructor(public domain: string, private readonly apiKey: string) {
-    this.remotes = new RemotesApiClient(this.domain, this.apiKey);
+  constructor({
+    domain,
+    apiKey,
+    version,
+    command,
+  }: {
+    domain: string;
+    apiKey: string;
+    version: string;
+    command: 'push' | 'push-status';
+  }) {
+    this.remotes = new RemotesApiClient(domain, apiKey, version, command);
   }
 }
 
diff --git a/packages/cli/src/cms/commands/push-status.ts b/packages/cli/src/cms/commands/push-status.ts
index 428957d9..f819d18c 100644
--- a/packages/cli/src/cms/commands/push-status.ts
+++ b/packages/cli/src/cms/commands/push-status.ts
@@ -4,7 +4,7 @@ import { Spinner } from '../../utils/spinner';
 import { DeploymentError } from '../utils';
 import { ReuniteApiClient, getApiKeys, getDomain } from '../api';
 import { capitalize } from '../../utils/js-utils';
-import { retryUntilConditionMet } from './utils';
+import { handleReuniteError, retryUntilConditionMet } from './utils';
 
 import type { OutputFormat } from '@redocly/openapi-core';
 import type { CommandArgs } from '../../wrapper';
@@ -41,7 +41,8 @@ export interface PushStatusSummary {
 export async function handlePushStatus({
   argv,
   config,
-}: CommandArgs<PushStatusOptions>): Promise<PushStatusSummary | undefined> {
+  version,
+}: CommandArgs<PushStatusOptions>): Promise<PushStatusSummary | void> {
   const startedAt = performance.now();
   const spinner = new Spinner();
 
@@ -67,7 +68,7 @@ export async function handlePushStatus({
 
   try {
     const apiKey = getApiKeys(domain);
-    const client = new ReuniteApiClient(domain, apiKey);
+    const client = new ReuniteApiClient({ domain, apiKey, version, command: 'push-status' });
 
     let pushResponse: PushResponse;
 
@@ -178,12 +179,7 @@ export async function handlePushStatus({
   } catch (err) {
     spinner.stop(); // Spinner can block process exit, so we need to stop it explicitly.
 
-    const message =
-      err instanceof DeploymentError
-        ? err.message
-        : `✗ Failed to get push status. Reason: ${err.message}\n`;
-    exitWithError(message);
-    return;
+    handleReuniteError('✗ Failed to get push status.', err);
   } finally {
     spinner.stop(); // Spinner can block process exit, so we need to stop it explicitly.
   }
diff --git a/packages/cli/src/cms/commands/push.ts b/packages/cli/src/cms/commands/push.ts
index 6f83606c..bef249c0 100644
--- a/packages/cli/src/cms/commands/push.ts
+++ b/packages/cli/src/cms/commands/push.ts
@@ -3,9 +3,10 @@ import * as path from 'path';
 import { slash } from '@redocly/openapi-core';
 import { pluralize } from '@redocly/openapi-core/lib/utils';
 import { green, yellow } from 'colorette';
-import { exitWithError, HandledError, printExecutionTime } from '../../utils/miscellaneous';
+import { exitWithError, printExecutionTime } from '../../utils/miscellaneous';
 import { handlePushStatus } from './push-status';
 import { ReuniteApiClient, getDomain, getApiKeys } from '../api';
+import { handleReuniteError } from './utils';
 
 import type { OutputFormat } from '@redocly/openapi-core';
 import type { CommandArgs } from '../../wrapper';
@@ -52,7 +53,7 @@ export async function handlePush({
   const orgId = organization || config.organization;
 
   if (!argv.message || !argv.author || !argv.branch) {
-    exitWithError('Error: message, author and branch are required for push to the CMS.');
+    exitWithError('Error: message, author and branch are required for push to the Reunite.');
   }
 
   if (!orgId) {
@@ -85,7 +86,7 @@ export async function handlePush({
       return printExecutionTime('push', startedAt, `No files to upload`);
     }
 
-    const client = new ReuniteApiClient(domain, apiKey);
+    const client = new ReuniteApiClient({ domain, apiKey, version, command: 'push' });
     const projectDefaultBranch = await client.remotes.getDefaultBranch(orgId, projectId);
     const remote = await client.remotes.upsert(orgId, projectId, {
       mountBranchName: projectDefaultBranch,
@@ -158,9 +159,7 @@ export async function handlePush({
       pushId: id,
     };
   } catch (err) {
-    const message =
-      err instanceof HandledError ? '' : `✗ File upload failed. Reason: ${err.message}`;
-    exitWithError(message);
+    handleReuniteError('✗ File upload failed.', err);
   }
 }
 
diff --git a/packages/cli/src/cms/commands/utils.ts b/packages/cli/src/cms/commands/utils.ts
index 9646fcda..ba80561d 100644
--- a/packages/cli/src/cms/commands/utils.ts
+++ b/packages/cli/src/cms/commands/utils.ts
@@ -1,4 +1,8 @@
 import { pause } from '@redocly/openapi-core';
+import { DeploymentError } from '../utils';
+import { exitWithError } from '../../utils/miscellaneous';
+
+import type { ReuniteApiError } from '../api';
 
 /**
  * This function retries an operation until a condition is met or a timeout is exceeded.
@@ -39,7 +43,7 @@ export async function retryUntilConditionMet<T>({
     if (condition(result)) {
       return result;
     } else if (Date.now() - startTime > retryTimeoutMs) {
-      throw new Error('Timeout exceeded');
+      throw new Error('Timeout exceeded.');
     } else {
       onConditionNotMet?.(result);
       await pause(retryIntervalMs);
@@ -50,3 +54,13 @@ export async function retryUntilConditionMet<T>({
 
   return attempt();
 }
+
+export function handleReuniteError(
+  message: string,
+  error: ReuniteApiError | DeploymentError | Error
+) {
+  const errorMessage =
+    error instanceof DeploymentError ? error.message : `${message} Reason: ${error.message}\n`;
+
+  return exitWithError(errorMessage);
+}
diff --git a/packages/cli/src/utils/fetch-with-timeout.ts b/packages/cli/src/utils/fetch-with-timeout.ts
index 82bd29b3..5bfcba4d 100644
--- a/packages/cli/src/utils/fetch-with-timeout.ts
+++ b/packages/cli/src/utils/fetch-with-timeout.ts
@@ -1,24 +1,33 @@
-import nodeFetch from 'node-fetch';
+import nodeFetch, { type RequestInit } from 'node-fetch';
 import AbortController from 'abort-controller';
 import { getProxyAgent } from '@redocly/openapi-core';
 
-const TIMEOUT = 3000;
+export const DEFAULT_FETCH_TIMEOUT = 3000;
 
-export default async (url: string, options = {}) => {
-  try {
-    const controller = new AbortController();
-    const timeout = setTimeout(() => {
-      controller.abort();
-    }, TIMEOUT);
+export type FetchWithTimeoutOptions = RequestInit & {
+  timeout?: number;
+};
 
-    const res = await nodeFetch(url, {
-      signal: controller.signal,
+export default async (url: string, { timeout, ...options }: FetchWithTimeoutOptions = {}) => {
+  if (!timeout) {
+    return nodeFetch(url, {
       ...options,
       agent: getProxyAgent(),
     });
-    clearTimeout(timeout);
-    return res;
-  } catch (e) {
-    return;
   }
+
+  const controller = new AbortController();
+  const timeoutId = setTimeout(() => {
+    controller.abort();
+  }, timeout);
+
+  const res = await nodeFetch(url, {
+    signal: controller.signal,
+    ...options,
+    agent: getProxyAgent(),
+  });
+
+  clearTimeout(timeoutId);
+
+  return res;
 };
diff --git a/packages/cli/src/utils/miscellaneous.ts b/packages/cli/src/utils/miscellaneous.ts
index 117c00f4..d188b961 100644
--- a/packages/cli/src/utils/miscellaneous.ts
+++ b/packages/cli/src/utils/miscellaneous.ts
@@ -22,7 +22,7 @@ import { deprecatedRefDocsSchema } from '@redocly/config/lib/reference-docs-conf
 import { outputExtensions } from '../types';
 import { version } from './update-version-notifier';
 import { DESTINATION_REGEX } from '../commands/push';
-import fetch from './fetch-with-timeout';
+import fetch, { DEFAULT_FETCH_TIMEOUT } from './fetch-with-timeout';
 
 import type { Arguments } from 'yargs';
 import type {
@@ -569,6 +569,7 @@ export async function sendTelemetry(
       spec_full_version,
     };
     await fetch(`https://api.redocly.com/registry/telemetry/cli`, {
+      timeout: DEFAULT_FETCH_TIMEOUT,
       method: 'POST',
       headers: {
         'content-type': 'application/json',
diff --git a/packages/cli/src/utils/update-version-notifier.ts b/packages/cli/src/utils/update-version-notifier.ts
index 8adff223..ce8ce9c6 100644
--- a/packages/cli/src/utils/update-version-notifier.ts
+++ b/packages/cli/src/utils/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 './fetch-with-timeout';
+import fetch, { DEFAULT_FETCH_TIMEOUT } from './fetch-with-timeout';
 import { cyan, green, yellow } from 'colorette';
 import { cleanColors } from './miscellaneous';
 
@@ -34,10 +34,16 @@ const isNewVersionAvailable = (current: string, latest: string) => compare(curre
 
 const getLatestVersion = async (packageName: string): Promise<string | undefined> => {
   const latestUrl = `http://registry.npmjs.org/${packageName}/latest`;
-  const response = await fetch(latestUrl);
-  if (!response) return;
-  const info = await response.json();
-  return info.version;
+
+  try {
+    const response = await fetch(latestUrl, { timeout: DEFAULT_FETCH_TIMEOUT });
+    const info = await response.json();
+
+    return info.version;
+  } catch {
+    // Do nothing
+    return;
+  }
 };
 
 export const cacheLatestVersion = () => {


Test patch:
diff --git a/packages/cli/src/__tests__/fetch-with-timeout.test.ts b/packages/cli/src/__tests__/fetch-with-timeout.test.ts
index e3db223d..20c5e844 100644
--- a/packages/cli/src/__tests__/fetch-with-timeout.test.ts
+++ b/packages/cli/src/__tests__/fetch-with-timeout.test.ts
@@ -1,23 +1,53 @@
 import AbortController from 'abort-controller';
 import fetchWithTimeout from '../utils/fetch-with-timeout';
 import nodeFetch from 'node-fetch';
+import { getProxyAgent } from '@redocly/openapi-core';
+import { HttpsProxyAgent } from 'https-proxy-agent';
 
 jest.mock('node-fetch');
+jest.mock('@redocly/openapi-core');
 
 describe('fetchWithTimeout', () => {
+  beforeAll(() => {
+    // @ts-ignore
+    global.setTimeout = jest.fn();
+    global.clearTimeout = jest.fn();
+  });
+
+  beforeEach(() => {
+    (getProxyAgent as jest.Mock).mockReturnValueOnce(undefined);
+  });
+
   afterEach(() => {
     jest.clearAllMocks();
   });
 
   it('should call node-fetch with signal', async () => {
-    // @ts-ignore
-    global.setTimeout = jest.fn();
-
-    global.clearTimeout = jest.fn();
-    await fetchWithTimeout('url');
+    await fetchWithTimeout('url', { timeout: 1000 });
 
     expect(global.setTimeout).toHaveBeenCalledTimes(1);
-    expect(nodeFetch).toHaveBeenCalledWith('url', { signal: new AbortController().signal });
+    expect(nodeFetch).toHaveBeenCalledWith('url', {
+      signal: new AbortController().signal,
+      agent: undefined,
+    });
     expect(global.clearTimeout).toHaveBeenCalledTimes(1);
   });
+
+  it('should call node-fetch with proxy agent', async () => {
+    (getProxyAgent as jest.Mock).mockRestore();
+    const proxyAgent = new HttpsProxyAgent('http://localhost');
+    (getProxyAgent as jest.Mock).mockReturnValueOnce(proxyAgent);
+
+    await fetchWithTimeout('url');
+
+    expect(nodeFetch).toHaveBeenCalledWith('url', { agent: proxyAgent });
+  });
+
+  it('should call node-fetch without signal when timeout is not passed', async () => {
+    await fetchWithTimeout('url');
+
+    expect(global.setTimeout).not.toHaveBeenCalled();
+    expect(nodeFetch).toHaveBeenCalledWith('url', { agent: undefined });
+    expect(global.clearTimeout).not.toHaveBeenCalled();
+  });
 });
diff --git a/packages/cli/src/cms/api/__tests__/api.client.test.ts b/packages/cli/src/cms/api/__tests__/api.client.test.ts
index cb7a2c13..f85a341e 100644
--- a/packages/cli/src/cms/api/__tests__/api.client.test.ts
+++ b/packages/cli/src/cms/api/__tests__/api.client.test.ts
@@ -1,7 +1,7 @@
 import fetch, { Response } from 'node-fetch';
 import * as FormData from 'form-data';
 
-import { ReuniteApiClient, PushPayload } from '../api-client';
+import { ReuniteApiClient, PushPayload, ReuniteApiError } from '../api-client';
 
 jest.mock('node-fetch', () => ({
   default: jest.fn(),
@@ -16,12 +16,15 @@ describe('ApiClient', () => {
   const testDomain = 'test-domain.com';
   const testOrg = 'test-org';
   const testProject = 'test-project';
+  const version = '1.0.0';
+  const command = 'push';
+  const expectedUserAgent = `redocly-cli/${version} ${command}`;
 
   describe('getDefaultBranch()', () => {
     let apiClient: ReuniteApiClient;
 
     beforeEach(() => {
-      apiClient = new ReuniteApiClient(testDomain, testToken);
+      apiClient = new ReuniteApiClient({ domain: testDomain, apiKey: testToken, version, command });
     });
 
     it('should get default project branch', async () => {
@@ -41,6 +44,7 @@ describe('ApiClient', () => {
           headers: {
             'Content-Type': 'application/json',
             Authorization: `Bearer ${testToken}`,
+            'user-agent': expectedUserAgent,
           },
           signal: expect.any(Object),
         }
@@ -62,7 +66,7 @@ describe('ApiClient', () => {
       });
 
       await expect(apiClient.remotes.getDefaultBranch(testOrg, testProject)).rejects.toThrow(
-        new Error('Failed to fetch default branch: Project source not found')
+        new ReuniteApiError('Failed to fetch default branch. Project source not found.', 404)
       );
     });
 
@@ -76,7 +80,7 @@ describe('ApiClient', () => {
       });
 
       await expect(apiClient.remotes.getDefaultBranch(testOrg, testProject)).rejects.toThrow(
-        new Error('Failed to fetch default branch: Not found')
+        new ReuniteApiError('Failed to fetch default branch. Not found.', 404)
       );
     });
   });
@@ -89,7 +93,7 @@ describe('ApiClient', () => {
     let apiClient: ReuniteApiClient;
 
     beforeEach(() => {
-      apiClient = new ReuniteApiClient(testDomain, testToken);
+      apiClient = new ReuniteApiClient({ domain: testDomain, apiKey: testToken, version, command });
     });
 
     it('should upsert remote', async () => {
@@ -116,6 +120,7 @@ describe('ApiClient', () => {
           headers: {
             'Content-Type': 'application/json',
             Authorization: `Bearer ${testToken}`,
+            'user-agent': expectedUserAgent,
           },
           body: JSON.stringify({
             mountPath: remotePayload.mountPath,
@@ -144,8 +149,9 @@ describe('ApiClient', () => {
       });
 
       await expect(apiClient.remotes.upsert(testOrg, testProject, remotePayload)).rejects.toThrow(
-        new Error(
-          'Failed to upsert remote: Not allowed to mount remote outside of project content path: /docs'
+        new ReuniteApiError(
+          'Failed to upsert remote. Not allowed to mount remote outside of project content path: /docs.',
+          403
         )
       );
     });
@@ -153,6 +159,7 @@ describe('ApiClient', () => {
     it('should throw statusText error if response is not ok', async () => {
       mockFetchResponse({
         ok: false,
+        status: 404,
         statusText: 'Not found',
         json: jest.fn().mockResolvedValue({
           unknownField: 'unknown-error',
@@ -160,7 +167,7 @@ describe('ApiClient', () => {
       });
 
       await expect(apiClient.remotes.upsert(testOrg, testProject, remotePayload)).rejects.toThrow(
-        new Error('Failed to upsert remote: Not found')
+        new ReuniteApiError('Failed to upsert remote. Not found.', 404)
       );
     });
   });
@@ -200,7 +207,7 @@ describe('ApiClient', () => {
     let apiClient: ReuniteApiClient;
 
     beforeEach(() => {
-      apiClient = new ReuniteApiClient(testDomain, testToken);
+      apiClient = new ReuniteApiClient({ domain: testDomain, apiKey: testToken, version, command });
     });
 
     it('should push to remote', async () => {
@@ -234,6 +241,7 @@ describe('ApiClient', () => {
           method: 'POST',
           headers: {
             Authorization: `Bearer ${testToken}`,
+            'user-agent': expectedUserAgent,
           },
         })
       );
@@ -258,12 +266,13 @@ describe('ApiClient', () => {
 
       await expect(
         apiClient.remotes.push(testOrg, testProject, pushPayload, filesMock)
-      ).rejects.toThrow(new Error('Failed to push: Cannot push to remote'));
+      ).rejects.toThrow(new ReuniteApiError('Failed to push. Cannot push to remote.', 403));
     });
 
     it('should throw statusText error if response is not ok', async () => {
       mockFetchResponse({
         ok: false,
+        status: 404,
         statusText: 'Not found',
         json: jest.fn().mockResolvedValue({
           unknownField: 'unknown-error',
@@ -272,7 +281,7 @@ describe('ApiClient', () => {
 
       await expect(
         apiClient.remotes.push(testOrg, testProject, pushPayload, filesMock)
-      ).rejects.toThrow(new Error('Failed to push: Not found'));
+      ).rejects.toThrow(new ReuniteApiError('Failed to push. Not found.', 404));
     });
   });
 });
diff --git a/packages/cli/src/cms/commands/__tests__/push-status.test.ts b/packages/cli/src/cms/commands/__tests__/push-status.test.ts
index 8bcd4cfa..db02dcbc 100644
--- a/packages/cli/src/cms/commands/__tests__/push-status.test.ts
+++ b/packages/cli/src/cms/commands/__tests__/push-status.test.ts
@@ -644,7 +644,7 @@ describe('handlePushStatus()', () => {
           version: 'cli-version',
         })
       ).rejects.toThrowErrorMatchingInlineSnapshot(`
-        "✗ Failed to get push status. Reason: Timeout exceeded
+        "✗ Failed to get push status. Reason: Timeout exceeded.
         "
       `);
     });
diff --git a/packages/cli/src/cms/commands/__tests__/push.test.ts b/packages/cli/src/cms/commands/__tests__/push.test.ts
index 2ac0845d..31870d69 100644
--- a/packages/cli/src/cms/commands/__tests__/push.test.ts
+++ b/packages/cli/src/cms/commands/__tests__/push.test.ts
@@ -1,7 +1,7 @@
 import * as fs from 'fs';
 import * as path from 'path';
 import { handlePush } from '../push';
-import { ReuniteApiClient } from '../../api';
+import { ReuniteApiClient, ReuniteApiError } from '../../api';
 
 const remotes = {
   push: jest.fn(),
@@ -332,6 +332,53 @@ describe('handlePush()', () => {
       version: 'cli-version',
     });
 
-    expect(ReuniteApiClient).toBeCalledWith('test-domain-from-env', 'test-api-key');
+    expect(ReuniteApiClient).toBeCalledWith({
+      domain: 'test-domain-from-env',
+      apiKey: 'test-api-key',
+      version: 'cli-version',
+      command: 'push',
+    });
+  });
+
+  it('should print error message', async () => {
+    const mockConfig = { apis: {} } as any;
+    process.env.REDOCLY_AUTHORIZATION = 'test-api-key';
+
+    remotes.push.mockRestore();
+    remotes.push.mockRejectedValueOnce(new ReuniteApiError('Deprecated.', 412));
+
+    fsStatSyncSpy.mockReturnValueOnce({
+      isDirectory() {
+        return false;
+      },
+    } as any);
+
+    pathResolveSpy.mockImplementationOnce((p) => p);
+    pathRelativeSpy.mockImplementationOnce((_, p) => p);
+    pathDirnameSpy.mockImplementation((_: string) => '.');
+
+    expect(
+      handlePush({
+        argv: {
+          domain: 'test-domain',
+          'mount-path': 'test-mount-path',
+          organization: 'test-org',
+          project: 'test-project',
+          branch: 'test-branch',
+          namespace: 'test-namespace',
+          repository: 'test-repository',
+          'commit-sha': 'test-commit-sha',
+          'commit-url': 'test-commit-url',
+          'default-branch': 'test-branch',
+          'created-at': 'test-created-at',
+          author: 'TestAuthor <test-author@mail.com>',
+          message: 'Test message',
+          files: ['test-file'],
+          'max-execution-time': 10,
+        },
+        config: mockConfig,
+        version: 'cli-version',
+      })
+    ).rejects.toThrow('✗ File upload failed. Reason: Deprecated.');
   });
 });
diff --git a/packages/cli/src/cms/commands/__tests__/utils.test.ts b/packages/cli/src/cms/commands/__tests__/utils.test.ts
index 482e02e1..bbf88fbc 100644
--- a/packages/cli/src/cms/commands/__tests__/utils.test.ts
+++ b/packages/cli/src/cms/commands/__tests__/utils.test.ts
@@ -32,7 +32,7 @@ describe('retryUntilConditionMet()', () => {
         retryIntervalMs: 100,
         retryTimeoutMs: 1000,
       })
-    ).rejects.toThrow('Timeout exceeded');
+    ).rejects.toThrow('Timeout exceeded.');
   });
 
   it('should call "onConditionNotMet" and "onRetry" callbacks', async () => {