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
41 changes: 41 additions & 0 deletions src/infrastructure/service-error.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import axios from "axios";
import { ApiError, ProblemDetailsError } from "@apimatic/sdk";
import { format as f } from "../prompts/format.js";

export enum ServiceErrorCode {
Expand Down Expand Up @@ -30,6 +31,14 @@
static notFound(customMessage: string): ServiceError {
return new ServiceError(ServiceErrorCode.NotFound, customMessage, {});
}
static unauthorizedWithHint(apiMessage: string | null): ServiceError {
const message = `${apiMessage ?? "You are not authorized to perform this action."} Please run ${f.cmdAlt(
"apimatic",
"auth",
"login"
)} to log in, or provide a valid auth key using the ${f.flag("auth-key")} flag.`;
return new ServiceError(ServiceErrorCode.UnAuthorized, message, {});
}

static readonly values: ServiceError[] = [
ServiceError.NotFound,
Expand All @@ -54,7 +63,39 @@
}
}

// A ProblemDetails body carries a `title` plus a map of field errors; surface
// the title and the first field message.
function mapProblemDetailsError(error: ProblemDetailsError): ServiceError | null {
// ProblemDetails.title and .errors are optional and result may be absent — guard
// all three so a sparse body can't crash or render a trailing "- null".
// TODO: This only picks the first error message, improve it to show all errors.

Check warning on line 71 in src/infrastructure/service-error.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=apimatic_apimatic-cli&issues=AZ993NkZXea5fq-azeZi&open=AZ993NkZXea5fq-azeZi&pullRequest=297
const errors = (error.result?.errors ?? {}) as Record<string, string[]>;
const firstFieldMessage = Object.values(errors)[0]?.[0];
const title = error.result?.title ?? "Request failed.";
const errorMessage = firstFieldMessage ? `${title}\n- ${firstFieldMessage}` : title;
if (error.statusCode === 400) return ServiceError.badRequest(errorMessage, errors);
if (error.statusCode === 403) return ServiceError.forbidden(errorMessage);
return null;
}

// SDK controllers throw typed `ApiError`s (not axios errors). The API reports
// the reason as {"message": "..."} deserialized into `result`.
function mapApiError(error: ApiError): ServiceError {
if (error instanceof ProblemDetailsError) {
const serviceError = mapProblemDetailsError(error);
if (serviceError) return serviceError;
}
if (error.statusCode === 401) {
const apiMessage = (error.result as { message?: string } | undefined)?.message ?? null;
return ServiceError.unauthorizedWithHint(apiMessage);
}
if (error.statusCode === 404) return ServiceError.NotFound;
return ServiceError.ServerError;
}

export function handleServiceError(error: unknown): ServiceError {
if (error instanceof ApiError) return mapApiError(error);

@mehnoorsiddiqui mehnoorsiddiqui Jul 22, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleServiceError is shared by 7 services (api-, auth-, file-download-, publishing-api-, validation-, portal-service). The new if (error instanceof ApiError) branch changes behavior for every SDK-based caller, not just portal.Is this intentional

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In practice portal-service is the only caller that routes an SDK ApiError through it today, every other service (api, auth, file-download, publishing-api, validation) is axios-based and still hits the axios branch unchanged, and transformation-service uses its own handler and never calls handleServiceError. So there's no behavior change for non-portal callers right now.
In the first commit the error handling was done inside portal-service. The refactor commits later on moved these to error-service


if (axios.isAxiosError(error)) {
const status = error.response?.status;
if (status === 401) return ServiceError.UnAuthorized;
Expand Down
47 changes: 4 additions & 43 deletions src/infrastructure/services/portal-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
ContentType,
DocsPortalGenerationAsyncController,
SdkSourceTreeGenerationAsyncController,
ProblemDetailsError,
FileWrapper,
TransformationController,
Transformation,
Expand Down Expand Up @@ -66,20 +65,8 @@ export class PortalService {
);
generationId = portalInstance.result.id;
} catch (error) {
if (error instanceof ProblemDetailsError) {
const errors = error.result!.errors as Record<string, string[]>;
// TODO: This only picks the first error message, improve it to show all errors.
const message = Object.values(errors)[0]?.[0] ?? null;
const errorMessage = error.result!.title + "\n- " + message;
if (error.statusCode === 400) {
return err(ServiceError.badRequest(errorMessage, errors));
}
if (error.statusCode === 403) {
return err(ServiceError.forbidden(errorMessage));
}
}
const serviceError = handleServiceError(error);
return err(serviceError);
// ProblemDetails (400/403), 401 and other SDK statuses are mapped centrally.
return err(handleServiceError(error));
} finally {
buildFileStream.close();
}
Expand Down Expand Up @@ -155,20 +142,7 @@ export class PortalService {
);
generationId = response.result.id;
} catch (error) {
if (error instanceof ProblemDetailsError) {
// TODO: This only picks the first error message, improve it to show all errors.
const errors = error.result!.errors as Record<string, string[]>;
const message = Object.values(errors)[0]?.[0] ?? null;
const errorMessage = error.result!.title + "\n- " + message;
if (error.statusCode === 400) {
return err(ServiceError.badRequest(errorMessage, errors));
}
if (error.statusCode === 403) {
return err(ServiceError.forbidden(errorMessage));
}
}
const serviceError = handleServiceError(error);
return err(serviceError);
return err(handleServiceError(error));
} finally {
buildFileStream.close();
}
Expand Down Expand Up @@ -253,20 +227,7 @@ export class PortalService {
);
generationId = response.result.id;
} catch (error) {
if (error instanceof ProblemDetailsError) {
// TODO: This only picks the first error message, improve it to show all errors.
const errors = error.result!.errors as Record<string, string[]>;
const message = Object.values(errors)[0]?.[0] ?? null;
const errorMessage = error.result!.title + '\n- ' + message;
if (error.statusCode === 400) {
return err(ServiceError.badRequest(errorMessage, errors));
}
if (error.statusCode === 403) {
return err(ServiceError.forbidden(errorMessage));
}
}
const serviceError = handleServiceError(error);
return err(serviceError);
return err(handleServiceError(error));
} finally {
buildFileStream.close();
}
Expand Down
Loading