Addresses three user-reported bugs: - API 500 diagnosability: rethrowAsApiError now preserves the original error as Error `cause` and the /api boundary logs stack + cause for 5xx, so the opaque "task write API returns 500 for every task" failures are traceable (client body stays generic in production). - In-app "Update now": detect EACCES/EPERM install failures and return actionable remediation (sudo fn update / reinstall without sudo / brew upgrade) instead of raw npm stderr; do not retry --force for this class. - Daemon restart: `fn daemon` and `fn serve` exit 128+signal (SIGTERM=143, SIGINT=130) on signal-initiated shutdown so Restart=on-failure restarts a memory-pressure kill. Interactive `fn dashboard` TUI intentionally unchanged. Adds regression tests (update-check EACCES/EPERM, daemon exit codes) and three @runfusion/fusion patch changesets. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
137 lines
4.3 KiB
TypeScript
137 lines
4.3 KiB
TypeScript
import type { NextFunction, Request, RequestHandler, Response } from "express";
|
|
import { createRuntimeLogger, type RuntimeLogger } from "./runtime-logger.js";
|
|
|
|
export interface ApiErrorResponse {
|
|
error: string;
|
|
details?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface SendErrorOptions {
|
|
details?: Record<string, unknown>;
|
|
logger?: RuntimeLogger;
|
|
/*
|
|
FNXC:ApiErrorDiagnostics 2026-07-10-14:00:
|
|
The original thrown error behind a 5xx. When present, its stack (and any `cause`
|
|
chain) is logged so server-side 500s are root-causable. Previously only the error
|
|
*message* was logged and `rethrowAsApiError` discarded the stack, leaving the
|
|
full-TaskDetail 500s on /api/tasks/:id (GET/DELETE/PATCH/retry/archive/reset)
|
|
untraceable across releases.
|
|
*/
|
|
error?: unknown;
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
public readonly statusCode: number;
|
|
public readonly details?: Record<string, unknown>;
|
|
public readonly isOperational: boolean;
|
|
|
|
constructor(statusCode: number, message: string, details?: Record<string, unknown>, cause?: unknown) {
|
|
super(message);
|
|
this.name = "ApiError";
|
|
this.statusCode = statusCode;
|
|
this.details = details;
|
|
this.isOperational = true;
|
|
// FNXC:ApiErrorDiagnostics 2026-07-10-14:00: preserve the wrapped error's
|
|
// stack/chain via Error `cause` so the boundary can log where the 500 came
|
|
// from (assigned directly rather than via super(message,{cause}) to stay
|
|
// independent of the compiled lib target).
|
|
if (cause !== undefined) {
|
|
(this as { cause?: unknown }).cause = cause;
|
|
}
|
|
}
|
|
}
|
|
|
|
export function sendErrorResponse(
|
|
res: Response,
|
|
statusCode: number,
|
|
message: string,
|
|
options?: SendErrorOptions,
|
|
): Response<ApiErrorResponse> {
|
|
if (statusCode >= 500) {
|
|
const request = res.req;
|
|
const logger = options?.logger ?? createRuntimeLogger("api:error");
|
|
// FNXC:ApiErrorDiagnostics 2026-07-10-14:00: log the underlying stack and
|
|
// cause (not just the message) so a 500 can be traced to its origin.
|
|
const originalError = options?.error;
|
|
const cause = originalError instanceof Error ? (originalError as { cause?: unknown }).cause : undefined;
|
|
logger.error("Request failed", {
|
|
method: request?.method,
|
|
path: request?.originalUrl ?? request?.path,
|
|
statusCode,
|
|
message,
|
|
stack: originalError instanceof Error ? originalError.stack : undefined,
|
|
cause: cause instanceof Error ? (cause.stack ?? cause.message) : cause !== undefined ? String(cause) : undefined,
|
|
});
|
|
}
|
|
|
|
const payload: ApiErrorResponse = { error: message };
|
|
if (options?.details !== undefined) {
|
|
payload.details = options.details;
|
|
}
|
|
|
|
return res.status(statusCode).json(payload);
|
|
}
|
|
|
|
type AsyncHandler = (req: Request, res: Response, next: NextFunction) => Promise<unknown> | unknown;
|
|
|
|
export function catchHandler(fn: AsyncHandler): RequestHandler {
|
|
return async (req, res, next) => {
|
|
try {
|
|
await fn(req, res, next);
|
|
} catch (error) {
|
|
if (res.headersSent) {
|
|
next(error);
|
|
return;
|
|
}
|
|
|
|
if (error instanceof ApiError) {
|
|
sendErrorResponse(res, error.statusCode, error.message, { details: error.details, error });
|
|
return;
|
|
}
|
|
|
|
if (error instanceof Error) {
|
|
sendErrorResponse(res, 500, error.message, { error });
|
|
return;
|
|
}
|
|
|
|
sendErrorResponse(res, 500, "Internal server error", { error });
|
|
}
|
|
};
|
|
}
|
|
|
|
export function badRequest(message: string, details?: Record<string, unknown>): ApiError {
|
|
return new ApiError(400, message, details);
|
|
}
|
|
|
|
export function unauthorized(message: string): ApiError {
|
|
return new ApiError(401, message);
|
|
}
|
|
|
|
export function notFound(message: string): ApiError {
|
|
return new ApiError(404, message);
|
|
}
|
|
|
|
export function conflict(message: string, details?: Record<string, unknown>): ApiError {
|
|
return new ApiError(409, message, details);
|
|
}
|
|
|
|
export function rateLimited(message: string, retryAfter?: number): ApiError {
|
|
return new ApiError(429, message, { retryAfter });
|
|
}
|
|
|
|
export function internalError(message: string): ApiError {
|
|
return new ApiError(500, message);
|
|
}
|
|
|
|
export function rethrowAsApiError(error: unknown, fallbackMessage = "Internal server error"): never {
|
|
if (error instanceof ApiError) {
|
|
throw error;
|
|
}
|
|
|
|
if (error instanceof Error && error.message) {
|
|
throw internalError(error.message);
|
|
}
|
|
|
|
throw internalError(fallbackMessage);
|
|
}
|