Files
prowler/ui/lib/server-actions-helper.ts
2026-07-20 16:34:24 +02:00

283 lines
7.6 KiB
TypeScript

import * as Sentry from "@sentry/nextjs";
import { revalidatePath } from "next/cache";
import { SentryErrorSource, SentryErrorType } from "@/sentry";
import {
isErrorAlreadyReported,
isErrorCapturedBySentry,
} from "@/sentry/event-policy";
import {
GENERIC_SERVER_ERROR_MESSAGE,
getErrorMessage,
parseStringify,
sanitizeErrorMessage,
} from "./helper";
const EXPECTED_HTTP_CLIENT_STATUS_CODES = new Set([401, 403, 404]);
const CONTROLLED_CLIENT_STATUS_CODES = new Set([400, 409, 422]);
const KNOWN_NON_ACTIONABLE_CLIENT_ERROR_MESSAGES = [
"already exists",
"duplicate",
] as const;
const UNKNOWN_URL_PATH_FINGERPRINT = "unknown-url-path";
/**
* Helper function to handle API responses consistently
* Includes Sentry error tracking for debugging
*/
export const handleApiResponse = async (
response: Response,
pathToRevalidate?: string,
parse = true,
) => {
if (!response.ok) {
// Read error body safely; prefer JSON, fallback to plain text
const rawErrorText = await response.text().catch(() => "");
const contentType = response.headers.get("content-type")?.toLowerCase();
let errorData: any = null;
try {
errorData = rawErrorText ? JSON.parse(rawErrorText) : null;
} catch {
errorData = null;
}
const errorsArray = Array.isArray(errorData?.errors)
? (errorData.errors as any[])
: undefined;
const parsedErrorMessage =
errorsArray?.[0]?.detail || errorData?.error || errorData?.message;
const fallbackErrorMessage =
response.status >= 500 || contentType?.includes("text/html")
? GENERIC_SERVER_ERROR_MESSAGE
: response.statusText || "Oops! Something went wrong.";
const rawErrorMessage =
parsedErrorMessage ||
(response.status < 500 && rawErrorText.trim()) ||
fallbackErrorMessage;
const errorDetail = sanitizeErrorMessage(
String(rawErrorMessage),
fallbackErrorMessage,
);
// Capture error context for Sentry
const errorContext = {
status: response.status,
statusText: response.statusText,
url: response.url,
errorDetail,
pathToRevalidate,
};
// 5XX errors - Server errors (high priority)
if (response.status >= 500) {
const serverError = new Error(
errorDetail ||
`Server error (${response.status}): The server encountered an error. Please try again later.`,
);
Sentry.captureException(serverError, {
tags: {
api_error: true,
status_code: response.status.toString(),
error_type: SentryErrorType.SERVER_ERROR,
error_source: SentryErrorSource.HANDLE_API_RESPONSE,
},
level: "error",
contexts: {
api_response: errorContext,
},
fingerprint: [
"api-server-error",
response.status.toString(),
getSentryFingerprintUrlPath(response.url),
],
});
throw serverError;
}
if (
!shouldSuppressApiClientFailure(response.status, errorDetail, errorsArray)
) {
captureUnexpectedApiClientFailure(response, errorDetail, errorContext);
}
return errorsArray
? { error: errorDetail, errors: errorsArray, status: response.status }
: ({ error: errorDetail, status: response.status } as any);
}
// Handle empty or no-content responses gracefully (e.g., 204, empty body)
if (response.status === 204) {
if (pathToRevalidate && pathToRevalidate !== "") {
revalidatePath(pathToRevalidate);
}
return { success: true, status: response.status } as any;
}
// Read raw text to determine if there's a body to parse
const rawText = await response.text();
const hasBody = rawText && rawText.trim().length > 0;
if (!hasBody) {
if (pathToRevalidate && pathToRevalidate !== "") {
revalidatePath(pathToRevalidate);
}
return { success: true, status: response.status } as any;
}
let data: any;
try {
data = JSON.parse(rawText);
} catch (_e) {
// If body isn't valid JSON, return as text payload
data = { data: rawText };
}
if (pathToRevalidate && pathToRevalidate !== "") {
revalidatePath(pathToRevalidate);
}
return parse ? parseStringify(data) : data;
};
/**
* Helper function to handle API errors consistently
* Includes Sentry error tracking
*/
export const handleApiError = (error: unknown): { error: string } => {
console.error(error);
// Check if this error was already captured by handleApiResponse
const isAlreadyCaptured =
isErrorAlreadyReported(error) || isErrorCapturedBySentry(error);
// Only capture if not already captured by handleApiResponse.
// HTTP status-based suppression belongs in the structured Sentry event policy,
// not in string-only Error message matching.
if (!isAlreadyCaptured) {
if (error instanceof Error) {
Sentry.captureException(error, {
tags: {
error_source: SentryErrorSource.HANDLE_API_ERROR,
error_type: SentryErrorType.UNEXPECTED_ERROR,
},
level: "error",
contexts: {
error_details: {
message: error.message,
stack: error.stack,
},
},
});
} else {
// Capture non-Error objects
Sentry.captureMessage(
`Non-Error object in handleApiError: ${String(error)}`,
{
level: "warning",
tags: {
error_source: SentryErrorSource.HANDLE_API_ERROR,
error_type: SentryErrorType.NON_ERROR_OBJECT,
},
extra: {
error: error,
},
},
);
}
}
return {
error: getErrorMessage(error),
};
};
function shouldSuppressApiClientFailure(
status: number,
errorDetail: unknown,
errorsArray: unknown[] | undefined,
) {
if (status < 400 || status >= 500) {
return true;
}
if (EXPECTED_HTTP_CLIENT_STATUS_CODES.has(status)) {
return true;
}
return (
CONTROLLED_CLIENT_STATUS_CODES.has(status) &&
(hasStructuredApiErrors(errorsArray) ||
hasKnownNonActionableClientErrorMessage(errorDetail))
);
}
function captureUnexpectedApiClientFailure(
response: Response,
errorDetail: unknown,
errorContext: Record<string, unknown>,
) {
const clientError = new Error(
typeof errorDetail === "string"
? errorDetail
: `Unexpected API client failure (${response.status})`,
);
Sentry.captureException(clientError, {
tags: {
api_error: true,
status_code: response.status.toString(),
error_type: SentryErrorType.CLIENT_ERROR,
error_source: SentryErrorSource.HANDLE_API_RESPONSE,
},
level: "error",
contexts: {
api_response: errorContext,
},
fingerprint: [
"api-client-contract-error",
response.status.toString(),
getSentryFingerprintUrlPath(response.url),
],
});
}
function getSentryFingerprintUrlPath(url: string) {
if (url.trim() === "") {
return UNKNOWN_URL_PATH_FINGERPRINT;
}
try {
const pathname = new URL(url).pathname;
return (
pathname
.replace(
/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=\/|$)/gi,
"/:id",
)
.replace(/\/\d+(?=\/|$)/g, "/:id") || UNKNOWN_URL_PATH_FINGERPRINT
);
} catch {
return UNKNOWN_URL_PATH_FINGERPRINT;
}
}
function hasStructuredApiErrors(errorsArray: unknown[] | undefined) {
return Array.isArray(errorsArray) && errorsArray.length > 0;
}
function hasKnownNonActionableClientErrorMessage(errorDetail: unknown) {
if (typeof errorDetail !== "string") {
return false;
}
const normalizedErrorDetail = errorDetail.toLowerCase();
return KNOWN_NON_ACTIONABLE_CLIENT_ERROR_MESSAGES.some((message) =>
normalizedErrorDetail.includes(message),
);
}