Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/fix-error-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@salesforce/b2c-cli': patch
'@salesforce/b2c-tooling-sdk': patch
---

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.

Added `getApiErrorMessage(error, response)` utility that extracts clean error messages from ODS, OCAPI, and SCAPI error patterns with HTTP status fallback.
56 changes: 56 additions & 0 deletions .claude/skills/api-client-development/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,62 @@ it('fetches endpoints', async () => {

---

## Error Handling

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.

### Using getApiErrorMessage

```typescript
import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk/clients';

const {data, error, response} = await client.GET('/sites', {...});

if (error) {
// Returns structured error message or "HTTP 521 Web Server Is Down"
const message = getApiErrorMessage(error, response);
this.error(`Failed to fetch sites: ${message}`);
}
```

### Supported Error Patterns

The utility extracts messages from these patterns in priority order:

| API | Error Structure | Message Location |
|-----|-----------------|------------------|
| ODS/SLAS | `{ error: { message } }` | `error.error.message` |
| OCAPI | `{ fault: { message } }` | `error.fault.message` |
| SCAPI/Problem+JSON | `{ title, detail }` | `error.detail` or `error.title` |
| Standard Error | `{ message }` | `error.message` |
| Fallback | Any | `HTTP {status} {statusText}` |

### Why This Matters

**Without `getApiErrorMessage`:**
```
ERROR: Failed to fetch sites: <!DOCTYPE html><html lang="en"><head><title>521 - Sandbox Down</title>...
```

**With `getApiErrorMessage`:**
```
ERROR: Failed to fetch sites: HTTP 521 Web Server Is Down
```

### Important: Always Destructure `response`

When making API calls, always destructure the `response` object alongside `error`:

```typescript
// GOOD: Include response for error handling
const {data, error, response} = await client.GET('/endpoint', {...});

// BAD: Missing response - can't get clean error message
const {data, error} = await client.GET('/endpoint', {...});
```

---

## Checklist: New SCAPI Client

1. Add OpenAPI spec to `specs/`
Expand Down
28 changes: 18 additions & 10 deletions .claude/skills/cli-command-development/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import { InstanceCommand, CartridgeCommand, OdsCommand } from '@salesforce/b2c-t
*/
import {Args, Flags} from '@oclif/core';
import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli';
import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk';
import {t} from '../../i18n/index.js';

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

// Implementation
const result = await this.instance.ocapi.GET('/some/endpoint');
const {data, error, response} = await this.instance.ocapi.GET('/some/endpoint');

if (!result.data) {
if (error) {
this.error(t('commands.topic.mycommand.error', 'Failed: {{message}}', {
message: result.response?.statusText || 'Unknown error',
message: getApiErrorMessage(error, response),
}));
}

const response: MyCommandResponse = {
const result: MyCommandResponse = {
success: true,
data: result.data,
data,
};

// JSON mode returns the object directly (oclif handles serialization)
if (this.jsonEnabled()) {
return response;
return result;
}

// Human-readable output
this.log('Success!');
return response;
return result;
}
}
```
Expand Down Expand Up @@ -305,14 +306,21 @@ this.error('Config file not found', {
// Warning (continues execution)
this.warn('Deprecated flag used');

// Structured API errors
if (result.error) {
// API errors - use getApiErrorMessage for clean messages
import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk';

const {data, error, response} = await this.instance.ocapi.GET('/sites', {...});
if (error) {
this.error(t('commands.topic.cmd.apiError', 'API error: {{message}}', {
message: formatApiError(result.error),
message: getApiErrorMessage(error, response),
}));
}
```

**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.

See [API Client Development](../api-client-development/SKILL.md#error-handling) for supported error patterns.

## Creating a Command Checklist

1. Create file at `packages/b2c-cli/src/commands/<topic>/<command>.ts`
Expand Down
6 changes: 2 additions & 4 deletions packages/b2c-cli/src/commands/ods/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import {Flags, ux} from '@oclif/core';
import cliui from 'cliui';
import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli';
import type {OdsComponents} from '@salesforce/b2c-tooling-sdk';
import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk';
import {t} from '../../i18n/index.js';

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

if (!result.data?.data) {
const errorResponse = result.error as OdsComponents['schemas']['ErrorResponse'] | undefined;
const errorMessage = errorResponse?.error?.message || result.response?.statusText || 'Unknown error';
this.error(
t('commands.ods.create.error', 'Failed to create sandbox: {{message}}', {
message: errorMessage,
message: getApiErrorMessage(result.error, result.response),
}),
);
}
Expand Down
6 changes: 2 additions & 4 deletions packages/b2c-cli/src/commands/ods/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import * as readline from 'node:readline';
import {Args, Flags} from '@oclif/core';
import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli';
import type {OdsComponents} from '@salesforce/b2c-tooling-sdk';
import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk';
import {t} from '../../i18n/index.js';

/**
Expand Down Expand Up @@ -92,11 +92,9 @@ export default class OdsDelete extends OdsCommand<typeof OdsDelete> {
});

if (result.response.status !== 202) {
const errorResponse = result.error as OdsComponents['schemas']['ErrorResponse'] | undefined;
const errorMessage = errorResponse?.error?.message || result.response?.statusText || 'Unknown error';
this.error(
t('commands.ods.delete.error', 'Failed to delete sandbox: {{message}}', {
message: errorMessage,
message: getApiErrorMessage(result.error, result.response),
}),
);
}
Expand Down
6 changes: 2 additions & 4 deletions packages/b2c-cli/src/commands/ods/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/
import {Flags} from '@oclif/core';
import {OdsCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli';
import type {OdsComponents} from '@salesforce/b2c-tooling-sdk';
import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk';
import {t} from '../../i18n/index.js';

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

if (result.error) {
const errorResponse = result.error as OdsComponents['schemas']['ErrorResponse'] | undefined;
const errorMessage = errorResponse?.error?.message || result.response?.statusText || 'Unknown error';
this.error(
t('commands.ods.list.error', 'Failed to fetch sandboxes: {{message}}', {
message: errorMessage,
message: getApiErrorMessage(result.error, result.response),
}),
);
}
Expand Down
6 changes: 2 additions & 4 deletions packages/b2c-cli/src/commands/ods/restart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/
import {Args} from '@oclif/core';
import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli';
import type {OdsComponents} from '@salesforce/b2c-tooling-sdk';
import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk';
import {t} from '../../i18n/index.js';

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

if (!result.data?.data) {
const errorResponse = result.error as OdsComponents['schemas']['ErrorResponse'] | undefined;
const errorMessage = errorResponse?.error?.message || result.response?.statusText || 'Unknown error';
this.error(
t('commands.ods.restart.error', 'Failed to restart sandbox: {{message}}', {
message: errorMessage,
message: getApiErrorMessage(result.error, result.response),
}),
);
}
Expand Down
6 changes: 2 additions & 4 deletions packages/b2c-cli/src/commands/ods/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/
import {Args} from '@oclif/core';
import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli';
import type {OdsComponents} from '@salesforce/b2c-tooling-sdk';
import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk';
import {t} from '../../i18n/index.js';

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

if (!result.data?.data) {
const errorResponse = result.error as OdsComponents['schemas']['ErrorResponse'] | undefined;
const errorMessage = errorResponse?.error?.message || result.response?.statusText || 'Unknown error';
this.error(
t('commands.ods.start.error', 'Failed to start sandbox: {{message}}', {
message: errorMessage,
message: getApiErrorMessage(result.error, result.response),
}),
);
}
Expand Down
6 changes: 2 additions & 4 deletions packages/b2c-cli/src/commands/ods/stop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/
import {Args} from '@oclif/core';
import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli';
import type {OdsComponents} from '@salesforce/b2c-tooling-sdk';
import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk';
import {t} from '../../i18n/index.js';

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

if (!result.data?.data) {
const errorResponse = result.error as OdsComponents['schemas']['ErrorResponse'] | undefined;
const errorMessage = errorResponse?.error?.message || result.response?.statusText || 'Unknown error';
this.error(
t('commands.ods.stop.error', 'Failed to stop sandbox: {{message}}', {
message: errorMessage,
message: getApiErrorMessage(result.error, result.response),
}),
);
}
Expand Down
15 changes: 12 additions & 3 deletions packages/b2c-cli/src/commands/scapi/custom/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@
*/
import {Command, Flags, ux} from '@oclif/core';
import {OAuthCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli';
import {createCustomApisClient, toOrganizationId, type CustomApisComponents} from '@salesforce/b2c-tooling-sdk';
import {
createCustomApisClient,
getApiErrorMessage,
toOrganizationId,
type CustomApisComponents,
} from '@salesforce/b2c-tooling-sdk';
import {t} from '../../../i18n/index.js';

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

const {data, error} = await client.GET('/organizations/{organizationId}/endpoints', {
const {
data,
error,
response: httpResponse,
} = await client.GET('/organizations/{organizationId}/endpoints', {
params: {
path: {organizationId},
query: status ? {status: status as 'active' | 'not_registered'} : undefined,
Expand All @@ -252,7 +261,7 @@ export default class ScapiCustomStatus extends ScapiCustomCommand<typeof ScapiCu
if (error) {
this.error(
t('commands.scapi.custom.status.error', 'Failed to fetch Custom API endpoints: {{message}}', {
message: typeof error === 'object' ? JSON.stringify(error) : String(error),
message: getApiErrorMessage(error, httpResponse),
}),
);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/b2c-cli/src/commands/scapi/schemas/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ export default class ScapiSchemasGet extends ScapiSchemasCommand<typeof ScapiSch

const client = this.getSchemasClient();

const {data, error} = await client.GET(
const {data, error, response} = await client.GET(
'/organizations/{organizationId}/schemas/{apiFamily}/{apiName}/{apiVersion}',
{
params: {
Expand All @@ -186,7 +186,7 @@ export default class ScapiSchemasGet extends ScapiSchemasCommand<typeof ScapiSch
if (error) {
this.error(
t('commands.scapi.schemas.get.error', 'Failed to fetch schema: {{message}}', {
message: formatApiError(error),
message: formatApiError(error, response),
}),
);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/b2c-cli/src/commands/scapi/schemas/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export default class ScapiSchemasList extends ScapiSchemasCommand<typeof ScapiSc

const client = this.getSchemasClient();

const {data, error} = await client.GET('/organizations/{organizationId}/schemas', {
const {data, error, response} = await client.GET('/organizations/{organizationId}/schemas', {
params: {
path: {organizationId: this.getOrganizationId()},
query: {
Expand All @@ -121,7 +121,7 @@ export default class ScapiSchemasList extends ScapiSchemasCommand<typeof ScapiSc
if (error) {
this.error(
t('commands.scapi.schemas.list.error', 'Failed to fetch SCAPI schemas: {{message}}', {
message: formatApiError(error),
message: formatApiError(error, response),
}),
);
}
Expand Down
9 changes: 7 additions & 2 deletions packages/b2c-cli/src/commands/sites/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/
import {ux} from '@oclif/core';
import {InstanceCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli';
import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk/clients';
import type {OcapiComponents} from '@salesforce/b2c-tooling-sdk';
import {t} from '../../i18n/index.js';

Expand Down Expand Up @@ -46,12 +47,16 @@ export default class SitesList extends InstanceCommand<typeof SitesList> {

this.log(t('commands.sites.list.fetching', 'Fetching sites from {{hostname}}...', {hostname}));

const {data, error} = await this.instance.ocapi.GET('/sites', {
const {data, error, response} = await this.instance.ocapi.GET('/sites', {
params: {query: {select: '(**)'}},
});

if (error) {
this.error(t('commands.sites.list.error', 'Failed to fetch sites: {{message}}', {message: String(error)}));
this.error(
t('commands.sites.list.error', 'Failed to fetch sites: {{message}}', {
message: getApiErrorMessage(error, response),
}),
);
}

const sites = data as Sites;
Expand Down
2 changes: 1 addition & 1 deletion packages/b2c-cli/src/commands/slas/client/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ export default class SlasClientCreate extends SlasClientCommand<typeof SlasClien
if (error) {
this.error(
t('commands.slas.client.create.error', 'Failed to create/update SLAS client: {{message}}', {
message: formatApiError(error),
message: formatApiError(error, response),
}),
);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/b2c-cli/src/commands/slas/client/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export default class SlasClientDelete extends SlasClientCommand<typeof SlasClien

const slasClient = this.getSlasClient();

const {error} = await slasClient.DELETE('/tenants/{tenantId}/clients/{clientId}', {
const {error, response} = await slasClient.DELETE('/tenants/{tenantId}/clients/{clientId}', {
params: {
path: {tenantId, clientId},
},
Expand All @@ -54,7 +54,7 @@ export default class SlasClientDelete extends SlasClientCommand<typeof SlasClien
if (error) {
this.error(
t('commands.slas.client.delete.error', 'Failed to delete SLAS client: {{message}}', {
message: formatApiError(error),
message: formatApiError(error, response),
}),
);
}
Expand Down
Loading
Loading