Skip to content

Commit c35f3a7

Browse files
authored
fix: extract clean error messages from API responses (#64)
* fix: extract clean error messages from API responses When API requests fail with non-JSON responses (like HTML error pages), error messages now show the HTTP status code (e.g., "HTTP 521 Web Server Is Down") instead of serializing the entire response body. Added getApiErrorMessage(error, response) utility that extracts clean error messages from ODS, OCAPI, and SCAPI error patterns with HTTP status fallback. * docs: update skills with getApiErrorMessage pattern Update CLI command development and API client development skills to document the new getApiErrorMessage utility for handling API errors cleanly.
1 parent 269de20 commit c35f3a7

24 files changed

Lines changed: 394 additions & 63 deletions

File tree

.changeset/fix-error-output.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@salesforce/b2c-cli': patch
3+
'@salesforce/b2c-tooling-sdk': patch
4+
---
5+
6+
Fix HTML response bodies appearing in ERROR log lines. When API requests fail with non-JSON responses (like HTML error pages), error messages now show the HTTP status code (e.g., "HTTP 521 Web Server Is Down") instead of serializing the entire response body.
7+
8+
Added `getApiErrorMessage(error, response)` utility that extracts clean error messages from ODS, OCAPI, and SCAPI error patterns with HTTP status fallback.

.claude/skills/api-client-development/SKILL.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,62 @@ it('fetches endpoints', async () => {
380380

381381
---
382382

383+
## Error Handling
384+
385+
When API requests fail, use `getApiErrorMessage()` to extract clean, user-friendly error messages. This utility handles multiple error formats and ensures HTML response bodies (like error pages from stopped sandboxes) are never shown to users.
386+
387+
### Using getApiErrorMessage
388+
389+
```typescript
390+
import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk/clients';
391+
392+
const {data, error, response} = await client.GET('/sites', {...});
393+
394+
if (error) {
395+
// Returns structured error message or "HTTP 521 Web Server Is Down"
396+
const message = getApiErrorMessage(error, response);
397+
this.error(`Failed to fetch sites: ${message}`);
398+
}
399+
```
400+
401+
### Supported Error Patterns
402+
403+
The utility extracts messages from these patterns in priority order:
404+
405+
| API | Error Structure | Message Location |
406+
|-----|-----------------|------------------|
407+
| ODS/SLAS | `{ error: { message } }` | `error.error.message` |
408+
| OCAPI | `{ fault: { message } }` | `error.fault.message` |
409+
| SCAPI/Problem+JSON | `{ title, detail }` | `error.detail` or `error.title` |
410+
| Standard Error | `{ message }` | `error.message` |
411+
| Fallback | Any | `HTTP {status} {statusText}` |
412+
413+
### Why This Matters
414+
415+
**Without `getApiErrorMessage`:**
416+
```
417+
ERROR: Failed to fetch sites: <!DOCTYPE html><html lang="en"><head><title>521 - Sandbox Down</title>...
418+
```
419+
420+
**With `getApiErrorMessage`:**
421+
```
422+
ERROR: Failed to fetch sites: HTTP 521 Web Server Is Down
423+
```
424+
425+
### Important: Always Destructure `response`
426+
427+
When making API calls, always destructure the `response` object alongside `error`:
428+
429+
```typescript
430+
// GOOD: Include response for error handling
431+
const {data, error, response} = await client.GET('/endpoint', {...});
432+
433+
// BAD: Missing response - can't get clean error message
434+
const {data, error} = await client.GET('/endpoint', {...});
435+
```
436+
437+
---
438+
383439
## Checklist: New SCAPI Client
384440

385441
1. Add OpenAPI spec to `specs/`

.claude/skills/cli-command-development/SKILL.md

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import { InstanceCommand, CartridgeCommand, OdsCommand } from '@salesforce/b2c-t
5757
*/
5858
import {Args, Flags} from '@oclif/core';
5959
import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli';
60+
import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk';
6061
import {t} from '../../i18n/index.js';
6162

6263
interface MyCommandResponse {
@@ -105,27 +106,27 @@ export default class MyCommand extends InstanceCommand<typeof MyCommand> {
105106
this.log(t('commands.topic.mycommand.working', 'Working on {{name}}...', {name}));
106107

107108
// Implementation
108-
const result = await this.instance.ocapi.GET('/some/endpoint');
109+
const {data, error, response} = await this.instance.ocapi.GET('/some/endpoint');
109110

110-
if (!result.data) {
111+
if (error) {
111112
this.error(t('commands.topic.mycommand.error', 'Failed: {{message}}', {
112-
message: result.response?.statusText || 'Unknown error',
113+
message: getApiErrorMessage(error, response),
113114
}));
114115
}
115116

116-
const response: MyCommandResponse = {
117+
const result: MyCommandResponse = {
117118
success: true,
118-
data: result.data,
119+
data,
119120
};
120121

121122
// JSON mode returns the object directly (oclif handles serialization)
122123
if (this.jsonEnabled()) {
123-
return response;
124+
return result;
124125
}
125126

126127
// Human-readable output
127128
this.log('Success!');
128-
return response;
129+
return result;
129130
}
130131
}
131132
```
@@ -305,14 +306,21 @@ this.error('Config file not found', {
305306
// Warning (continues execution)
306307
this.warn('Deprecated flag used');
307308

308-
// Structured API errors
309-
if (result.error) {
309+
// API errors - use getApiErrorMessage for clean messages
310+
import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk';
311+
312+
const {data, error, response} = await this.instance.ocapi.GET('/sites', {...});
313+
if (error) {
310314
this.error(t('commands.topic.cmd.apiError', 'API error: {{message}}', {
311-
message: formatApiError(result.error),
315+
message: getApiErrorMessage(error, response),
312316
}));
313317
}
314318
```
315319

320+
**Important:** Always destructure `response` alongside `error` when making API calls. The `getApiErrorMessage` utility extracts clean messages from ODS, OCAPI, and SCAPI error patterns, and falls back to HTTP status (e.g., "HTTP 521 Web Server Is Down") for non-JSON responses like HTML error pages.
321+
322+
See [API Client Development](../api-client-development/SKILL.md#error-handling) for supported error patterns.
323+
316324
## Creating a Command Checklist
317325

318326
1. Create file at `packages/b2c-cli/src/commands/<topic>/<command>.ts`

packages/b2c-cli/src/commands/ods/create.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import {Flags, ux} from '@oclif/core';
77
import cliui from 'cliui';
88
import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli';
9-
import type {OdsComponents} from '@salesforce/b2c-tooling-sdk';
9+
import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk';
1010
import {t} from '../../i18n/index.js';
1111

1212
type SandboxModel = OdsComponents['schemas']['SandboxModel'];
@@ -139,11 +139,9 @@ export default class OdsCreate extends OdsCommand<typeof OdsCreate> {
139139
});
140140

141141
if (!result.data?.data) {
142-
const errorResponse = result.error as OdsComponents['schemas']['ErrorResponse'] | undefined;
143-
const errorMessage = errorResponse?.error?.message || result.response?.statusText || 'Unknown error';
144142
this.error(
145143
t('commands.ods.create.error', 'Failed to create sandbox: {{message}}', {
146-
message: errorMessage,
144+
message: getApiErrorMessage(result.error, result.response),
147145
}),
148146
);
149147
}

packages/b2c-cli/src/commands/ods/delete.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import * as readline from 'node:readline';
77
import {Args, Flags} from '@oclif/core';
88
import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli';
9-
import type {OdsComponents} from '@salesforce/b2c-tooling-sdk';
9+
import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk';
1010
import {t} from '../../i18n/index.js';
1111

1212
/**
@@ -92,11 +92,9 @@ export default class OdsDelete extends OdsCommand<typeof OdsDelete> {
9292
});
9393

9494
if (result.response.status !== 202) {
95-
const errorResponse = result.error as OdsComponents['schemas']['ErrorResponse'] | undefined;
96-
const errorMessage = errorResponse?.error?.message || result.response?.statusText || 'Unknown error';
9795
this.error(
9896
t('commands.ods.delete.error', 'Failed to delete sandbox: {{message}}', {
99-
message: errorMessage,
97+
message: getApiErrorMessage(result.error, result.response),
10098
}),
10199
);
102100
}

packages/b2c-cli/src/commands/ods/list.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66
import {Flags} from '@oclif/core';
77
import {OdsCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli';
8-
import type {OdsComponents} from '@salesforce/b2c-tooling-sdk';
8+
import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk';
99
import {t} from '../../i18n/index.js';
1010

1111
type SandboxModel = OdsComponents['schemas']['SandboxModel'];
@@ -142,11 +142,9 @@ export default class OdsList extends OdsCommand<typeof OdsList> {
142142
});
143143

144144
if (result.error) {
145-
const errorResponse = result.error as OdsComponents['schemas']['ErrorResponse'] | undefined;
146-
const errorMessage = errorResponse?.error?.message || result.response?.statusText || 'Unknown error';
147145
this.error(
148146
t('commands.ods.list.error', 'Failed to fetch sandboxes: {{message}}', {
149-
message: errorMessage,
147+
message: getApiErrorMessage(result.error, result.response),
150148
}),
151149
);
152150
}

packages/b2c-cli/src/commands/ods/restart.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66
import {Args} from '@oclif/core';
77
import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli';
8-
import type {OdsComponents} from '@salesforce/b2c-tooling-sdk';
8+
import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk';
99
import {t} from '../../i18n/index.js';
1010

1111
type SandboxOperationModel = OdsComponents['schemas']['SandboxOperationModel'];
@@ -45,11 +45,9 @@ export default class OdsRestart extends OdsCommand<typeof OdsRestart> {
4545
});
4646

4747
if (!result.data?.data) {
48-
const errorResponse = result.error as OdsComponents['schemas']['ErrorResponse'] | undefined;
49-
const errorMessage = errorResponse?.error?.message || result.response?.statusText || 'Unknown error';
5048
this.error(
5149
t('commands.ods.restart.error', 'Failed to restart sandbox: {{message}}', {
52-
message: errorMessage,
50+
message: getApiErrorMessage(result.error, result.response),
5351
}),
5452
);
5553
}

packages/b2c-cli/src/commands/ods/start.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66
import {Args} from '@oclif/core';
77
import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli';
8-
import type {OdsComponents} from '@salesforce/b2c-tooling-sdk';
8+
import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk';
99
import {t} from '../../i18n/index.js';
1010

1111
type SandboxOperationModel = OdsComponents['schemas']['SandboxOperationModel'];
@@ -45,11 +45,9 @@ export default class OdsStart extends OdsCommand<typeof OdsStart> {
4545
});
4646

4747
if (!result.data?.data) {
48-
const errorResponse = result.error as OdsComponents['schemas']['ErrorResponse'] | undefined;
49-
const errorMessage = errorResponse?.error?.message || result.response?.statusText || 'Unknown error';
5048
this.error(
5149
t('commands.ods.start.error', 'Failed to start sandbox: {{message}}', {
52-
message: errorMessage,
50+
message: getApiErrorMessage(result.error, result.response),
5351
}),
5452
);
5553
}

packages/b2c-cli/src/commands/ods/stop.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66
import {Args} from '@oclif/core';
77
import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli';
8-
import type {OdsComponents} from '@salesforce/b2c-tooling-sdk';
8+
import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk';
99
import {t} from '../../i18n/index.js';
1010

1111
type SandboxOperationModel = OdsComponents['schemas']['SandboxOperationModel'];
@@ -45,11 +45,9 @@ export default class OdsStop extends OdsCommand<typeof OdsStop> {
4545
});
4646

4747
if (!result.data?.data) {
48-
const errorResponse = result.error as OdsComponents['schemas']['ErrorResponse'] | undefined;
49-
const errorMessage = errorResponse?.error?.message || result.response?.statusText || 'Unknown error';
5048
this.error(
5149
t('commands.ods.stop.error', 'Failed to stop sandbox: {{message}}', {
52-
message: errorMessage,
50+
message: getApiErrorMessage(result.error, result.response),
5351
}),
5452
);
5553
}

packages/b2c-cli/src/commands/scapi/custom/status.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55
*/
66
import {Command, Flags, ux} from '@oclif/core';
77
import {OAuthCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli';
8-
import {createCustomApisClient, toOrganizationId, type CustomApisComponents} from '@salesforce/b2c-tooling-sdk';
8+
import {
9+
createCustomApisClient,
10+
getApiErrorMessage,
11+
toOrganizationId,
12+
type CustomApisComponents,
13+
} from '@salesforce/b2c-tooling-sdk';
914
import {t} from '../../../i18n/index.js';
1015

1116
type CustomApiEndpoint = CustomApisComponents['schemas']['CustomApiEndpoint'];
@@ -242,7 +247,11 @@ export default class ScapiCustomStatus extends ScapiCustomCommand<typeof ScapiCu
242247
// Ensure organizationId has the required f_ecom_ prefix
243248
const organizationId = toOrganizationId(tenantId);
244249

245-
const {data, error} = await client.GET('/organizations/{organizationId}/endpoints', {
250+
const {
251+
data,
252+
error,
253+
response: httpResponse,
254+
} = await client.GET('/organizations/{organizationId}/endpoints', {
246255
params: {
247256
path: {organizationId},
248257
query: status ? {status: status as 'active' | 'not_registered'} : undefined,
@@ -252,7 +261,7 @@ export default class ScapiCustomStatus extends ScapiCustomCommand<typeof ScapiCu
252261
if (error) {
253262
this.error(
254263
t('commands.scapi.custom.status.error', 'Failed to fetch Custom API endpoints: {{message}}', {
255-
message: typeof error === 'object' ? JSON.stringify(error) : String(error),
264+
message: getApiErrorMessage(error, httpResponse),
256265
}),
257266
);
258267
}

0 commit comments

Comments
 (0)