From db6b4ad5ae1bccbd3ba624812bc6ac277620ff6b Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Wed, 21 Jan 2026 14:52:18 -0500 Subject: [PATCH 1/2] 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. --- .changeset/fix-error-output.md | 8 + packages/b2c-cli/src/commands/ods/create.ts | 6 +- packages/b2c-cli/src/commands/ods/delete.ts | 6 +- packages/b2c-cli/src/commands/ods/list.ts | 6 +- packages/b2c-cli/src/commands/ods/restart.ts | 6 +- packages/b2c-cli/src/commands/ods/start.ts | 6 +- packages/b2c-cli/src/commands/ods/stop.ts | 6 +- .../src/commands/scapi/custom/status.ts | 15 +- .../b2c-cli/src/commands/scapi/schemas/get.ts | 4 +- .../src/commands/scapi/schemas/list.ts | 4 +- packages/b2c-cli/src/commands/sites/list.ts | 9 +- .../src/commands/slas/client/create.ts | 2 +- .../src/commands/slas/client/delete.ts | 4 +- .../b2c-cli/src/commands/slas/client/get.ts | 4 +- .../b2c-cli/src/commands/slas/client/list.ts | 4 +- .../src/commands/slas/client/update.ts | 12 +- packages/b2c-cli/src/utils/scapi/schemas.ts | 11 +- packages/b2c-cli/src/utils/slas/client.ts | 12 +- .../src/clients/error-utils.ts | 74 ++++++++ packages/b2c-tooling-sdk/src/clients/index.ts | 2 + packages/b2c-tooling-sdk/src/index.ts | 1 + .../test/clients/error-utils.test.ts | 171 ++++++++++++++++++ 22 files changed, 320 insertions(+), 53 deletions(-) create mode 100644 .changeset/fix-error-output.md create mode 100644 packages/b2c-tooling-sdk/src/clients/error-utils.ts create mode 100644 packages/b2c-tooling-sdk/test/clients/error-utils.test.ts diff --git a/.changeset/fix-error-output.md b/.changeset/fix-error-output.md new file mode 100644 index 00000000..8ecead91 --- /dev/null +++ b/.changeset/fix-error-output.md @@ -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. diff --git a/packages/b2c-cli/src/commands/ods/create.ts b/packages/b2c-cli/src/commands/ods/create.ts index d98cb570..a8dc2409 100644 --- a/packages/b2c-cli/src/commands/ods/create.ts +++ b/packages/b2c-cli/src/commands/ods/create.ts @@ -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']; @@ -139,11 +139,9 @@ export default class OdsCreate extends OdsCommand { }); 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), }), ); } diff --git a/packages/b2c-cli/src/commands/ods/delete.ts b/packages/b2c-cli/src/commands/ods/delete.ts index 091e1e9a..86223221 100644 --- a/packages/b2c-cli/src/commands/ods/delete.ts +++ b/packages/b2c-cli/src/commands/ods/delete.ts @@ -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'; /** @@ -92,11 +92,9 @@ export default class OdsDelete extends OdsCommand { }); 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), }), ); } diff --git a/packages/b2c-cli/src/commands/ods/list.ts b/packages/b2c-cli/src/commands/ods/list.ts index 57a9b783..430157c6 100644 --- a/packages/b2c-cli/src/commands/ods/list.ts +++ b/packages/b2c-cli/src/commands/ods/list.ts @@ -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']; @@ -142,11 +142,9 @@ export default class OdsList extends OdsCommand { }); 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), }), ); } diff --git a/packages/b2c-cli/src/commands/ods/restart.ts b/packages/b2c-cli/src/commands/ods/restart.ts index b6667369..44a51b51 100644 --- a/packages/b2c-cli/src/commands/ods/restart.ts +++ b/packages/b2c-cli/src/commands/ods/restart.ts @@ -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']; @@ -45,11 +45,9 @@ export default class OdsRestart extends OdsCommand { }); 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), }), ); } diff --git a/packages/b2c-cli/src/commands/ods/start.ts b/packages/b2c-cli/src/commands/ods/start.ts index 2a1aa8ae..fc3c0698 100644 --- a/packages/b2c-cli/src/commands/ods/start.ts +++ b/packages/b2c-cli/src/commands/ods/start.ts @@ -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']; @@ -45,11 +45,9 @@ export default class OdsStart extends OdsCommand { }); 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), }), ); } diff --git a/packages/b2c-cli/src/commands/ods/stop.ts b/packages/b2c-cli/src/commands/ods/stop.ts index 3160bb05..78d4c053 100644 --- a/packages/b2c-cli/src/commands/ods/stop.ts +++ b/packages/b2c-cli/src/commands/ods/stop.ts @@ -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']; @@ -45,11 +45,9 @@ export default class OdsStop extends OdsCommand { }); 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), }), ); } diff --git a/packages/b2c-cli/src/commands/scapi/custom/status.ts b/packages/b2c-cli/src/commands/scapi/custom/status.ts index e8ed0df6..cecb3e67 100644 --- a/packages/b2c-cli/src/commands/scapi/custom/status.ts +++ b/packages/b2c-cli/src/commands/scapi/custom/status.ts @@ -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']; @@ -242,7 +247,11 @@ export default class ScapiCustomStatus extends ScapiCustomCommand { 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; diff --git a/packages/b2c-cli/src/commands/slas/client/create.ts b/packages/b2c-cli/src/commands/slas/client/create.ts index f57e233c..7e6caaa1 100644 --- a/packages/b2c-cli/src/commands/slas/client/create.ts +++ b/packages/b2c-cli/src/commands/slas/client/create.ts @@ -178,7 +178,7 @@ export default class SlasClientCreate extends SlasClientCommand extends OAuthC if (!isTenantNotFound) { this.error( t('commands.slas.client.create.tenantError', 'Failed to check tenant: {{message}}', { - message: formatApiError(error), + message: formatApiError(error, response), }), ); } @@ -149,7 +149,7 @@ export abstract class SlasClientCommand extends OAuthC this.log(t('commands.slas.client.create.creatingTenant', 'Creating SLAS tenant {{tenantId}}...', {tenantId})); } - const {error: createError} = await slasClient.PUT('/tenants/{tenantId}', { + const {error: createError, response: createResponse} = await slasClient.PUT('/tenants/{tenantId}', { params: { path: {tenantId}, }, @@ -166,7 +166,7 @@ export abstract class SlasClientCommand extends OAuthC if (createError) { this.error( t('commands.slas.client.create.tenantCreateError', 'Failed to create tenant: {{message}}', { - message: formatApiError(createError), + message: formatApiError(createError, createResponse), }), ); } diff --git a/packages/b2c-tooling-sdk/src/clients/error-utils.ts b/packages/b2c-tooling-sdk/src/clients/error-utils.ts new file mode 100644 index 00000000..ed57363e --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/error-utils.ts @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/** + * Utilities for extracting error messages from API responses. + * + * @module clients/error-utils + */ + +/** + * Extract a clean error message from an API error response. + * + * Handles multiple API error patterns and falls back to HTTP status. + * This ensures that HTML response bodies (like error pages) are never + * included in user-facing error messages. + * + * Supported error patterns: + * - ODS/SLAS: `{ error: { message: '...' } }` + * - OCAPI: `{ fault: { message: '...' } }` + * - SCAPI/Problem+JSON: `{ detail: '...', title: '...' }` + * - Standard Error: `{ message: '...' }` + * + * @param error - The error object from an API response + * @param response - The HTTP response (for status code fallback) + * @returns A clean, human-readable error message + * + * @example + * ```typescript + * const {data, error, response} = await client.GET('/sites', {...}); + * if (error) { + * const message = getApiErrorMessage(error, response); + * // Returns structured error message or "HTTP 521 Web Server Is Down" + * } + * ``` + */ +export function getApiErrorMessage(error: unknown, response: Response | {status: number; statusText: string}): string { + if (error && typeof error === 'object') { + const err = error as Record; + + // ODS/SLAS pattern: { error: { message: '...' } } + if (err.error && typeof err.error === 'object') { + const nested = err.error as Record; + if (typeof nested.message === 'string' && nested.message) { + return nested.message; + } + } + + // OCAPI fault pattern: { fault: { message: '...' } } + if (err.fault && typeof err.fault === 'object') { + const fault = err.fault as Record; + if (typeof fault.message === 'string' && fault.message) { + return fault.message; + } + } + + // SCAPI/Problem+JSON pattern: { detail: '...', title: '...' } + if (typeof err.detail === 'string' && err.detail) { + return err.detail; + } + if (typeof err.title === 'string' && err.title) { + return err.title; + } + + // Standard Error pattern: { message: '...' } + if (typeof err.message === 'string' && err.message) { + return err.message; + } + } + + // Fallback to HTTP status + return `HTTP ${response.status} ${response.statusText}`; +} diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index 591d3615..688b7195 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -195,3 +195,5 @@ export type { paths as ScapiSchemasPaths, components as ScapiSchemasComponents, } from './scapi-schemas.js'; + +export {getApiErrorMessage} from './error-utils.js'; diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index 8b2e5923..4970ef19 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -74,6 +74,7 @@ export { toOrganizationId, toTenantId, buildTenantScope, + getApiErrorMessage, ORGANIZATION_ID_PREFIX, SCAPI_TENANT_SCOPE_PREFIX, CUSTOM_APIS_DEFAULT_SCOPES, diff --git a/packages/b2c-tooling-sdk/test/clients/error-utils.test.ts b/packages/b2c-tooling-sdk/test/clients/error-utils.test.ts new file mode 100644 index 00000000..fc51b5e7 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/clients/error-utils.test.ts @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {expect} from 'chai'; +import {getApiErrorMessage} from '../../src/clients/error-utils.js'; + +describe('getApiErrorMessage', () => { + // Mock response object for testing + const mockResponse = (status: number, statusText: string) => ({ + status, + statusText, + }); + + describe('ODS/SLAS error pattern', () => { + it('extracts message from { error: { message } } structure', () => { + const error = {error: {message: 'Sandbox not found'}}; + const response = mockResponse(404, 'Not Found'); + expect(getApiErrorMessage(error, response)).to.equal('Sandbox not found'); + }); + + it('ignores error.error if message is not a string', () => { + const error = {error: {message: 123}}; + const response = mockResponse(500, 'Internal Server Error'); + expect(getApiErrorMessage(error, response)).to.equal('HTTP 500 Internal Server Error'); + }); + + it('ignores error.error if message is empty', () => { + const error = {error: {message: ''}}; + const response = mockResponse(500, 'Internal Server Error'); + expect(getApiErrorMessage(error, response)).to.equal('HTTP 500 Internal Server Error'); + }); + }); + + describe('OCAPI fault pattern', () => { + it('extracts message from { fault: { message } } structure', () => { + const error = {fault: {type: 'NotFoundException', message: 'Site not found'}}; + const response = mockResponse(404, 'Not Found'); + expect(getApiErrorMessage(error, response)).to.equal('Site not found'); + }); + + it('ignores fault if message is not a string', () => { + const error = {fault: {type: 'Error', message: null}}; + const response = mockResponse(500, 'Internal Server Error'); + expect(getApiErrorMessage(error, response)).to.equal('HTTP 500 Internal Server Error'); + }); + }); + + describe('SCAPI/Problem+JSON pattern', () => { + it('extracts detail from { detail } structure', () => { + const error = {type: 'NotFound', title: 'Resource Not Found', detail: 'The requested schema was not found'}; + const response = mockResponse(404, 'Not Found'); + expect(getApiErrorMessage(error, response)).to.equal('The requested schema was not found'); + }); + + it('falls back to title if detail is missing', () => { + const error = {type: 'NotFound', title: 'Resource Not Found'}; + const response = mockResponse(404, 'Not Found'); + expect(getApiErrorMessage(error, response)).to.equal('Resource Not Found'); + }); + + it('ignores detail if it is empty', () => { + const error = {title: 'Some Title', detail: ''}; + const response = mockResponse(400, 'Bad Request'); + expect(getApiErrorMessage(error, response)).to.equal('Some Title'); + }); + }); + + describe('standard Error pattern', () => { + it('extracts message from { message } structure', () => { + const error = {message: 'Something went wrong'}; + const response = mockResponse(500, 'Internal Server Error'); + expect(getApiErrorMessage(error, response)).to.equal('Something went wrong'); + }); + + it('works with actual Error objects', () => { + const error = new Error('Connection refused'); + const response = mockResponse(503, 'Service Unavailable'); + expect(getApiErrorMessage(error, response)).to.equal('Connection refused'); + }); + }); + + describe('HTTP status fallback', () => { + it('returns HTTP status when error is null', () => { + const response = mockResponse(521, 'Web Server Is Down'); + expect(getApiErrorMessage(null, response)).to.equal('HTTP 521 Web Server Is Down'); + }); + + it('returns HTTP status when error is undefined', () => { + const response = mockResponse(502, 'Bad Gateway'); + expect(getApiErrorMessage(undefined, response)).to.equal('HTTP 502 Bad Gateway'); + }); + + it('returns HTTP status when error is not an object', () => { + const response = mockResponse(500, 'Internal Server Error'); + expect(getApiErrorMessage('some string error', response)).to.equal('HTTP 500 Internal Server Error'); + expect(getApiErrorMessage(123, response)).to.equal('HTTP 500 Internal Server Error'); + }); + + it('returns HTTP status when error object has no recognized fields', () => { + const error = {someField: 'value', html: '...'}; + const response = mockResponse(521, 'Sandbox Down'); + expect(getApiErrorMessage(error, response)).to.equal('HTTP 521 Sandbox Down'); + }); + + it('does not include HTML content in the message', () => { + const error = {body: 'Error page'}; + const response = mockResponse(521, 'Web Server Is Down'); + const message = getApiErrorMessage(error, response); + expect(message).to.equal('HTTP 521 Web Server Is Down'); + expect(message).to.not.include(''); + }); + }); + + describe('priority order', () => { + it('prioritizes error.error.message over other fields', () => { + const error = { + error: {message: 'ODS message'}, + fault: {message: 'OCAPI message'}, + detail: 'SCAPI detail', + message: 'Standard message', + }; + const response = mockResponse(500, 'Error'); + expect(getApiErrorMessage(error, response)).to.equal('ODS message'); + }); + + it('prioritizes fault.message over SCAPI and standard message', () => { + const error = { + fault: {message: 'OCAPI message'}, + detail: 'SCAPI detail', + message: 'Standard message', + }; + const response = mockResponse(500, 'Error'); + expect(getApiErrorMessage(error, response)).to.equal('OCAPI message'); + }); + + it('prioritizes detail over title and standard message', () => { + const error = { + title: 'Error Title', + detail: 'Error Detail', + message: 'Standard message', + }; + const response = mockResponse(500, 'Error'); + expect(getApiErrorMessage(error, response)).to.equal('Error Detail'); + }); + + it('prioritizes title over standard message', () => { + const error = { + title: 'Error Title', + message: 'Standard message', + }; + const response = mockResponse(500, 'Error'); + expect(getApiErrorMessage(error, response)).to.equal('Error Title'); + }); + }); + + describe('works with real Response objects', () => { + it('accepts a Response-like object', () => { + // Simulate what openapi-fetch returns + const error = null; + const response = { + status: 521, + statusText: 'Web Server Is Down', + ok: false, + headers: new Headers(), + }; + expect(getApiErrorMessage(error, response as Response)).to.equal('HTTP 521 Web Server Is Down'); + }); + }); +}); From c9fce292c58eb21286e93481e28080aad27910d7 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Wed, 21 Jan 2026 15:00:57 -0500 Subject: [PATCH 2/2] 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. --- .../skills/api-client-development/SKILL.md | 56 +++++++++++++++++++ .../skills/cli-command-development/SKILL.md | 28 ++++++---- 2 files changed, 74 insertions(+), 10 deletions(-) diff --git a/.claude/skills/api-client-development/SKILL.md b/.claude/skills/api-client-development/SKILL.md index c61118b8..13162723 100644 --- a/.claude/skills/api-client-development/SKILL.md +++ b/.claude/skills/api-client-development/SKILL.md @@ -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: 521 - Sandbox Down... +``` + +**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/` diff --git a/.claude/skills/cli-command-development/SKILL.md b/.claude/skills/cli-command-development/SKILL.md index 8a8f1779..9368120d 100644 --- a/.claude/skills/cli-command-development/SKILL.md +++ b/.claude/skills/cli-command-development/SKILL.md @@ -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 { @@ -105,27 +106,27 @@ export default class MyCommand extends InstanceCommand { 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; } } ``` @@ -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//.ts`