From adaa8aebd2a10bea57eec2e62a4409180be23ca4 Mon Sep 17 00:00:00 2001 From: MuhammadRafay1 Date: Tue, 14 Jul 2026 16:46:51 +0500 Subject: [PATCH 1/5] feat: surface API 401 error message with login suggestion in portal generation Previously, running portal commands while logged out showed the generic "An unexpected error occurred" message because the SDK's typed 401 error (UnauthorizedResponseError) was not recognized by the axios-only handleServiceError. Handle ApiError with status 401 in generatePortal, extract the API's message, and pair it with a suggestion to run `apimatic auth login` or pass `--auth-key`. Co-Authored-By: Claude Fable 5 --- src/infrastructure/service-error.ts | 8 ++++++++ src/infrastructure/services/portal-service.ts | 10 ++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/infrastructure/service-error.ts b/src/infrastructure/service-error.ts index 0dca88ae..d2fca9f0 100644 --- a/src/infrastructure/service-error.ts +++ b/src/infrastructure/service-error.ts @@ -30,6 +30,14 @@ export class ServiceError { static notFound(customMessage: string): ServiceError { return new ServiceError(ServiceErrorCode.NotFound, customMessage, {}); } + static unauthorized(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, diff --git a/src/infrastructure/services/portal-service.ts b/src/infrastructure/services/portal-service.ts index e585d77e..6cb0a656 100644 --- a/src/infrastructure/services/portal-service.ts +++ b/src/infrastructure/services/portal-service.ts @@ -74,10 +74,16 @@ export class PortalService { if (error.statusCode === 400) { return err(ServiceError.badRequest(errorMessage, errors)); } - if (error.statusCode === 403) { - return err(ServiceError.forbidden(errorMessage)); + if (error.statusCode === 401) { + return err(ServiceError.UnAuthorized); } } + if (error instanceof ApiError && error.statusCode === 401) { + // The API reports the reason as {"message": "..."} which the SDK + // deserializes into `result` for its typed 401 error. + const apiMessage = (error.result as { message?: string } | undefined)?.message ?? null; + return err(ServiceError.unauthorized(apiMessage)); + } const serviceError = handleServiceError(error); return err(serviceError); } finally { From d5f9fe05b63a5d84856e375acb850ff50ffa71a5 Mon Sep 17 00:00:00 2001 From: Sohail Date: Wed, 15 Jul 2026 17:57:33 +0500 Subject: [PATCH 2/5] refactor: centralize SDK ApiError-to-ServiceError mapping in handleServiceError Move the 401 message extraction out of generatePortal's catch and into handleServiceError, which now recognizes SDK ApiError statuses (401/404/500) alongside axios errors. This surfaces the API's 401 message + login hint for every SDK-based flow, not just portal generation, and removes the unreachable 401 branch inside the ProblemDetailsError block. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/infrastructure/service-error.ts | 10 ++++++++++ src/infrastructure/services/portal-service.ts | 11 +++-------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/infrastructure/service-error.ts b/src/infrastructure/service-error.ts index d2fca9f0..4a95d84e 100644 --- a/src/infrastructure/service-error.ts +++ b/src/infrastructure/service-error.ts @@ -1,4 +1,5 @@ import axios from "axios"; +import { ApiError } from "@apimatic/sdk"; import { format as f } from "../prompts/format.js"; export enum ServiceErrorCode { @@ -63,6 +64,15 @@ export class ServiceError { } export function handleServiceError(error: unknown): ServiceError { + // SDK controllers throw typed `ApiError`s (not axios errors). The API reports + // the reason as {"message": "..."} deserialized into `result`. + if (error instanceof ApiError) { + const apiMessage = (error.result as { message?: string } | undefined)?.message ?? null; + if (error.statusCode === 401) return ServiceError.unauthorized(apiMessage); + if (error.statusCode === 404) return ServiceError.NotFound; + if (error.statusCode === 500) return ServiceError.ServerError; + } + if (axios.isAxiosError(error)) { const status = error.response?.status; if (status === 401) return ServiceError.UnAuthorized; diff --git a/src/infrastructure/services/portal-service.ts b/src/infrastructure/services/portal-service.ts index 6cb0a656..75257ffb 100644 --- a/src/infrastructure/services/portal-service.ts +++ b/src/infrastructure/services/portal-service.ts @@ -74,16 +74,11 @@ export class PortalService { if (error.statusCode === 400) { return err(ServiceError.badRequest(errorMessage, errors)); } - if (error.statusCode === 401) { - return err(ServiceError.UnAuthorized); + if (error.statusCode === 403) { + return err(ServiceError.forbidden(errorMessage)); } } - if (error instanceof ApiError && error.statusCode === 401) { - // The API reports the reason as {"message": "..."} which the SDK - // deserializes into `result` for its typed 401 error. - const apiMessage = (error.result as { message?: string } | undefined)?.message ?? null; - return err(ServiceError.unauthorized(apiMessage)); - } + // 401 (and other SDK ApiError statuses) are mapped centrally in handleServiceError. const serviceError = handleServiceError(error); return err(serviceError); } finally { From 82746fd80680c4b420b166a6adb2642a20d2fac5 Mon Sep 17 00:00:00 2001 From: Sohail Date: Mon, 20 Jul 2026 09:45:03 +0500 Subject: [PATCH 3/5] refactor: extract mapApiError to reduce handleServiceError complexity SonarCloud flagged handleServiceError at cognitive complexity 16 (limit 15). Move the SDK ApiError status mapping into its own mapApiError function, pulling the nested conditionals out of handleServiceError. Behavior is unchanged: 401 -> unauthorized, 404 -> NotFound, other statuses -> ServerError. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/infrastructure/service-error.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/infrastructure/service-error.ts b/src/infrastructure/service-error.ts index 4a95d84e..93dfffa0 100644 --- a/src/infrastructure/service-error.ts +++ b/src/infrastructure/service-error.ts @@ -63,15 +63,19 @@ export class ServiceError { } } -export function handleServiceError(error: unknown): ServiceError { - // SDK controllers throw typed `ApiError`s (not axios errors). The API reports - // the reason as {"message": "..."} deserialized into `result`. - if (error instanceof ApiError) { +// 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.statusCode === 401) { const apiMessage = (error.result as { message?: string } | undefined)?.message ?? null; - if (error.statusCode === 401) return ServiceError.unauthorized(apiMessage); - if (error.statusCode === 404) return ServiceError.NotFound; - if (error.statusCode === 500) return ServiceError.ServerError; + return ServiceError.unauthorized(apiMessage); } + if (error.statusCode === 404) return ServiceError.NotFound; + return ServiceError.ServerError; +} + +export function handleServiceError(error: unknown): ServiceError { + if (error instanceof ApiError) return mapApiError(error); if (axios.isAxiosError(error)) { const status = error.response?.status; From 948e8aaf02ed5c9ec5530a3c906be09607c3c92a Mon Sep 17 00:00:00 2001 From: Sohail Date: Mon, 20 Jul 2026 09:50:17 +0500 Subject: [PATCH 4/5] refactor: centralize ProblemDetails mapping to remove duplicated catch blocks The ProblemDetailsError 400/403 handling was triplicated across generatePortal, generateSdk and generateV4Sdk, which SonarQube flagged as duplicated new code. Move it into mapProblemDetailsError (reached via handleServiceError, since ProblemDetailsError extends ApiError) and collapse all three generation catch blocks to a single handleServiceError call. Behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/infrastructure/service-error.ts | 18 ++++++- src/infrastructure/services/portal-service.ts | 48 ++----------------- 2 files changed, 21 insertions(+), 45 deletions(-) diff --git a/src/infrastructure/service-error.ts b/src/infrastructure/service-error.ts index 93dfffa0..c9ecea7f 100644 --- a/src/infrastructure/service-error.ts +++ b/src/infrastructure/service-error.ts @@ -1,5 +1,5 @@ import axios from "axios"; -import { ApiError } from "@apimatic/sdk"; +import { ApiError, ProblemDetailsError } from "@apimatic/sdk"; import { format as f } from "../prompts/format.js"; export enum ServiceErrorCode { @@ -63,9 +63,25 @@ export class ServiceError { } } +// 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 { + // TODO: This only picks the first error message, improve it to show all errors. + const errors = error.result!.errors as Record; + const message = Object.values(errors)[0]?.[0] ?? null; + const errorMessage = error.result!.title + "\n- " + message; + 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.unauthorized(apiMessage); diff --git a/src/infrastructure/services/portal-service.ts b/src/infrastructure/services/portal-service.ts index 75257ffb..b4bbc1ee 100644 --- a/src/infrastructure/services/portal-service.ts +++ b/src/infrastructure/services/portal-service.ts @@ -6,7 +6,6 @@ import { ContentType, DocsPortalGenerationAsyncController, SdkSourceTreeGenerationAsyncController, - ProblemDetailsError, FileWrapper, TransformationController, Transformation, @@ -66,21 +65,8 @@ export class PortalService { ); generationId = portalInstance.result.id; } catch (error) { - if (error instanceof ProblemDetailsError) { - const errors = error.result!.errors as Record; - // 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)); - } - } - // 401 (and other SDK ApiError statuses) are mapped centrally in handleServiceError. - 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(); } @@ -156,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; - 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(); } @@ -254,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; - 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(); } From 996f8f58fb3b8422fc75e59717fe8c6280ab6174 Mon Sep 17 00:00:00 2001 From: MuhammadRafay1 Date: Thu, 23 Jul 2026 15:22:06 +0500 Subject: [PATCH 5/5] refactor: disambiguate unauthorized factory and harden ProblemDetails mapping Rename ServiceError.unauthorized -> unauthorizedWithHint so the enhanced-message factory no longer collides by casing with the generic UnAuthorized singleton. Guard mapProblemDetailsError against an absent ProblemDetails result/errors/title: default errors to {}, only append the field message when present (no trailing "- null"), and fall back to a "Request failed." title. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/infrastructure/service-error.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/infrastructure/service-error.ts b/src/infrastructure/service-error.ts index c9ecea7f..32049fd8 100644 --- a/src/infrastructure/service-error.ts +++ b/src/infrastructure/service-error.ts @@ -31,7 +31,7 @@ export class ServiceError { static notFound(customMessage: string): ServiceError { return new ServiceError(ServiceErrorCode.NotFound, customMessage, {}); } - static unauthorized(apiMessage: string | null): ServiceError { + static unauthorizedWithHint(apiMessage: string | null): ServiceError { const message = `${apiMessage ?? "You are not authorized to perform this action."} Please run ${f.cmdAlt( "apimatic", "auth", @@ -66,10 +66,13 @@ export class ServiceError { // 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. - const errors = error.result!.errors as Record; - const message = Object.values(errors)[0]?.[0] ?? null; - const errorMessage = error.result!.title + "\n- " + message; + const errors = (error.result?.errors ?? {}) as Record; + 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; @@ -84,7 +87,7 @@ function mapApiError(error: ApiError): ServiceError { } if (error.statusCode === 401) { const apiMessage = (error.result as { message?: string } | undefined)?.message ?? null; - return ServiceError.unauthorized(apiMessage); + return ServiceError.unauthorizedWithHint(apiMessage); } if (error.statusCode === 404) return ServiceError.NotFound; return ServiceError.ServerError;