Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions packages/oc-docs/e2e/request-errors.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { test, expect, type Page } from '@playwright/test';

/**
* Locate an endpoint section by its h1 title (mirrors requests.spec.ts).
*/
function endpointSection(page: Page, name: string) {
return page.locator('.endpoint-section').filter({
has: page.getByRole('heading', { name, level: 1, exact: true }),
});
}

/** Open the try-it playground for an endpoint. */
async function openTryIt(page: Page, endpoint: string) {
await endpointSection(page, endpoint).getByRole('button', { name: 'Try' }).click();
}

test.describe('Try-it request failure messages (BRU-3408)', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.endpoint-section');
});

test('cross-origin failure is classified as browser-blocked (CORS), inside the Response tab', async ({ page }) => {
// The sample collection targets localhost:8081 — a different origin than the
// docs page (127.0.0.1:3001). Aborting reproduces the opaque failure.
await page.route('**/api/users**', (route) => route.abort());

await openTryIt(page, 'get users');
await page.getByRole('button', { name: 'SEND' }).click();

await expect(page.locator('.error-title')).toHaveText('Request blocked');
await expect(page.locator('.error-message')).toContainText('usually CORS');
await expect(page.locator('.error-message')).toContainText('Bruno desktop app');

// Banner renders inside the Response tab shell, not as a full-pane replacement.
await expect(page.getByRole('button', { name: 'Response', exact: true })).toBeVisible();
});

test('same-origin failure is "unreachable" and never mentions CORS', async ({ page }) => {
// Point the request at the docs page's own origin, then fail it.
await page.route('**/same-origin-fail**', (route) => route.abort());

await openTryIt(page, 'get users');
await page.getByPlaceholder('Enter request URL').fill('http://127.0.0.1:3001/same-origin-fail');
await page.getByRole('button', { name: 'SEND' }).click();

await expect(page.locator('.error-title')).toHaveText("Couldn't reach the server");
const message = (await page.locator('.error-message').innerText()).toLowerCase();
expect(message).toContain('may be down');
expect(message).not.toContain('cors');
});

test('a 4xx response is NOT treated as a failure (renders normally)', async ({ page }) => {
await page.route('**/api/users**', (route) =>
route.fulfill({
status: 404,
headers: { 'access-control-allow-origin': '*' },
contentType: 'application/json',
body: JSON.stringify({ error: 'not found' }),
})
);

await openTryIt(page, 'get users');
await page.getByRole('button', { name: 'SEND' }).click();

// No error banner; a 404 is a normal response (status shown).
await expect(page.locator('.error-title')).toHaveCount(0);
await expect(page.getByText('404 Not Found')).toBeVisible();
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import Tabs from '../../../../../../ui/Tabs/Tabs';
import ErrorBanner from '../../../../../../ui/ErrorBanner/ErrorBanner';
import { ResponseBodyTab, ResponseHeadersTab, TestResultsTab } from '../../Common';

interface ResponsePaneProps {
Expand Down Expand Up @@ -46,20 +47,19 @@ const ResponsePane: React.FC<ResponsePaneProps> = ({ response, isLoading }) => {
);
}

if (response.error) {
return (
<div className="h-full" style={{ backgroundColor: 'var(--bg-primary)' }}>
<div className="p-4">
<div className="p-4 rounded border-l-4 border-red-500" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<h3 className="text-lg font-medium text-red-600 mb-2">Request Failed</h3>
<p style={{ color: 'var(--text-primary)' }}>{response.error}</p>
</div>
</div>
</div>
);
}
// A failed request (no HTTP response) renders a danger banner inside the
// Response tab, keeping the same tab shell as a successful response.
const renderErrorBanner = () => (
<div className="p-4">
<ErrorBanner
title={response.errorTitle || 'Request Failed'}
message={response.error}
/>
</div>
);

const renderResponseBody = () => <ResponseBodyTab response={response} />;
const renderResponseBody = () =>
response.error ? renderErrorBanner() : <ResponseBodyTab response={response} />;
const renderHeaders = () => <ResponseHeadersTab headers={response.headers} />;
const renderTestResults = () => (
<TestResultsTab
Expand Down Expand Up @@ -134,7 +134,7 @@ const ResponsePane: React.FC<ResponsePaneProps> = ({ response, isLoading }) => {
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
rightElement={statusInfo}
rightElement={response.error ? undefined : statusInfo}
/>
</div>
);
Expand Down
37 changes: 13 additions & 24 deletions packages/oc-docs/src/runner/RequestExecutor.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import type { HttpRequest } from '@opencollection/types/requests/http';
import { RunRequestResponse } from './index';
import { getHttpMethod, getRequestUrl, getHttpHeaders, getHttpBody, getRequestAuth } from '../utils/schemaHelpers';
import { classifyRequestError, DEFAULT_TIMEOUT_MS } from './classifyRequestError';
import stripJsonComments from 'strip-json-comments';

export class RequestExecutor {
async executeRequest(request: HttpRequest, options: { timeout?: number } = {}): Promise<RunRequestResponse> {
const startTime = Date.now();
const timeoutMs = options.timeout ?? DEFAULT_TIMEOUT_MS;

try {
const fetchOptions = await this.buildFetchOptions(request, options.timeout);
const fetchOptions = await this.buildFetchOptions(request, timeoutMs);
const requestUrl = getRequestUrl(request);
const response = await fetch(requestUrl, fetchOptions);
const endTime = Date.now();
Expand All @@ -27,35 +29,22 @@ export class RequestExecutor {
};
} catch (error) {
const endTime = Date.now();
let errorMessage = 'Request failed';
let errorType = 'unknown';

if (error instanceof Error) {
errorMessage = error.message;

// Categorize error types
if (error.name === 'AbortError' || error.message.includes('timeout')) {
errorType = 'timeout';
errorMessage = `Request timed out after ${endTime - startTime}ms`;
} else if (error.name === 'TypeError' && error.message.includes('fetch')) {
errorType = 'cors';
errorMessage = 'CORS error: Failed to fetch. The server either does not include the required CORS headers for this origin or cannot be reached.';
} else if (error.message.includes('fetch')) {
errorType = 'network';
} else if (error.message.includes('SSL') || error.message.includes('certificate')) {
errorType = 'ssl';
}
}
const classified = classifyRequestError(error, {
timeoutMs,
requestUrl: getRequestUrl(request),
pageUrl: typeof window !== 'undefined' ? window.location.href : undefined
});

return {
error: errorMessage,
duration: endTime - startTime,
errorType
error: classified.message,
errorType: classified.type,
errorTitle: classified.title,
duration: endTime - startTime
};
}
}

private async buildFetchOptions(request: HttpRequest, timeout = 30000): Promise<RequestInit> {
private async buildFetchOptions(request: HttpRequest, timeout = DEFAULT_TIMEOUT_MS): Promise<RequestInit> {
const method = getHttpMethod(request);
const options: RequestInit = {
method,
Expand Down
112 changes: 112 additions & 0 deletions packages/oc-docs/src/runner/classifyRequestError.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, it, expect } from 'vitest';
import { classifyRequestError } from './classifyRequestError';

// Helpers to build the exact error shapes the browser throws.
const timeoutError = () => {
const e = new Error('signal timed out');
e.name = 'TimeoutError';
return e;
};

const abortError = () => {
const e = new Error('The operation was aborted');
e.name = 'AbortError';
return e;
};

const failedToFetch = (message = 'Failed to fetch') => {
const e = new Error(message);
e.name = 'TypeError';
return e;
};

describe('classifyRequestError', () => {
describe('timeout', () => {
it('classifies a TimeoutError from AbortSignal.timeout()', () => {
const result = classifyRequestError(timeoutError());
expect(result.type).toBe('timeout');
expect(result.title).toBe('Request timed out');
expect(result.message).toBe("Request timed out. The server didn't respond in time.");
});

it('classifies a manual AbortError', () => {
expect(classifyRequestError(abortError()).type).toBe('timeout');
});
});

describe('mixed content (secure page, insecure URL)', () => {
it('classifies an https page calling an http URL', () => {
const result = classifyRequestError(failedToFetch(), {
pageUrl: 'https://docs.example.com/api.html',
requestUrl: 'http://api.example.com/users'
});
expect(result.type).toBe('mixed-content');
expect(result.message).toContain('secure (https)');
expect(result.message).toContain('insecure (http)');
});
});

describe('browser-blocked (CORS — cross-origin or opened from a file)', () => {
it('classifies a cross-origin request (different host, same site)', () => {
const result = classifyRequestError(failedToFetch(), {
pageUrl: 'https://docs.example.com/',
requestUrl: 'https://api.example.com/users'
});
expect(result.type).toBe('browser-blocked');
expect(result.message).toContain('usually CORS');
expect(result.message).toContain('Bruno desktop app');
});

it('classifies a request from a docs page opened from a file (origin null)', () => {
const result = classifyRequestError(failedToFetch(), {
pageUrl: 'file:///Users/me/docs.html',
requestUrl: 'https://api.example.com/users'
});
expect(result.type).toBe('browser-blocked');
});

it('never suggests CORS for a same-origin failure', () => {
const result = classifyRequestError(failedToFetch(), {
pageUrl: 'https://app.example.com/docs',
requestUrl: 'https://app.example.com/api/users'
});
expect(result.type).not.toBe('browser-blocked');
expect(result.message.toLowerCase()).not.toContain('cors');
});
});

describe('server unreachable (same origin)', () => {
it('classifies a same-origin failure as unreachable', () => {
const result = classifyRequestError(failedToFetch(), {
pageUrl: 'https://app.example.com/docs',
requestUrl: 'https://app.example.com/api/users'
});
expect(result.type).toBe('unreachable');
expect(result.message).toBe("Couldn't reach the server. It may be down, or the URL may be wrong.");
});
});

describe('anything else -> underlying message', () => {
it('surfaces the raw message for a non-network error', () => {
const result = classifyRequestError(new Error('Something weird happened'));
expect(result.type).toBe('unknown');
expect(result.message).toBe('Something weird happened');
});

it('falls through to the raw message when the request URL is unparseable', () => {
// e.g. an unresolved {{baseUrl}} leaves a relative path with no origin to compare.
const result = classifyRequestError(failedToFetch('Failed to fetch'), {
pageUrl: 'https://docs.example.com/',
requestUrl: '/users'
});
expect(result.type).toBe('unknown');
expect(result.message).toBe('Failed to fetch');
});

it('falls back to a generic message for a non-Error throw', () => {
const result = classifyRequestError('boom');
expect(result.type).toBe('unknown');
expect(result.message).toBe('The request could not be completed.');
});
});
});
Loading