refactor: consolidate error handling to safeOperation

- Enhance safeOperation with rethrow and fallbackMessage options for CRITICAL operations
- Migrate all 19 withErrorHandling calls across 5 services to safeOperation
- Remove safeAsync from error.util.ts
- Delete error-handler.util.ts (withErrorHandling, withErrorSuppression, withErrorLogging)
- Update barrel exports in core/utils/index.ts
This commit is contained in:
barsa 2026-02-24 13:15:35 +09:00
parent a94e59fe06
commit 9736e96cb3
9 changed files with 104 additions and 187 deletions

View File

@ -1,122 +0,0 @@
/**
* Centralized error handling utilities for consistent error handling across services
*/
import {
NotFoundException,
BadRequestException,
InternalServerErrorException,
type Type,
} from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { extractErrorMessage } from "./error.util.js";
/**
* Types of exceptions that can be rethrown without wrapping
*/
type RethrowableException = Type<Error>;
/**
* Options for error handling
*/
interface ErrorHandlerOptions {
/**
* Context string for logging (e.g., "Get invoices for user xyz")
*/
context: string;
/**
* Exception types that should be rethrown as-is without wrapping
* @default [NotFoundException, BadRequestException]
*/
rethrow?: RethrowableException[];
/**
* Custom fallback message for InternalServerErrorException
* @default `${context} failed`
*/
fallbackMessage?: string;
}
/**
* Wraps an async operation with consistent error handling.
*
* - Logs errors with context
* - Rethrows specified exception types as-is
* - Wraps other errors in InternalServerErrorException
*
* @example
* return withErrorHandling(
* () => this.whmcsService.getInvoices(clientId, userId),
* this.logger,
* { context: `Get invoices for user ${userId}`, rethrow: [NotFoundException] }
* );
*/
export async function withErrorHandling<T>(
operation: () => Promise<T>,
logger: Logger,
options: ErrorHandlerOptions
): Promise<T> {
const { context, rethrow = [NotFoundException, BadRequestException], fallbackMessage } = options;
try {
return await operation();
} catch (error) {
logger.error(context, { error: extractErrorMessage(error) });
for (const ExceptionType of rethrow) {
if (error instanceof ExceptionType) {
throw error;
}
}
throw new InternalServerErrorException(fallbackMessage ?? `${context} failed`);
}
}
/**
* Safe wrapper that returns null instead of throwing.
* Useful for optional operations where failure is acceptable.
*
* @example
* const cached = await withErrorSuppression(
* () => this.cache.get(key),
* this.logger,
* "Get cached value"
* );
*/
export async function withErrorSuppression<T>(
operation: () => Promise<T>,
logger: Logger,
context: string
): Promise<T | null> {
try {
return await operation();
} catch (error) {
logger.warn(`${context} (suppressed)`, { error: extractErrorMessage(error) });
return null;
}
}
/**
* Wraps an async operation and logs errors, but always rethrows them.
* Useful when you want consistent logging but don't want to change the error type.
*
* @example
* return withErrorLogging(
* () => this.externalService.call(),
* this.logger,
* "Call external service"
* );
*/
export async function withErrorLogging<T>(
operation: () => Promise<T>,
logger: Logger,
context: string
): Promise<T> {
try {
return await operation();
} catch (error) {
logger.error(context, { error: extractErrorMessage(error) });
throw error;
}
}

View File

@ -185,19 +185,3 @@ export function createDeferredPromise<T>(): {
return { promise, resolve, reject };
}
/**
* Safe async operation wrapper with proper error handling
*/
export async function safeAsync<T>(
operation: () => Promise<T>,
fallback?: T
): Promise<{ data: T | null; error: EnhancedError | null }> {
try {
const data = await operation();
return { data, error: null };
} catch (unknownError) {
const error = toError(unknownError);
return { data: fallback ?? null, error };
}
}

View File

@ -9,7 +9,7 @@ export { chunkArray, groupBy, uniqueBy } from "./array.util.js";
// Error utilities
export { extractErrorMessage } from "./error.util.js";
export { withErrorHandling, withErrorSuppression, withErrorLogging } from "./error-handler.util.js";
export { safeOperation, OperationCriticality, type SafeOperationOptions, type SafeOperationResult } from "./safe-operation.util.js";
// Retry utilities
export {

View File

@ -1,3 +1,4 @@
import { InternalServerErrorException } from "@nestjs/common";
import type { Logger } from "nestjs-pino";
import { extractErrorMessage } from "./error.util.js";
@ -36,6 +37,12 @@ export interface SafeOperationOptions<T> {
/** Additional metadata for logging */
metadata?: Record<string, unknown>;
/** Exception types to rethrow as-is (only applicable when criticality is CRITICAL) */
rethrow?: Array<new (...args: any[]) => Error>;
/** Custom message for InternalServerErrorException wrapper (only applicable when criticality is CRITICAL) */
fallbackMessage?: string;
}
/**
@ -82,7 +89,7 @@ export async function safeOperation<T>(
executor: () => Promise<T>,
options: SafeOperationOptions<T>
): Promise<T> {
const { criticality, fallback, context, logger, metadata = {} } = options;
const { criticality, fallback, context, logger, metadata = {}, rethrow, fallbackMessage } = options;
try {
return await executor();
@ -98,6 +105,14 @@ export async function safeOperation<T>(
switch (criticality) {
case OperationCriticality.CRITICAL:
logger.error(logPayload, `Critical operation failed: ${context}`);
if (rethrow) {
for (const ExceptionType of rethrow) {
if (error instanceof ExceptionType) {
throw error;
}
}
throw new InternalServerErrorException(fallbackMessage ?? `${context} failed`);
}
throw error;
case OperationCriticality.DEGRADABLE:

View File

@ -22,7 +22,7 @@ import {
changePasswordRequestSchema,
} from "@customer-portal/domain/auth";
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
import { withErrorHandling } from "@bff/core/utils/error-handler.util.js";
import { safeOperation, OperationCriticality } from "@bff/core/utils/safe-operation.util.js";
import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js";
@Injectable()
@ -64,7 +64,7 @@ export class PasswordWorkflowService {
const passwordHash = await argon2.hash(password);
return withErrorHandling(
return safeOperation(
async () => {
try {
await this.usersService.update(user.id, { passwordHash });
@ -102,9 +102,11 @@ export class PasswordWorkflowService {
tokens,
};
},
this.logger,
{
criticality: OperationCriticality.CRITICAL,
context: `Set password for user ${user.id}`,
logger: this.logger,
rethrow: [NotFoundException, BadRequestException],
fallbackMessage: "Failed to set password",
}
);
@ -151,7 +153,7 @@ export class PasswordWorkflowService {
// This will throw BadRequestException if token is invalid, expired, or already used
const { userId } = await this.passwordResetTokenService.consume(token);
return withErrorHandling(
return safeOperation(
async () => {
const prismaUser = await this.usersService.findByIdInternal(userId);
if (!prismaUser) throw new BadRequestException("Invalid token");
@ -168,11 +170,12 @@ export class PasswordWorkflowService {
// Revoke all trusted devices - attacker could have set up a trusted device
await this.trustedDeviceService.revokeAllUserDevices(freshUser.id);
},
this.logger,
{
criticality: OperationCriticality.CRITICAL,
context: "Reset password",
fallbackMessage: "Failed to reset password",
logger: this.logger,
rethrow: [BadRequestException],
fallbackMessage: "Failed to reset password",
}
);
}
@ -213,7 +216,7 @@ export class PasswordWorkflowService {
const passwordHash = await argon2.hash(newPassword);
return withErrorHandling(
return safeOperation(
async () => {
await this.usersService.update(user.id, { passwordHash });
const prismaUser = await this.usersService.findByIdInternal(user.id);
@ -244,9 +247,11 @@ export class PasswordWorkflowService {
tokens,
};
},
this.logger,
{
criticality: OperationCriticality.CRITICAL,
context: `Change password for user ${user.id}`,
logger: this.logger,
rethrow: [NotFoundException, BadRequestException],
fallbackMessage: "Failed to change password",
}
);

View File

@ -14,7 +14,7 @@ import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforc
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
import { getCustomFieldValue } from "@customer-portal/domain/customer/providers";
import { withErrorHandling } from "@bff/core/utils/error-handler.util.js";
import { safeOperation, OperationCriticality } from "@bff/core/utils/safe-operation.util.js";
import type { User } from "@customer-portal/domain/customer";
import {
PORTAL_SOURCE_MIGRATED,
@ -50,7 +50,7 @@ export class WhmcsLinkWorkflowService {
);
}
return withErrorHandling(
return safeOperation(
async () => {
const clientDetails = await this.discoveryService.findClientByEmail(email);
if (!clientDetails) {
@ -133,11 +133,12 @@ export class WhmcsLinkWorkflowService {
needsPasswordSet: true,
};
},
this.logger,
{
criticality: OperationCriticality.CRITICAL,
context: "WHMCS account linking",
fallbackMessage: "Failed to link WHMCS account",
logger: this.logger,
rethrow: [BadRequestException, ConflictException, UnauthorizedException],
fallbackMessage: "Failed to link WHMCS account",
}
);
}

View File

@ -1,4 +1,4 @@
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Injectable, Inject, BadRequestException, NotFoundException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import type {
Invoice,
@ -9,7 +9,7 @@ import type {
import { INVOICE_PAGINATION, VALID_INVOICE_QUERY_STATUSES } from "@customer-portal/domain/billing";
import { WhmcsInvoiceService } from "@bff/integrations/whmcs/services/whmcs-invoice.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { withErrorHandling } from "@bff/core/utils/error-handler.util.js";
import { safeOperation, OperationCriticality } from "@bff/core/utils/safe-operation.util.js";
import { parseUuidOrThrow } from "@bff/core/utils/validation.util.js";
/**
@ -40,7 +40,7 @@ export class InvoiceRetrievalService {
parseUuidOrThrow(userId, "Invalid user ID format");
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
return withErrorHandling(
return safeOperation(
async () => {
const invoiceList = await this.whmcsInvoiceService.getInvoices(whmcsClientId, userId, {
page,
@ -57,8 +57,13 @@ export class InvoiceRetrievalService {
return invoiceList;
},
this.logger,
{ context: `Get invoices for user ${userId}`, fallbackMessage: "Failed to retrieve invoices" }
{
criticality: OperationCriticality.CRITICAL,
context: `Get invoices for user ${userId}`,
logger: this.logger,
rethrow: [NotFoundException, BadRequestException],
fallbackMessage: "Failed to retrieve invoices",
}
);
}
@ -69,7 +74,7 @@ export class InvoiceRetrievalService {
parseUuidOrThrow(userId, "Invalid user ID format");
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
return withErrorHandling(
return safeOperation(
async () => {
const invoice = await this.whmcsInvoiceService.getInvoiceById(
whmcsClientId,
@ -79,9 +84,11 @@ export class InvoiceRetrievalService {
this.logger.log(`Retrieved invoice ${invoiceId} for user ${userId}`);
return invoice;
},
this.logger,
{
criticality: OperationCriticality.CRITICAL,
context: `Get invoice ${invoiceId} for user ${userId}`,
logger: this.logger,
rethrow: [NotFoundException, BadRequestException],
fallbackMessage: "Failed to retrieve invoice",
}
);
@ -108,11 +115,13 @@ export class InvoiceRetrievalService {
);
}
return withErrorHandling(
return safeOperation(
async () => this.getInvoices(userId, { page, limit, status }),
this.logger,
{
criticality: OperationCriticality.CRITICAL,
context: `Get ${status} invoices for user ${userId}`,
logger: this.logger,
rethrow: [NotFoundException, BadRequestException],
fallbackMessage: "Failed to retrieve invoices",
}
);

View File

@ -1,4 +1,4 @@
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Injectable, Inject, BadRequestException, NotFoundException } from "@nestjs/common";
import {
subscriptionListSchema,
subscriptionStatusSchema,
@ -17,7 +17,7 @@ import { WhmcsInvoiceService } from "@bff/integrations/whmcs/services/whmcs-invo
import { WhmcsSubscriptionService } from "@bff/integrations/whmcs/services/whmcs-subscription.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { Logger } from "nestjs-pino";
import { withErrorHandling } from "@bff/core/utils/error-handler.util.js";
import { safeOperation, OperationCriticality } from "@bff/core/utils/safe-operation.util.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
export interface GetSubscriptionsOptions {
@ -53,7 +53,7 @@ export class SubscriptionsOrchestrator {
const { status } = options;
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
return withErrorHandling(
return safeOperation(
async () => {
const subscriptionList = await this.whmcsSubscriptionService.getSubscriptions(
whmcsClientId,
@ -74,9 +74,11 @@ export class SubscriptionsOrchestrator {
totalCount: subscriptions.length,
});
},
this.logger,
{
criticality: OperationCriticality.CRITICAL,
context: `Get subscriptions for user ${userId}`,
logger: this.logger,
rethrow: [NotFoundException, BadRequestException],
fallbackMessage: "Failed to retrieve subscriptions",
}
);
@ -94,7 +96,7 @@ export class SubscriptionsOrchestrator {
// Get WHMCS client ID from user mapping
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
return withErrorHandling(
return safeOperation(
async () => {
const subscription = await this.whmcsSubscriptionService.getSubscriptionById(
whmcsClientId,
@ -111,9 +113,11 @@ export class SubscriptionsOrchestrator {
return subscription;
},
this.logger,
{
criticality: OperationCriticality.CRITICAL,
context: `Get subscription ${subscriptionId} for user ${userId}`,
logger: this.logger,
rethrow: [NotFoundException, BadRequestException],
fallbackMessage: "Failed to retrieve subscription",
}
);
@ -164,7 +168,7 @@ export class SubscriptionsOrchestrator {
completed: number;
cancelled: number;
}> {
return withErrorHandling(
return safeOperation(
async () => {
const subscriptionList = await this.getSubscriptions(userId);
const subscriptions: Subscription[] = subscriptionList.subscriptions;
@ -180,9 +184,11 @@ export class SubscriptionsOrchestrator {
return subscriptionStatsSchema.parse(stats);
},
this.logger,
{
criticality: OperationCriticality.CRITICAL,
context: `Generate subscription stats for user ${userId}`,
logger: this.logger,
rethrow: [NotFoundException, BadRequestException],
}
);
}
@ -191,7 +197,7 @@ export class SubscriptionsOrchestrator {
* Get subscriptions expiring soon (within next 30 days)
*/
async getExpiringSoon(userId: string, days: number = 30): Promise<Subscription[]> {
return withErrorHandling(
return safeOperation(
async () => {
const subscriptionList = await this.getSubscriptions(userId);
const subscriptions: Subscription[] = subscriptionList.subscriptions;
@ -213,9 +219,11 @@ export class SubscriptionsOrchestrator {
);
return expiringSoon;
},
this.logger,
{
criticality: OperationCriticality.CRITICAL,
context: `Get expiring subscriptions for user ${userId}`,
logger: this.logger,
rethrow: [NotFoundException, BadRequestException],
}
);
}
@ -224,7 +232,7 @@ export class SubscriptionsOrchestrator {
* Get recent subscription activity (newly created or status changed)
*/
async getRecentActivity(userId: string, days: number = 30): Promise<Subscription[]> {
return withErrorHandling(
return safeOperation(
async () => {
const subscriptionList = await this.getSubscriptions(userId);
const subscriptions = subscriptionList.subscriptions;
@ -242,9 +250,11 @@ export class SubscriptionsOrchestrator {
);
return recentActivity;
},
this.logger,
{
criticality: OperationCriticality.CRITICAL,
context: `Get recent subscription activity for user ${userId}`,
logger: this.logger,
rethrow: [NotFoundException, BadRequestException],
}
);
}
@ -257,7 +267,7 @@ export class SubscriptionsOrchestrator {
throw new BadRequestException("Search query must be at least 2 characters long");
}
return withErrorHandling(
return safeOperation(
async () => {
const subscriptionList = await this.getSubscriptions(userId);
const subscriptions = subscriptionList.subscriptions;
@ -275,9 +285,11 @@ export class SubscriptionsOrchestrator {
);
return matches;
},
this.logger,
{
criticality: OperationCriticality.CRITICAL,
context: `Search subscriptions for user ${userId}`,
logger: this.logger,
rethrow: [NotFoundException, BadRequestException],
}
);
}
@ -292,7 +304,7 @@ export class SubscriptionsOrchestrator {
): Promise<InvoiceList> {
const { page = 1, limit = 10 } = options;
return withErrorHandling(
return safeOperation(
async () => {
const cachedResult = await this.tryGetCachedInvoices(userId, subscriptionId, page, limit);
if (cachedResult) return cachedResult;
@ -326,9 +338,11 @@ export class SubscriptionsOrchestrator {
return result;
},
this.logger,
{
criticality: OperationCriticality.CRITICAL,
context: `Get invoices for subscription ${subscriptionId}`,
logger: this.logger,
rethrow: [NotFoundException, BadRequestException],
fallbackMessage: "Failed to retrieve subscription invoices",
}
);

View File

@ -38,7 +38,7 @@ import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-clien
import { WhmcsInvoiceService } from "@bff/integrations/whmcs/services/whmcs-invoice.service.js";
import { WhmcsSubscriptionService } from "@bff/integrations/whmcs/services/whmcs-subscription.service.js";
import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js";
import { withErrorHandling } from "@bff/core/utils/error-handler.util.js";
import { safeOperation, OperationCriticality } from "@bff/core/utils/safe-operation.util.js";
import { parseUuidOrThrow } from "@bff/core/utils/validation.util.js";
import { UserAuthRepository } from "./user-auth.repository.js";
import { AddressReconcileQueueService } from "../queue/address-reconcile.queue.js";
@ -279,7 +279,7 @@ export class UserProfileAggregator {
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(validId);
return withErrorHandling(
return safeOperation(
async () => {
await this.whmcsClientService.updateClientAddress(whmcsClientId, parsed);
await this.whmcsClientService.invalidateUserCache(validId);
@ -297,9 +297,11 @@ export class UserProfileAggregator {
const refreshedAddress = await this.whmcsClientService.getClientAddress(whmcsClientId);
return addressSchema.parse(refreshedAddress ?? {});
},
this.logger,
{
criticality: OperationCriticality.CRITICAL,
context: `Update address for user ${validId}`,
logger: this.logger,
rethrow: [NotFoundException, BadRequestException],
fallbackMessage: "Unable to update address",
}
);
@ -320,7 +322,7 @@ export class UserProfileAggregator {
): Promise<Address> {
const validId = parseUuidOrThrow(userId, ERROR_INVALID_USER_ID);
return withErrorHandling(
return safeOperation(
async () => {
const mapping = await this.mappingsService.findByUserId(validId);
if (!mapping?.whmcsClientId) {
@ -353,9 +355,11 @@ export class UserProfileAggregator {
return this.fetchRefreshedAddress(validId, mapping.whmcsClientId);
},
this.logger,
{
criticality: OperationCriticality.CRITICAL,
context: `Update bilingual address for user ${validId}`,
logger: this.logger,
rethrow: [NotFoundException, BadRequestException],
fallbackMessage: "Unable to update address",
}
);
@ -438,7 +442,7 @@ export class UserProfileAggregator {
const validId = parseUuidOrThrow(userId, ERROR_INVALID_USER_ID);
const parsed = updateCustomerProfileRequestSchema.parse(update);
return withErrorHandling(
return safeOperation(
async () => {
// Explicitly disallow name changes from portal
if (parsed.firstname !== undefined || parsed.lastname !== undefined) {
@ -482,17 +486,22 @@ export class UserProfileAggregator {
return this.getProfile(validId);
},
this.logger,
{
criticality: OperationCriticality.CRITICAL,
context: `Update profile for user ${validId}`,
logger: this.logger,
rethrow: [NotFoundException, BadRequestException],
fallbackMessage: "Unable to update profile",
}
);
}
async getUserSummary(userId: string): Promise<DashboardSummary> {
return withErrorHandling(async () => this.buildUserSummary(userId), this.logger, {
return safeOperation(async () => this.buildUserSummary(userId), {
criticality: OperationCriticality.CRITICAL,
context: `Get user summary for ${userId}`,
logger: this.logger,
rethrow: [NotFoundException, BadRequestException],
fallbackMessage: "Unable to retrieve dashboard summary",
});
}
@ -641,7 +650,7 @@ export class UserProfileAggregator {
private async getProfileForUser(user: PrismaUser): Promise<User> {
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(user.id);
return withErrorHandling(
return safeOperation(
async () => {
const whmcsClient = await this.whmcsClientService.getClientDetails(whmcsClientId);
const userAuth = mapPrismaUserToUserAuth(user);
@ -676,9 +685,11 @@ export class UserProfileAggregator {
gender,
};
},
this.logger,
{
criticality: OperationCriticality.CRITICAL,
context: `Fetch client profile from WHMCS for user ${user.id}`,
logger: this.logger,
rethrow: [NotFoundException, BadRequestException],
fallbackMessage: "Unable to retrieve customer profile from billing system",
}
);