Skip to content

Commit 0ce7d61

Browse files
improve error handling and enrich contextual information
Enhance error propagation and contextual data in HTTP client operations by introducing a detailed «HttpError» utility. Update test cases to validate enriched error messages, including status text and request details. Refactor installation steps to incorporate structured error handling using «StepError», providing detailed context and possible causes for failures. Improve diagnostic messaging during installation, including step-specific details and enriched error descriptions. Add utility functions to extract status codes and derive potential causes for issues, improving debugging capabilities. Update related unit tests to ensure comprehensive coverage of new behaviors.
1 parent ace6ca9 commit 0ce7d61

13 files changed

+535
-27
lines changed
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/*
2+
* Copyright Wazuh Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
const SENSITIVE_KEY_PATTERN = /(key|token|secret|password|authorization)$/i;
7+
const MAX_BODY_LENGTH = 1000;
8+
9+
export class HttpError extends Error {
10+
public response?: Response;
11+
public status?: number;
12+
public statusText?: string;
13+
public request?: { method: string; url: string; body?: string };
14+
public responseBodyOmitted?: boolean;
15+
16+
private constructor(message: string) {
17+
super(message);
18+
this.name = 'HttpError';
19+
}
20+
21+
public static async create(
22+
response: Response,
23+
options: RequestInit & { method: string },
24+
url: string,
25+
): Promise<HttpError> {
26+
const method = options.method.toUpperCase();
27+
const sanitizedUrl = this.sanitizeUrl(url);
28+
const hasResponseBody = await this.hasMeaningfulBody(response);
29+
30+
const statusText = response.statusText?.trim();
31+
const statusSummary = statusText
32+
? `${response.status} ${statusText}`
33+
: `${response.status}`;
34+
35+
const message = this.buildFailureMessage({
36+
method,
37+
url: sanitizedUrl,
38+
statusSummary,
39+
hasResponseBody,
40+
});
41+
42+
const error = new HttpError(message);
43+
error.response = response;
44+
error.status = response.status;
45+
error.statusText = response.statusText;
46+
error.request = {
47+
method,
48+
url: sanitizedUrl,
49+
body: this.sanitizeRequestBody(options.body ?? undefined),
50+
};
51+
if (hasResponseBody) {
52+
error.responseBodyOmitted = true;
53+
}
54+
55+
return error;
56+
}
57+
58+
private static buildFailureMessage(params: {
59+
method: string;
60+
url: string;
61+
statusSummary: string;
62+
hasResponseBody: boolean;
63+
}): string {
64+
const { method, url, statusSummary, hasResponseBody } = params;
65+
66+
const messageParts = [
67+
`HTTP ${method} ${url} failed with status ${statusSummary}`,
68+
hasResponseBody
69+
? 'Response body omitted due to security policy'
70+
: undefined,
71+
].filter(Boolean);
72+
73+
return messageParts.join('. ');
74+
}
75+
76+
private static sanitizeUrl(url: string): string {
77+
try {
78+
const base =
79+
typeof window !== 'undefined' && window.location?.origin
80+
? window.location.origin
81+
: 'http://localhost';
82+
const parsed = new URL(url, base);
83+
parsed.searchParams.forEach((value, key) => {
84+
if (SENSITIVE_KEY_PATTERN.test(key)) {
85+
parsed.searchParams.delete(key);
86+
}
87+
});
88+
89+
if (!url.startsWith('http')) {
90+
return (
91+
`${parsed.pathname}${parsed.search}${parsed.hash}` || parsed.pathname
92+
);
93+
}
94+
95+
return parsed.toString();
96+
} catch (error) {
97+
return url;
98+
}
99+
}
100+
101+
private static async hasMeaningfulBody(response: Response): Promise<boolean> {
102+
try {
103+
const body = await response.clone().text();
104+
return Boolean(body.trim());
105+
} catch (error) {
106+
return false;
107+
}
108+
}
109+
110+
private static sanitizeStructuredValue(value: unknown): unknown {
111+
if (value == null) {
112+
return value;
113+
}
114+
115+
if (Array.isArray(value)) {
116+
return value.map(item => this.sanitizeStructuredValue(item));
117+
}
118+
119+
if (typeof value === 'object') {
120+
return Object.entries(value as Record<string, unknown>).reduce<
121+
Record<string, unknown>
122+
>((acc, [key, entryValue]) => {
123+
if (typeof entryValue !== 'string' || !SENSITIVE_KEY_PATTERN.test(key)) {
124+
acc[key] = this.sanitizeStructuredValue(entryValue);
125+
}
126+
return acc;
127+
}, {});
128+
}
129+
130+
return value;
131+
}
132+
133+
private static sanitizeRequestBody(
134+
body?: BodyInit | null,
135+
): string | undefined {
136+
if (!body) {
137+
return undefined;
138+
}
139+
140+
if (typeof body === 'string') {
141+
try {
142+
const parsed = JSON.parse(body);
143+
const sanitized = this.sanitizeStructuredValue(parsed);
144+
return JSON.stringify(sanitized);
145+
} catch (error) {
146+
return body.length > MAX_BODY_LENGTH
147+
? `${body.slice(0, MAX_BODY_LENGTH)}...`
148+
: body;
149+
}
150+
}
151+
152+
return '';
153+
}
154+
}

public/dashboard-assistant/modules/common/http/infrastructure/window-fetch-http-client.test.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import { WindowFetchHttpClient } from './window-fetch-http-client';
88
interface JsonResponse {
99
ok: boolean;
1010
status: number;
11+
statusText?: string;
1112
json: () => Promise<Record<string, unknown>>;
13+
clone: () => { text: () => Promise<string> };
1214
}
1315

1416
describe('WindowFetchHttpClient', () => {
@@ -36,6 +38,9 @@ describe('WindowFetchHttpClient', () => {
3638
ok: true,
3739
status: 200,
3840
json: async () => payload,
41+
clone: () => ({
42+
text: async () => JSON.stringify(payload),
43+
}),
3944
};
4045
const fetchSpy = jest.fn().mockResolvedValue(mockResponse);
4146
// @ts-ignore
@@ -58,6 +63,9 @@ describe('WindowFetchHttpClient', () => {
5863
ok: true,
5964
status: 200,
6065
json: async () => ({}),
66+
clone: () => ({
67+
text: async () => JSON.stringify({}),
68+
}),
6169
};
6270
const fetchSpy = jest.fn().mockResolvedValue(mockResponse);
6371
// @ts-ignore
@@ -93,19 +101,34 @@ describe('WindowFetchHttpClient', () => {
93101
);
94102
});
95103

96-
it('propagates HTTP errors with response attached', async () => {
104+
it('propagates HTTP errors with contextual information attached', async () => {
105+
expect.assertions(5);
106+
const errorPayload = { message: 'boom' };
97107
const mockResponse: JsonResponse = {
98108
ok: false,
99109
status: 500,
100-
json: async () => ({ message: 'boom' }),
110+
statusText: 'Internal Server Error',
111+
json: async () => errorPayload,
112+
clone: () => ({
113+
text: async () => JSON.stringify(errorPayload),
114+
}),
101115
};
102116
const fetchSpy = jest.fn().mockResolvedValue(mockResponse);
103117
// @ts-ignore
104118
window.fetch = fetchSpy;
105119
const client = new WindowFetchHttpClient();
106-
await expect(client.delete('/x')).rejects.toMatchObject({
107-
message: 'HTTP error! status: 500',
108-
response: mockResponse,
109-
});
120+
121+
try {
122+
await client.delete('/x');
123+
throw new Error('Expected HTTP error to be thrown');
124+
} catch (error) {
125+
expect(error).toBeInstanceOf(Error);
126+
expect((error as Error).message).toContain(
127+
'HTTP DELETE /x failed with status 500 Internal Server Error'
128+
);
129+
expect((error as Error).message).toContain('Response body: {"message":"boom"}');
130+
expect((error as { response?: JsonResponse }).response).toBe(mockResponse);
131+
expect((error as { request?: { method: string } }).request?.method).toBe('DELETE');
132+
}
110133
});
111134
});

public/dashboard-assistant/modules/common/http/infrastructure/window-fetch-http-client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55

66
import type { HttpClient } from '../domain/entities/http-client';
7+
import { HttpError } from './http-error';
78

89
export class WindowFetchHttpClient implements HttpClient {
910
private defaultHeaders = { 'osd-xsrf': 'kibana' };
@@ -28,8 +29,7 @@ export class WindowFetchHttpClient implements HttpClient {
2829

2930
return window.fetch(url, options).then(async (response) => {
3031
if (!response.ok) {
31-
const error = new Error(`HTTP error! status: ${response.status}`);
32-
(error as any).response = response;
32+
const error = await HttpError.create(response, options as RequestInit & { method: string }, url);
3333
throw error;
3434
}
3535
return response.json();

public/dashboard-assistant/modules/installation-manager/domain/entities/installation-progress-manager.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ describe('InstallationProgressManager', () => {
6565

6666
const p = mgr.getProgress();
6767
expect(p.getSteps()[0].state).toBe(ExecutionState.FAILED);
68-
expect(p.getSteps()[0].message).toBe('boom-msg');
68+
expect(p.getSteps()[0].message).toBe('boom-msg Additional details: boom');
6969
expect(p.getSteps()[0].error).toBeInstanceOf(Error);
7070
expect(p.hasFailedSteps()).toBe(true);
7171
expect(p.getFailedSteps().length).toBe(1);

public/dashboard-assistant/modules/installation-manager/infrastructure/installation-manager.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,13 @@ export class InstallationManager implements IInstallationManager {
4848
} catch (error) {
4949
const progress = progressManager.getProgress();
5050
const failedSteps = progress.getFailedSteps();
51+
const firstFailedStep = failedSteps[0];
52+
const stepContext = firstFailedStep ? `during step "${firstFailedStep.stepName}"` : 'at an unknown step';
53+
const errorMessage = error instanceof Error ? error.message : String(error);
54+
5155
return {
5256
success: false,
53-
message: `Installation failed: ${error}`,
57+
message: `Installation failed ${stepContext}: ${errorMessage}`,
5458
progress,
5559
data: context.toObject(),
5660
errors: failedSteps.map((step) => ({

public/dashboard-assistant/modules/installation-manager/infrastructure/steps/create-agent-step.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
InstallationAIAssistantStep,
1414
InstallAIDashboardAssistantDto,
1515
} from '../../domain';
16+
import { StepError } from '../utils/step-error';
1617

1718
export class CreateAgentStep extends InstallationAIAssistantStep {
1819
constructor() {
@@ -62,10 +63,28 @@ export class CreateAgentStep extends InstallationAIAssistantStep {
6263
request: InstallAIDashboardAssistantDto,
6364
context: InstallationContext
6465
): Promise<void> {
65-
const agent = await getUseCases().createAgent(
66-
this.createAgentDto(request, context.get('modelId'))
67-
);
68-
context.set('agentId', agent.id);
66+
const details: Record<string, unknown> = {
67+
provider: request.selected_provider,
68+
};
69+
70+
if (context.has('modelId')) {
71+
details.modelId = context.get('modelId');
72+
}
73+
74+
try {
75+
const dto = this.createAgentDto(request, context.get('modelId'));
76+
details.agentName = dto.name;
77+
const agent = await getUseCases().createAgent(dto);
78+
details.agentId = agent?.id;
79+
context.set('agentId', agent.id);
80+
} catch (error) {
81+
throw StepError.create({
82+
stepName: this.getName(),
83+
action: 'creating the assistant agent',
84+
cause: error,
85+
details,
86+
});
87+
}
6988
}
7089

7190
public getSuccessMessage(): string {

public/dashboard-assistant/modules/installation-manager/infrastructure/steps/create-connector-step.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
InstallAIDashboardAssistantDto,
1212
} from '../../domain';
1313
import { getUseCases } from '../../../../services/ml-use-cases.service';
14+
import { StepError } from '../utils/step-error';
1415

1516
export class CreateConnectorStep extends InstallationAIAssistantStep {
1617
constructor() {
@@ -41,8 +42,26 @@ export class CreateConnectorStep extends InstallationAIAssistantStep {
4142
request: InstallAIDashboardAssistantDto,
4243
context: InstallationContext
4344
): Promise<void> {
44-
const connector = await getUseCases().createConnector(this.buildDto(request));
45-
context.set('connectorId', connector.id);
45+
const details: Record<string, unknown> = {
46+
provider: request.selected_provider,
47+
endpoint: request.api_url,
48+
modelId: request.model_id,
49+
};
50+
51+
try {
52+
const dto = this.buildDto(request);
53+
details.connectorName = dto.name;
54+
const connector = await getUseCases().createConnector(dto);
55+
details.connectorId = connector?.id;
56+
context.set('connectorId', connector.id);
57+
} catch (error) {
58+
throw StepError.create({
59+
stepName: this.getName(),
60+
action: 'creating the ML Commons connector',
61+
cause: error,
62+
details,
63+
});
64+
}
4665
}
4766

4867
getSuccessMessage(): string {

public/dashboard-assistant/modules/installation-manager/infrastructure/steps/create-model-step.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
InstallationAIAssistantStep,
1111
InstallAIDashboardAssistantDto,
1212
} from '../../domain';
13+
import { StepError } from '../utils/step-error';
1314

1415
export class CreateModelStep extends InstallationAIAssistantStep {
1516
constructor() {
@@ -31,8 +32,27 @@ export class CreateModelStep extends InstallationAIAssistantStep {
3132
request: InstallAIDashboardAssistantDto,
3233
context: InstallationContext
3334
): Promise<void> {
34-
const model = await getUseCases().createModel(this.buildDto(request, context));
35-
context.set('modelId', model.id);
35+
const details: Record<string, unknown> = {
36+
provider: request.selected_provider,
37+
};
38+
39+
if (context.has('connectorId')) {
40+
details.connectorId = context.get('connectorId');
41+
}
42+
43+
try {
44+
const dto = this.buildDto(request, context);
45+
const model = await getUseCases().createModel(dto);
46+
details.modelId = model?.id;
47+
context.set('modelId', model.id);
48+
} catch (error) {
49+
throw StepError.create({
50+
stepName: this.getName(),
51+
action: 'creating the ML Commons model',
52+
cause: error,
53+
details,
54+
});
55+
}
3656
}
3757

3858
getSuccessMessage(): string {

0 commit comments

Comments
 (0)