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
71 changes: 70 additions & 1 deletion __tests__/lib/utils.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { splitKeyValueString } from '../../src/lib/utils';
import { parseApiError, splitKeyValueString } from '../../src/lib/utils';

describe( 'splitKeyValueString', () => {
it.each( [
Expand All @@ -13,3 +13,72 @@ describe( 'splitKeyValueString', () => {
expect( splitKeyValueString( input ) ).toEqual( expected );
} );
} );

describe( 'parseApiError', () => {
it( 'extracts the message from an HTTP response body (bodyText)', () => {
const error = Object.assign( new Error( 'Response not successful: Received status code 413' ), {
bodyText: '{"status":"error","message":"Request body is too large"}',
} );

expect( parseApiError( error ) ).toBe( 'Request body is too large' );
} );

it( 'extracts a GraphQL error message from a bodyText errors array', () => {
const error = Object.assign( new Error( 'Response not successful: Received status code 400' ), {
bodyText: JSON.stringify( {
errors: [ { message: 'Cannot query field "npmToken" on type "BuildConfiguration".' } ],
} ),
} );

expect( parseApiError( error ) ).toBe(
'Cannot query field "npmToken" on type "BuildConfiguration".'
);
} );

it( 'extracts the first message from a bodyText errors array', () => {
const error = Object.assign( new Error( 'Bad Request' ), {
bodyText: JSON.stringify( {
errors: [
{ message: 'Field "provider" of required type was not provided.' },
{ message: 'Field "providerXX" is not defined. Did you mean "provider"?' },
],
} ),
} );

expect( parseApiError( error ) ).toBe( 'Field "provider" of required type was not provided.' );
} );

it( 'extracts a networkError message', () => {
const error = { networkError: { message: 'network is down' } };

expect( parseApiError( error ) ).toBe( 'network is down' );
} );

it( 'extracts a graphQLErrors message', () => {
const error = { graphQLErrors: [ { message: 'BAD_REQUEST message' } ] };

expect( parseApiError( error ) ).toBe( 'BAD_REQUEST message' );
} );

it( 'falls back to the error message when bodyText is not JSON', () => {
const error = Object.assign( new Error( 'Boom' ), { bodyText: 'not json' } );

expect( parseApiError( error ) ).toBe( 'Boom' );
} );

it( 'falls back to the error message when the body has no message field', () => {
const error = Object.assign( new Error( 'Boom' ), {
bodyText: '{"status":"error"}',
} );

expect( parseApiError( error ) ).toBe( 'Boom' );
} );

it( 'returns the message for a plain Error', () => {
expect( parseApiError( new Error( 'Something failed' ) ) ).toBe( 'Something failed' );
} );

it( 'returns null for unknown error shapes', () => {
expect( parseApiError( { foo: 'bar' } ) ).toBeNull();
} );
} );
3 changes: 1 addition & 2 deletions src/bin/vip-app-deploy-token-generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,7 @@ export async function appDeployTokenGenerateCmd(
},
} );
} catch ( err ) {
const message =
parseApiError( err as Parameters< typeof parseApiError >[ 0 ] ) ?? 'Unknown error';
const message = parseApiError( err ) ?? 'Unknown error';
await trackerFn( 'error', { error: message } );
exit.withError( `Failed to generate deploy token: ${ message }` );
return;
Expand Down
42 changes: 31 additions & 11 deletions src/commands/export-sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,14 @@ import {
BackupLiveCopyType,
} from '../lib/live-backup-copy';
import { retry } from '../lib/retry';
import { getAbsolutePath, pollUntil } from '../lib/utils';
import UserError from '../lib/user-error';
import { getAbsolutePath, pollUntil, parseApiError } from '../lib/utils';

const EXPORT_SQL_PROGRESS_POLL_INTERVAL = 1000;

// Maximum allowed size for a live backup copy configuration file (500 KB).
const MAX_LIVE_BACKUP_CONFIG_SIZE_BYTES = 500 * 1024;

const BACKUP_AND_JOB_STATUS_QUERY = gql`
query AppBackupAndJobStatus($appId: Int!, $envId: Int!) {
app(id: $appId) {
Expand Down Expand Up @@ -569,11 +573,11 @@ export class ExportSQLCommand {

private async generateLiveBackupCopy() {
if ( ! this.liveBackupCopyCLIOptions ) {
throw new Error( 'Configuration file is required for live backup copy.' );
throw new UserError( 'Configuration file is required for live backup copy.' );
}

if ( ! this.app.id || ! this.env.id ) {
throw new Error( 'App ID and Environment ID are required to start live backup copy.' );
throw new UserError( 'App ID and Environment ID are required to start live backup copy.' );
}

const config = this.getLiveBackupConfigFromCLIOptions();
Expand Down Expand Up @@ -601,7 +605,7 @@ export class ExportSQLCommand {

return result;
} catch ( err ) {
const message = err instanceof Error ? err.message : 'Unknown error';
const message = parseApiError( err ) ?? 'Unknown error';

await this.track( 'error', {
error_type: 'live_backup_copy',
Expand Down Expand Up @@ -645,19 +649,35 @@ export class ExportSQLCommand {
}

private loadLiveBackupCopyConfig( configFile: string ): DBLiveCopyConfig {
if ( ! fs.existsSync( configFile ) ) {
throw new Error( `Configuration file not found: ${ configFile }` );
}

try {
return JSON.parse( fs.readFileSync( configFile, 'utf-8' ) ) as DBLiveCopyConfig;
// Read the file once to avoid a check-then-use race condition.
const fileContents = fs.readFileSync( configFile, 'utf-8' );

const size = Buffer.byteLength( fileContents );
if ( size > MAX_LIVE_BACKUP_CONFIG_SIZE_BYTES ) {
throw new UserError(
`Configuration file is too large (${ formatBytes(
size
) }); the maximum allowed size is ${ formatBytes(
MAX_LIVE_BACKUP_CONFIG_SIZE_BYTES
) }. For large or complex exports, use the \`--wpcli-command\` option instead.`
);
}

return JSON.parse( fileContents ) as DBLiveCopyConfig;
} catch ( err ) {
if ( err instanceof UserError ) {
throw err;
}

const errMessage = err instanceof Error ? err.message : 'Unknown error';
if ( err instanceof SyntaxError ) {
throw new Error( `Invalid JSON in configuration file: ${ configFile } - ${ errMessage }` );
throw new UserError(
`Invalid JSON in configuration file: ${ configFile } - ${ errMessage }`
);
}

throw new Error( `Error reading configuration file: ${ configFile } - ${ errMessage }` );
throw new UserError( `Error reading configuration file: ${ configFile } - ${ errMessage }` );
}
}
}
53 changes: 47 additions & 6 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,17 +79,49 @@ export function getAbsolutePath( filePath: string ): string {
}

/**
* Parse error object and return probable error.
*
* @param {Error} Error object
* Extract a message from a raw HTTP response body (Apollo's `ServerError.bodyText`).
*
* @return {string|null} Error string when error was found, otherwise null.
* The body may be either `{"status":"error","message":"..."}` (e.g. a 413 whose
* top-level message is only `Received status code 413`) or a GraphQL
* `{"errors":[{"message":"..."}]}` payload.
*/
export function parseApiError( err: {
function parseJSONBodyErrorMessage( bodyText: string ): string | null {
try {
const body = JSON.parse( bodyText ) as {
message?: string;
errors?: { message?: string }[];
};

if ( body?.errors?.[ 0 ]?.message ) {
return body.errors[ 0 ].message;
}

if ( body?.message ) {
return body.message;
}
} catch {
// Body wasn't JSON; nothing to extract.
}

return null;
}

interface ApiError {
bodyText?: string;
networkError?: { message?: string };
message?: string;
graphQLErrors?: { message?: string }[];
} ): string | null {
}

/**
* Parse error object and return probable error.
*
* Accepts either a known API error shape or an `unknown` value (e.g. a caught
* error), so callers do not need to cast before passing it in.
*/
export function parseApiError( error: unknown ): string | null {
const err = error as ApiError;

if ( err?.networkError?.message ) {
return err?.networkError?.message;
}
Expand All @@ -98,6 +130,15 @@ export function parseApiError( err: {
return err.graphQLErrors[ 0 ].message;
}

// Apollo's `ServerError` exposes the raw HTTP response body as `bodyText`,
// which often holds a more specific message than the generic top-level one.
if ( typeof err?.bodyText === 'string' ) {
const bodyTextMessage = parseJSONBodyErrorMessage( err.bodyText );
if ( bodyTextMessage ) {
return bodyTextMessage;
}
}

if ( err?.message ) {
return err?.message;
}
Expand Down