added circuit breaker

This commit is contained in:
Dmitri 2026-07-03 15:59:42 +02:00
parent 57a705f3ba
commit 9e6e0e0dbe
18 changed files with 747 additions and 177 deletions

View File

@ -5,14 +5,9 @@ import './load-env.ts';
import { validateEnv } from './utils/env-validation.ts';
import type { BackendConfig } from './types/index.ts';
validateEnv();
const env = validateEnv();
function envNumber(name: string, fallback: number): number {
const parsed = Number.parseInt(process.env[name] || '', 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
const isProduction = process.env.NODE_ENV === 'production';
const isProduction = env.NODE_ENV === 'production';
const remote = '';
const port = isProduction ? '' : '8080';
const hostUI = isProduction ? '' : 'http://localhost';
@ -21,36 +16,77 @@ const swaggerUI = isProduction ? '' : 'http://localhost';
const swaggerPort = isProduction ? '' : ':8080';
const host = isProduction ? remote : 'http://localhost';
const getBaseUrl = (url: string): string => {
if (!url) return '';
return url.endsWith('/api') ? url.slice(0, -4) : url;
};
const getServerPort = (): number => {
if (process.env.PORT !== undefined) {
return env.PORT;
}
return env.NODE_ENV === 'dev_stage' ? 3000 : 8080;
};
const serverPort = getServerPort();
const swaggerServerUrl =
getBaseUrl(env.NEXT_PUBLIC_BACK_API) || `${swaggerUI}${swaggerPort}`;
const config: BackendConfig = {
gcloud: {
bucket: 'fldemo-files',
hash: 'afeefb9d49f5b7977577876b99532ac7',
projectId: env.GC_PROJECT_ID,
clientEmail: env.GC_CLIENT_EMAIL,
privateKey: env.GC_PRIVATE_KEY,
},
fileStorage: {
provider: env.FILE_STORAGE_PROVIDER,
},
s3: {
bucket: process.env.AWS_S3_BUCKET || '',
region: process.env.AWS_S3_REGION || 'us-east-1',
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '',
prefix: process.env.AWS_S3_PREFIX || 'afeefb9d49f5b7977577876b99532ac7',
connectionTimeout: envNumber('AWS_S3_CONNECTION_TIMEOUT', 5000),
requestTimeout: envNumber('AWS_S3_REQUEST_TIMEOUT', 30000),
maxAttempts: envNumber('AWS_S3_MAX_ATTEMPTS', 3),
maxSockets: envNumber('AWS_S3_MAX_SOCKETS', 50),
keepAlive: process.env.AWS_S3_KEEP_ALIVE !== 'false',
presignExpirySeconds: envNumber('AWS_S3_PRESIGN_EXPIRY', 3600),
bucket: env.AWS_S3_BUCKET,
region: env.AWS_S3_REGION,
accessKeyId: env.AWS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
prefix: env.AWS_S3_PREFIX,
connectionTimeout: env.AWS_S3_CONNECTION_TIMEOUT,
requestTimeout: env.AWS_S3_REQUEST_TIMEOUT,
maxAttempts: env.AWS_S3_MAX_ATTEMPTS,
maxSockets: env.AWS_S3_MAX_SOCKETS,
keepAlive: env.AWS_S3_KEEP_ALIVE !== 'false',
presignExpirySeconds: env.AWS_S3_PRESIGN_EXPIRY,
},
resilience: {
ffmpeg: {
reverseTimeoutMs: env.FFMPEG_REVERSE_TIMEOUT_MS,
ffprobeTimeoutMs: env.FFPROBE_TIMEOUT_MS,
breaker: {
failureThreshold: env.FFMPEG_BREAKER_FAILURE_THRESHOLD,
cooldownMs: env.FFMPEG_BREAKER_COOLDOWN_MS,
successThreshold: env.FFMPEG_BREAKER_SUCCESS_THRESHOLD,
},
},
fileStorage: {
breaker: {
failureThreshold: env.FILE_STORAGE_BREAKER_FAILURE_THRESHOLD,
cooldownMs: env.FILE_STORAGE_BREAKER_COOLDOWN_MS,
successThreshold: env.FILE_STORAGE_BREAKER_SUCCESS_THRESHOLD,
},
},
},
bcrypt: {
saltRounds: 12,
},
admin_pass: process.env.ADMIN_PASS || '88dbeaf8',
user_pass: process.env.USER_PASS || 'c3baadeda5c6',
admin_email: process.env.ADMIN_EMAIL || 'admin@flatlogic.com',
admin_pass: env.ADMIN_PASS,
user_pass: env.USER_PASS,
admin_email: env.ADMIN_EMAIL,
providers: {
LOCAL: 'local',
GOOGLE: 'google',
MICROSOFT: 'microsoft',
},
secret_key: process.env.SECRET_KEY || '88dbeaf8-e906-405e-9e41-c3baadeda5c6',
secret_key: env.SECRET_KEY,
remote,
port,
host,
@ -60,33 +96,38 @@ const config: BackendConfig = {
swaggerUI,
swaggerPort,
google: {
clientId: process.env.GOOGLE_CLIENT_ID || '',
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
},
microsoft: {
clientId: process.env.MS_CLIENT_ID || '',
clientSecret: process.env.MS_CLIENT_SECRET || '',
clientId: env.MS_CLIENT_ID,
clientSecret: env.MS_CLIENT_SECRET,
},
uploadDir: os.tmpdir(),
s3CacheDir: process.env.S3_CACHE_DIR || path.join(os.tmpdir(), 's3-cache'),
s3CacheEnabled: process.env.S3_CACHE_ENABLED !== 'false',
s3CacheMaxAge: envNumber('S3_CACHE_MAX_AGE', 86400),
s3CacheDir: env.S3_CACHE_DIR || path.join(os.tmpdir(), 's3-cache'),
s3CacheEnabled: env.S3_CACHE_ENABLED !== 'false',
s3CacheMaxAge: env.S3_CACHE_MAX_AGE,
email: {
from: 'Tour Builder Platform <app@flatlogic.app>',
host: 'email-smtp.us-east-1.amazonaws.com',
port: 587,
auth: {
user: process.env.EMAIL_USER || '',
pass: process.env.EMAIL_PASS || '',
user: env.EMAIL_USER,
pass: env.EMAIL_PASS,
},
tls: {
rejectUnauthorized: process.env.EMAIL_TLS_REJECT_UNAUTHORIZED !== 'false',
rejectUnauthorized: env.EMAIL_TLS_REJECT_UNAUTHORIZED !== 'false',
},
},
roles: {
admin: 'Administrator',
user: 'Analytics Viewer',
},
server: {
env: env.NODE_ENV,
port: serverPort,
swaggerServerUrl,
},
apiUrl: `${host}${port ? `:${port}` : ''}/api`,
swaggerUrl: `${swaggerUI}${swaggerPort}`,
uiUrl: `${hostUI}${portUI ? `:${portUI}` : ''}/#`,

View File

@ -55,7 +55,7 @@ const commonErrorHandler: ErrorRequestHandler = (
if (
statusCode !== undefined &&
[400, 401, 403, 404, 409, 422].includes(statusCode)
[400, 401, 403, 404, 409, 422, 503].includes(statusCode)
) {
return res.status(statusCode).send(error.message);
}

View File

@ -145,13 +145,8 @@ const searchRoutes = getExpressRouter('search', searchRoutesModule);
const tourPagesRoutes = getExpressRouter('tour_pages', tourPagesRoutesModule);
const usersRoutes = getExpressRouter('users', usersRoutesModule);
const getBaseUrl = (url: string | undefined): string => {
if (!url) return '';
return url.endsWith('/api') ? url.slice(0, -4) : url;
};
const specs = createOpenApiDocument({
serverUrl: getBaseUrl(process.env.NEXT_PUBLIC_BACK_API) || config.swaggerUrl,
serverUrl: config.server.swaggerServerUrl,
});
app.use(
@ -271,7 +266,7 @@ app.get('/api/health', wrapAsync(async (_req, res) => {
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
environment: process.env.NODE_ENV || 'development',
environment: config.server.env,
};
try {
@ -370,39 +365,52 @@ if (fs.existsSync(publicDir)) {
}
// Generic error handler
const getNumericErrorProperty = (
error: unknown,
property: 'code' | 'status',
): number | null => {
if (typeof error !== 'object' || error === null) {
return null;
}
const descriptor = Object.getOwnPropertyDescriptor(error, property);
return typeof descriptor?.value === 'number' ? descriptor.value : null;
};
const appErrorHandler: AppErrorHandler = (err, req, res, _next) => {
if (!res.headersSent) {
const requestLog = getRequestLogger(req) ?? logger;
const statusCode =
getNumericErrorProperty(err, 'code') ??
getNumericErrorProperty(err, 'status') ??
500;
const safeStatusCode = statusCode === 503 ? 503 : 500;
requestLog.error(
{ err, url: req.url, method: req.method },
'Express error middleware caught unhandled error',
);
res.status(500).json({ message: 'Internal server error' });
res.status(safeStatusCode).json({
message:
safeStatusCode === 503
? 'Service temporarily unavailable'
: 'Internal server error',
});
}
};
app.use(appErrorHandler);
function getServerPort(): number {
const configuredPort = Number.parseInt(process.env.PORT || '', 10);
if (Number.isFinite(configuredPort)) {
return configuredPort;
}
return process.env.NODE_ENV === 'dev_stage' ? 3000 : 8080;
}
const PORT = getServerPort();
const PORT = config.server.port;
const server = app.listen(PORT, () => {
logger.info(
{ port: PORT, env: process.env.NODE_ENV || 'development' },
{ port: PORT, env: config.server.env },
'Server started',
);
});
server.on('error', (err: NodeJS.ErrnoException) => {
logger.error(
{ err, port: PORT, env: process.env.NODE_ENV || 'development' },
{ err, port: PORT, env: config.server.env },
'Server failed to start',
);
exitAfterLogging();

View File

@ -12,6 +12,7 @@
import type { RequestHandler } from 'express';
import config from '../config.ts';
import type { RateLimitEntry, RateLimiterOptions } from '../types/index.ts';
import { logger } from '../utils/logger.ts';
import { getCurrentUser } from '../utils/request-context.ts';
@ -72,7 +73,7 @@ const createRateLimiter = (options: RateLimiterOptions = {}): RequestHandler =>
// Skip in development when accessing from localhost (optional)
if (
process.env.NODE_ENV === 'development' &&
config.server.env === 'development' &&
(req.ip === '127.0.0.1' || req.ip === '::1')
) {
return next();

View File

@ -53,6 +53,7 @@ import type {
UploadSessionInitRequest,
UploadSessionRequest,
} from '../types/index.ts';
import { CircuitBreaker } from '../utils/circuit-breaker.ts';
import { logger } from '../utils/logger.ts';
import LocalStorageProvider from './file/LocalStorageProvider.ts';
import S3StorageProvider from './file/S3StorageProvider.ts';
@ -232,10 +233,19 @@ let gcloudBucket: GCloudBucketState['bucket'] | null = null;
let gcloudHash: string | null = null;
let uploadSessionManager: UploadSessionManager | null = null;
const externalStorageBreaker = new CircuitBreaker({
name: 'external-file-storage',
failureThreshold: config.resilience.fileStorage.breaker.failureThreshold,
cooldownMs: config.resilience.fileStorage.breaker.cooldownMs,
successThreshold: config.resilience.fileStorage.breaker.successThreshold,
shouldRecordFailure: (error) =>
getFileStorageProvider() === 's3'
? S3StorageProvider.isRetryableError(error)
: true,
});
const getFileStorageProvider = (): FileStorageProviderName => {
const provider = (process.env.FILE_STORAGE_PROVIDER || '')
.trim()
.toLowerCase();
const provider = config.fileStorage.provider;
if (provider === 's3' || provider === 'gcloud' || provider === 'local') {
return provider;
}
@ -249,9 +259,9 @@ const getFileStorageProvider = (): FileStorageProviderName => {
if (hasS3) return 's3';
const hasGCloud = Boolean(
process.env.GC_PROJECT_ID &&
process.env.GC_CLIENT_EMAIL &&
process.env.GC_PRIVATE_KEY &&
config.gcloud.projectId &&
config.gcloud.clientEmail &&
config.gcloud.privateKey &&
config.gcloud.bucket &&
config.gcloud.hash,
);
@ -260,6 +270,21 @@ const getFileStorageProvider = (): FileStorageProviderName => {
return 'local';
};
const executeStorageOperation = async <TResult>(
provider: FileStorageProviderName,
operationName: string,
operation: () => Promise<TResult>,
): Promise<TResult> => {
if (provider === 'local') {
return operation();
}
return externalStorageBreaker.execute(
`${provider}.${operationName}`,
operation,
);
};
const getS3Provider = (): S3StorageProvider => {
if (!s3Provider) {
s3Provider = new S3StorageProvider({
@ -300,16 +325,11 @@ const getLocalProvider = (): LocalStorageProvider => {
const getGCloudBucket = (): GCloudBucketState => {
if (!gcloudBucket) {
const privateKey = (process.env.GC_PRIVATE_KEY || '').replace(
/\\\n/g,
'\n',
);
const clientEmail = process.env.GC_CLIENT_EMAIL || '';
const projectId = process.env.GC_PROJECT_ID || '';
const privateKey = config.gcloud.privateKey.replace(/\\\n/g, '\n');
const storage = new Storage({
projectId,
projectId: config.gcloud.projectId,
credentials: {
client_email: clientEmail,
client_email: config.gcloud.clientEmail,
private_key: privateKey,
},
});
@ -918,18 +938,20 @@ const deleteFile = async (
const provider = getFileStorageProvider();
try {
if (provider === 's3') {
const s3 = getS3Provider();
await s3.delete(privateUrl);
} else if (provider === 'gcloud') {
const { bucket, hash } = getGCloudBucket();
const file = bucket.file(`${hash}/${privateUrl}`);
const [exists] = await file.exists();
if (exists) await file.delete();
} else {
const local = getLocalProvider();
await local.delete(privateUrl);
}
await executeStorageOperation(provider, 'deleteFile', async () => {
if (provider === 's3') {
const s3 = getS3Provider();
await s3.delete(privateUrl);
} else if (provider === 'gcloud') {
const { bucket, hash } = getGCloudBucket();
const file = bucket.file(`${hash}/${privateUrl}`);
const [exists] = await file.exists();
if (exists) await file.delete();
} else {
const local = getLocalProvider();
await local.delete(privateUrl);
}
});
logger.debug({ provider, privateUrl }, 'File deleted successfully');
return { success: true };
@ -956,34 +978,36 @@ const deleteFile = async (
const downloadToBuffer = async (privateUrl: string): Promise<Buffer> => {
const provider = getFileStorageProvider();
if (provider === 's3') {
const s3 = getS3Provider();
const result = await s3.download(privateUrl);
return executeStorageOperation(provider, 'downloadToBuffer', async () => {
if (provider === 's3') {
const s3 = getS3Provider();
const result = await s3.download(privateUrl);
// Convert stream to buffer
if (hasByteArrayTransformer(result.body)) {
const bytes = await result.body.transformToByteArray();
return Buffer.from(bytes);
}
// Handle readable stream
if (!isReadableStream(result.body)) {
return Buffer.from([]);
}
// Convert stream to buffer
if (hasByteArrayTransformer(result.body)) {
const bytes = await result.body.transformToByteArray();
return Buffer.from(bytes);
}
// Handle readable stream
if (!isReadableStream(result.body)) {
return Buffer.from([]);
}
const chunks: Buffer[] = [];
for await (const chunk of result.body) {
chunks.push(toBufferChunk(chunk));
const chunks: Buffer[] = [];
for await (const chunk of result.body) {
chunks.push(toBufferChunk(chunk));
}
return Buffer.concat(chunks);
} else if (provider === 'gcloud') {
const { bucket, hash } = getGCloudBucket();
const file = bucket.file(`${hash}/${privateUrl}`);
const [data] = await file.download();
return data;
} else {
// Local provider - read directly from filesystem
return fs.readFileSync(path.join(config.uploadDir, privateUrl));
}
return Buffer.concat(chunks);
} else if (provider === 'gcloud') {
const { bucket, hash } = getGCloudBucket();
const file = bucket.file(`${hash}/${privateUrl}`);
const [data] = await file.download();
return data;
} else {
// Local provider - read directly from filesystem
return fs.readFileSync(path.join(config.uploadDir, privateUrl));
}
});
};
/**
@ -1008,28 +1032,30 @@ const downloadToTempFile = async (
};
try {
if (provider === 's3') {
const s3 = getS3Provider();
const result = await s3.download(privateUrl);
if (!result.body) {
throw new Error(`Empty S3 response body for ${privateUrl}`);
}
if (isReadableStream(result.body)) {
await pipeline(result.body, fs.createWriteStream(filePath));
} else if (hasByteArrayTransformer(result.body)) {
const bytes = await result.body.transformToByteArray();
await fs.promises.writeFile(filePath, Buffer.from(bytes));
await executeStorageOperation(provider, 'downloadToTempFile', async () => {
if (provider === 's3') {
const s3 = getS3Provider();
const result = await s3.download(privateUrl);
if (!result.body) {
throw new Error(`Empty S3 response body for ${privateUrl}`);
}
if (isReadableStream(result.body)) {
await pipeline(result.body, fs.createWriteStream(filePath));
} else if (hasByteArrayTransformer(result.body)) {
const bytes = await result.body.transformToByteArray();
await fs.promises.writeFile(filePath, Buffer.from(bytes));
} else {
throw new Error(`Unsupported S3 response body for ${privateUrl}`);
}
} else if (provider === 'gcloud') {
const { bucket, hash } = getGCloudBucket();
const file = bucket.file(`${hash}/${privateUrl}`);
await file.download({ destination: filePath });
} else {
throw new Error(`Unsupported S3 response body for ${privateUrl}`);
const local = getLocalProvider();
await fs.promises.copyFile(local.buildPath(privateUrl), filePath);
}
} else if (provider === 'gcloud') {
const { bucket, hash } = getGCloudBucket();
const file = bucket.file(`${hash}/${privateUrl}`);
await file.download({ destination: filePath });
} else {
const local = getLocalProvider();
await fs.promises.copyFile(local.buildPath(privateUrl), filePath);
}
});
return { filePath, cleanup };
} catch (error) {
@ -1054,30 +1080,32 @@ const uploadBuffer = async (
const provider = getFileStorageProvider();
const { contentType = 'application/octet-stream' } = options;
if (provider === 's3') {
const s3 = getS3Provider();
const result = await s3.upload(privateUrl, buffer, { contentType });
return { url: getStorageUploadUrl(result.url) };
} else if (provider === 'gcloud') {
const { bucket, hash } = getGCloudBucket();
const filePath = `${hash}/${privateUrl}`;
const blob = bucket.file(filePath);
await new Promise<void>((resolve, reject) => {
const blobStream = blob.createWriteStream({ resumable: false });
blobStream.on('error', reject);
blobStream.on('finish', resolve);
blobStream.end(buffer);
});
return {
url: `https://storage.googleapis.com/${bucket.name}/${blob.name}`,
};
} else {
const local = getLocalProvider();
await local.upload(privateUrl, buffer);
return {
url: `/api/file/download?privateUrl=${encodeURIComponent(privateUrl)}`,
};
}
return executeStorageOperation(provider, 'uploadBuffer', async () => {
if (provider === 's3') {
const s3 = getS3Provider();
const result = await s3.upload(privateUrl, buffer, { contentType });
return { url: getStorageUploadUrl(result.url) };
} else if (provider === 'gcloud') {
const { bucket, hash } = getGCloudBucket();
const filePath = `${hash}/${privateUrl}`;
const blob = bucket.file(filePath);
await new Promise<void>((resolve, reject) => {
const blobStream = blob.createWriteStream({ resumable: false });
blobStream.on('error', reject);
blobStream.on('finish', resolve);
blobStream.end(buffer);
});
return {
url: `https://storage.googleapis.com/${bucket.name}/${blob.name}`,
};
} else {
const local = getLocalProvider();
await local.upload(privateUrl, buffer);
return {
url: `/api/file/download?privateUrl=${encodeURIComponent(privateUrl)}`,
};
}
});
};
// ============================================================================

View File

@ -13,7 +13,9 @@ import path from 'path';
import ffmpeg from 'fluent-ffmpeg';
import { path as ffprobePath } from 'ffprobe-static';
import config from '../config.ts';
import type { FfmpegJobRunner, MediaMetadata } from '../types/index.ts';
import { CircuitBreaker } from '../utils/circuit-breaker.ts';
import { logger } from '../utils/logger.ts';
const loadCommonJsModule = createRequire(import.meta.url);
@ -29,6 +31,16 @@ let ffmpegQueueTail: Promise<unknown> = Promise.resolve();
let queuedFfmpegJobs = 0;
let ffmpegJobSequence = 0;
const reverseVideoBreaker = new CircuitBreaker({
name: 'ffmpeg-reverse-video',
failureThreshold: config.resilience.ffmpeg.breaker.failureThreshold,
cooldownMs: config.resilience.ffmpeg.breaker.cooldownMs,
successThreshold: config.resilience.ffmpeg.breaker.successThreshold,
});
const FFMPEG_REVERSE_TIMEOUT_MS = config.resilience.ffmpeg.reverseTimeoutMs;
const FFPROBE_TIMEOUT_MS = config.resilience.ffmpeg.ffprobeTimeoutMs;
function parseFrameRate(value: unknown): number | null {
if (!value) {
return null;
@ -115,7 +127,9 @@ async function reverseVideo(
filename: string,
): Promise<Buffer> {
return enqueueFfmpegJob('reverseVideo', () =>
reverseVideoWithoutQueue(inputBuffer, filename),
reverseVideoBreaker.execute('reverseVideo', () =>
reverseVideoWithoutQueue(inputBuffer, filename),
),
);
}
@ -131,10 +145,30 @@ async function reverseVideoWithoutQueue(
try {
await fs.writeFile(inputPath, inputBuffer);
logger.info({ inputPath, outputPath }, 'Starting video reversal');
let inputMetadata: MediaMetadata | null = null;
try {
inputMetadata = await probeMediaMetadata(inputPath);
} catch (metadataError) {
logger.warn(
{ err: metadataError, inputPath },
'Failed to probe input video metadata before reversal',
);
}
logger.info(
{
inputPath,
outputPath,
inputSizeBytes: inputBuffer.length,
inputMetadata,
timeoutMs: FFMPEG_REVERSE_TIMEOUT_MS,
breakerState: reverseVideoBreaker.getState(),
},
'Starting video reversal',
);
await new Promise<void>((resolve, reject) => {
ffmpeg(inputPath)
const command = ffmpeg(inputPath)
.outputOptions([
'-vf',
'reverse',
@ -161,17 +195,54 @@ async function reverseVideoWithoutQueue(
}
})
.on('end', () => {
clearTimeout(timeout);
logger.info({ outputPath }, 'Video reversal complete');
resolve();
})
.on('error', (err, stdout, stderr) => {
clearTimeout(timeout);
logger.error({ err, stdout, stderr }, 'FFmpeg error');
reject(err);
})
.run();
});
const timeout = setTimeout(() => {
const error = new Error(
`FFmpeg reverse video timed out after ${FFMPEG_REVERSE_TIMEOUT_MS}ms`,
);
logger.error(
{ err: error, inputPath, outputPath },
'FFmpeg reverse video timeout',
);
command.kill('SIGKILL');
reject(error);
}, FFMPEG_REVERSE_TIMEOUT_MS);
timeout.unref();
command.run();
});
return await fs.readFile(outputPath);
const reversedBuffer = await fs.readFile(outputPath);
let outputMetadata: MediaMetadata | null = null;
try {
outputMetadata = await probeMediaMetadata(outputPath);
} catch (metadataError) {
logger.warn(
{ err: metadataError, outputPath },
'Failed to probe output video metadata after reversal',
);
}
logger.info(
{
inputSizeBytes: inputBuffer.length,
outputSizeBytes: reversedBuffer.length,
inputMetadata,
outputMetadata,
},
'Video reversal output ready',
);
return reversedBuffer;
} finally {
try {
await fs.rm(tempDir, { recursive: true, force: true });
@ -183,7 +254,15 @@ async function reverseVideoWithoutQueue(
async function probeMediaMetadata(filePath: string): Promise<MediaMetadata> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(
new Error(`FFprobe timed out after ${FFPROBE_TIMEOUT_MS}ms: ${filePath}`),
);
}, FFPROBE_TIMEOUT_MS);
timeout.unref();
ffmpeg.ffprobe(filePath, (err: unknown, metadata) => {
clearTimeout(timeout);
if (err) {
reject(toError(err));
return;

View File

@ -11,6 +11,12 @@ export interface BackendConfig {
gcloud: {
bucket: string;
hash: string;
projectId: string;
clientEmail: string;
privateKey: string;
};
fileStorage: {
provider: '' | 's3' | 'gcloud' | 'local';
};
s3: {
bucket: string;
@ -25,6 +31,24 @@ export interface BackendConfig {
keepAlive: boolean;
presignExpirySeconds: number;
};
resilience: {
ffmpeg: {
reverseTimeoutMs: number;
ffprobeTimeoutMs: number;
breaker: {
failureThreshold: number;
cooldownMs: number;
successThreshold: number;
};
};
fileStorage: {
breaker: {
failureThreshold: number;
cooldownMs: number;
successThreshold: number;
};
};
};
secret_key: string;
admin_pass: string;
user_pass: string;
@ -50,6 +74,11 @@ export interface BackendConfig {
admin: string;
user?: string;
};
server: {
env: string;
port: number;
swaggerServerUrl: string;
};
remote: string;
port: string;
host: string;

View File

@ -7,6 +7,7 @@ export type NodeEnvironment =
export interface ValidatedEnvironment {
NODE_ENV: NodeEnvironment;
PORT: number;
NEXT_PUBLIC_BACK_API: string;
DB_HOST: string;
DB_PORT: number;
DB_NAME: string;
@ -25,9 +26,31 @@ export interface ValidatedEnvironment {
AWS_S3_BUCKET: string;
AWS_S3_REGION: string;
AWS_S3_PREFIX: string;
AWS_S3_CONNECTION_TIMEOUT: number;
AWS_S3_REQUEST_TIMEOUT: number;
AWS_S3_MAX_ATTEMPTS: number;
AWS_S3_MAX_SOCKETS: number;
AWS_S3_KEEP_ALIVE: 'true' | 'false';
AWS_S3_PRESIGN_EXPIRY: number;
FILE_STORAGE_PROVIDER: '' | 's3' | 'gcloud' | 'local';
GC_PROJECT_ID: string;
GC_CLIENT_EMAIL: string;
GC_PRIVATE_KEY: string;
S3_CACHE_DIR: string;
S3_CACHE_ENABLED: 'true' | 'false';
S3_CACHE_MAX_AGE: number;
FFMPEG_REVERSE_TIMEOUT_MS: number;
FFPROBE_TIMEOUT_MS: number;
FFMPEG_BREAKER_FAILURE_THRESHOLD: number;
FFMPEG_BREAKER_COOLDOWN_MS: number;
FFMPEG_BREAKER_SUCCESS_THRESHOLD: number;
FILE_STORAGE_BREAKER_FAILURE_THRESHOLD: number;
FILE_STORAGE_BREAKER_COOLDOWN_MS: number;
FILE_STORAGE_BREAKER_SUCCESS_THRESHOLD: number;
EMAIL_USER: string;
EMAIL_PASS: string;
EMAIL_TLS_REJECT_UNAUTHORIZED: 'true' | 'false';
PEXELS_KEY: string;
LOG_LEVEL: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace';
LOG_PRETTY: 'true' | 'false';
}

View File

@ -0,0 +1,170 @@
import { logger } from './logger.ts';
type CircuitBreakerState = 'closed' | 'open' | 'half-open';
interface CircuitBreakerOptions {
name: string;
failureThreshold?: number;
cooldownMs?: number;
successThreshold?: number;
shouldRecordFailure?: (error: unknown) => boolean;
}
class CircuitBreakerOpenError extends Error {
code = 503;
status = 503;
constructor(name: string) {
super(`${name} circuit breaker is open`);
this.name = 'CircuitBreakerOpenError';
}
}
class CircuitBreaker {
private readonly name: string;
private readonly failureThreshold: number;
private readonly cooldownMs: number;
private readonly successThreshold: number;
private readonly shouldRecordFailure: (error: unknown) => boolean;
private state: CircuitBreakerState = 'closed';
private failures = 0;
private consecutiveHalfOpenSuccesses = 0;
private openedAt = 0;
private halfOpenProbeInFlight = false;
constructor(options: CircuitBreakerOptions) {
this.name = options.name;
this.failureThreshold = options.failureThreshold ?? 5;
this.cooldownMs = options.cooldownMs ?? 30_000;
this.successThreshold = options.successThreshold ?? 2;
this.shouldRecordFailure = options.shouldRecordFailure ?? (() => true);
}
async execute<TResult>(
operationName: string,
operation: () => Promise<TResult>,
): Promise<TResult> {
this.refreshState();
if (this.state === 'open') {
logger.warn(
{ breaker: this.name, operationName, state: this.state },
'Circuit breaker rejected operation',
);
throw new CircuitBreakerOpenError(this.name);
}
if (this.state === 'half-open') {
if (this.halfOpenProbeInFlight) {
logger.warn(
{ breaker: this.name, operationName, state: this.state },
'Circuit breaker rejected concurrent half-open probe',
);
throw new CircuitBreakerOpenError(this.name);
}
this.halfOpenProbeInFlight = true;
}
try {
const result = await operation();
this.recordSuccess(operationName);
return result;
} catch (error) {
if (this.shouldRecordFailure(error)) {
this.recordFailure(operationName, error);
}
throw error;
} finally {
if (this.state === 'half-open') {
this.halfOpenProbeInFlight = false;
}
}
}
getState(): CircuitBreakerState {
this.refreshState();
return this.state;
}
private refreshState(): void {
if (this.state !== 'open') {
return;
}
if (Date.now() - this.openedAt < this.cooldownMs) {
return;
}
this.state = 'half-open';
this.consecutiveHalfOpenSuccesses = 0;
logger.warn(
{ breaker: this.name, cooldownMs: this.cooldownMs },
'Circuit breaker moved to half-open',
);
}
private recordSuccess(operationName: string): void {
if (this.state === 'half-open') {
this.consecutiveHalfOpenSuccesses += 1;
if (this.consecutiveHalfOpenSuccesses >= this.successThreshold) {
this.close(operationName);
}
return;
}
this.failures = 0;
}
private recordFailure(operationName: string, error: unknown): void {
this.failures += 1;
this.consecutiveHalfOpenSuccesses = 0;
logger.warn(
{
err: error,
breaker: this.name,
operationName,
failures: this.failures,
failureThreshold: this.failureThreshold,
state: this.state,
},
'Circuit breaker recorded operation failure',
);
if (this.state === 'half-open' || this.failures >= this.failureThreshold) {
this.open(operationName);
}
}
private open(operationName: string): void {
this.state = 'open';
this.openedAt = Date.now();
this.halfOpenProbeInFlight = false;
logger.error(
{
breaker: this.name,
operationName,
failures: this.failures,
cooldownMs: this.cooldownMs,
},
'Circuit breaker opened',
);
}
private close(operationName: string): void {
this.state = 'closed';
this.failures = 0;
this.consecutiveHalfOpenSuccesses = 0;
this.halfOpenProbeInFlight = false;
logger.info(
{ breaker: this.name, operationName },
'Circuit breaker closed',
);
}
}
export { CircuitBreaker, CircuitBreakerOpenError };

View File

@ -12,6 +12,7 @@ const envSchema = Joi.object({
.default('development'),
PORT: Joi.number().default(8080),
NEXT_PUBLIC_BACK_API: Joi.string().uri().allow('').default(''),
DB_HOST: Joi.string().default('localhost'),
DB_PORT: Joi.number().default(5432),
@ -37,6 +38,52 @@ const envSchema = Joi.object({
AWS_S3_BUCKET: Joi.string().allow('').default(''),
AWS_S3_REGION: Joi.string().default('us-east-1'),
AWS_S3_PREFIX: Joi.string().default('afeefb9d49f5b7977577876b99532ac7'),
AWS_S3_CONNECTION_TIMEOUT: Joi.number().integer().positive().default(5000),
AWS_S3_REQUEST_TIMEOUT: Joi.number().integer().positive().default(30000),
AWS_S3_MAX_ATTEMPTS: Joi.number().integer().positive().default(3),
AWS_S3_MAX_SOCKETS: Joi.number().integer().positive().default(50),
AWS_S3_KEEP_ALIVE: Joi.string().valid('true', 'false').default('true'),
AWS_S3_PRESIGN_EXPIRY: Joi.number().integer().positive().default(3600),
FILE_STORAGE_PROVIDER: Joi.string()
.trim()
.lowercase()
.valid('', 's3', 'gcloud', 'local')
.default(''),
GC_PROJECT_ID: Joi.string().allow('').default(''),
GC_CLIENT_EMAIL: Joi.string().allow('').default(''),
GC_PRIVATE_KEY: Joi.string().allow('').default(''),
S3_CACHE_DIR: Joi.string().allow('').default(''),
S3_CACHE_ENABLED: Joi.string().valid('true', 'false').default('true'),
S3_CACHE_MAX_AGE: Joi.number().integer().positive().default(86400),
FFMPEG_REVERSE_TIMEOUT_MS: Joi.number().integer().positive().default(600000),
FFPROBE_TIMEOUT_MS: Joi.number().integer().positive().default(30000),
FFMPEG_BREAKER_FAILURE_THRESHOLD: Joi.number()
.integer()
.positive()
.default(3),
FFMPEG_BREAKER_COOLDOWN_MS: Joi.number()
.integer()
.positive()
.default(120000),
FFMPEG_BREAKER_SUCCESS_THRESHOLD: Joi.number()
.integer()
.positive()
.default(1),
FILE_STORAGE_BREAKER_FAILURE_THRESHOLD: Joi.number()
.integer()
.positive()
.default(5),
FILE_STORAGE_BREAKER_COOLDOWN_MS: Joi.number()
.integer()
.positive()
.default(30000),
FILE_STORAGE_BREAKER_SUCCESS_THRESHOLD: Joi.number()
.integer()
.positive()
.default(2),
EMAIL_USER: Joi.string().allow('').default(''),
EMAIL_PASS: Joi.string().allow('').default(''),
@ -49,6 +96,7 @@ const envSchema = Joi.object({
LOG_LEVEL: Joi.string()
.valid('fatal', 'error', 'warn', 'info', 'debug', 'trace')
.default('info'),
LOG_PRETTY: Joi.string().valid('true', 'false').default('false'),
}).unknown(true);
function isNodeEnvironment(value: unknown): value is NodeEnvironment {
@ -103,10 +151,15 @@ function toValidatedEnvironment(
const nodeEnv = values.NODE_ENV;
const tlsRejectUnauthorized = values.EMAIL_TLS_REJECT_UNAUTHORIZED;
const logLevel = values.LOG_LEVEL;
const s3KeepAlive = values.AWS_S3_KEEP_ALIVE;
const fileStorageProvider = values.FILE_STORAGE_PROVIDER;
const s3CacheEnabled = values.S3_CACHE_ENABLED;
const logPretty = values.LOG_PRETTY;
return {
NODE_ENV: isNodeEnvironment(nodeEnv) ? nodeEnv : 'development',
PORT: readNumber(values, 'PORT', 8080),
NEXT_PUBLIC_BACK_API: readString(values, 'NEXT_PUBLIC_BACK_API', ''),
DB_HOST: readString(values, 'DB_HOST', 'localhost'),
DB_PORT: readNumber(values, 'DB_PORT', 5432),
DB_NAME: readString(values, 'DB_NAME', 'db_tour_builder_platform'),
@ -133,6 +186,74 @@ function toValidatedEnvironment(
'AWS_S3_PREFIX',
'afeefb9d49f5b7977577876b99532ac7',
),
AWS_S3_CONNECTION_TIMEOUT: readNumber(
values,
'AWS_S3_CONNECTION_TIMEOUT',
5000,
),
AWS_S3_REQUEST_TIMEOUT: readNumber(
values,
'AWS_S3_REQUEST_TIMEOUT',
30000,
),
AWS_S3_MAX_ATTEMPTS: readNumber(values, 'AWS_S3_MAX_ATTEMPTS', 3),
AWS_S3_MAX_SOCKETS: readNumber(values, 'AWS_S3_MAX_SOCKETS', 50),
AWS_S3_KEEP_ALIVE: isEnvBooleanString(s3KeepAlive) ? s3KeepAlive : 'true',
AWS_S3_PRESIGN_EXPIRY: readNumber(
values,
'AWS_S3_PRESIGN_EXPIRY',
3600,
),
FILE_STORAGE_PROVIDER:
fileStorageProvider === 's3' ||
fileStorageProvider === 'gcloud' ||
fileStorageProvider === 'local'
? fileStorageProvider
: '',
GC_PROJECT_ID: readString(values, 'GC_PROJECT_ID', ''),
GC_CLIENT_EMAIL: readString(values, 'GC_CLIENT_EMAIL', ''),
GC_PRIVATE_KEY: readString(values, 'GC_PRIVATE_KEY', ''),
S3_CACHE_DIR: readString(values, 'S3_CACHE_DIR', ''),
S3_CACHE_ENABLED: isEnvBooleanString(s3CacheEnabled)
? s3CacheEnabled
: 'true',
S3_CACHE_MAX_AGE: readNumber(values, 'S3_CACHE_MAX_AGE', 86400),
FFMPEG_REVERSE_TIMEOUT_MS: readNumber(
values,
'FFMPEG_REVERSE_TIMEOUT_MS',
600000,
),
FFPROBE_TIMEOUT_MS: readNumber(values, 'FFPROBE_TIMEOUT_MS', 30000),
FFMPEG_BREAKER_FAILURE_THRESHOLD: readNumber(
values,
'FFMPEG_BREAKER_FAILURE_THRESHOLD',
3,
),
FFMPEG_BREAKER_COOLDOWN_MS: readNumber(
values,
'FFMPEG_BREAKER_COOLDOWN_MS',
120000,
),
FFMPEG_BREAKER_SUCCESS_THRESHOLD: readNumber(
values,
'FFMPEG_BREAKER_SUCCESS_THRESHOLD',
1,
),
FILE_STORAGE_BREAKER_FAILURE_THRESHOLD: readNumber(
values,
'FILE_STORAGE_BREAKER_FAILURE_THRESHOLD',
5,
),
FILE_STORAGE_BREAKER_COOLDOWN_MS: readNumber(
values,
'FILE_STORAGE_BREAKER_COOLDOWN_MS',
30000,
),
FILE_STORAGE_BREAKER_SUCCESS_THRESHOLD: readNumber(
values,
'FILE_STORAGE_BREAKER_SUCCESS_THRESHOLD',
2,
),
EMAIL_USER: readString(values, 'EMAIL_USER', ''),
EMAIL_PASS: readString(values, 'EMAIL_PASS', ''),
EMAIL_TLS_REJECT_UNAUTHORIZED: isEnvBooleanString(tlsRejectUnauthorized)
@ -140,6 +261,7 @@ function toValidatedEnvironment(
: 'true',
PEXELS_KEY: readString(values, 'PEXELS_KEY', ''),
LOG_LEVEL: isEnvLogLevel(logLevel) ? logLevel : 'info',
LOG_PRETTY: isEnvBooleanString(logPretty) ? logPretty : 'false',
};
}

View File

@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import config from '../src/config.ts';
import {
createErrorResponse,
generatePresignedUrls,
@ -37,8 +38,8 @@ void test('createErrorResponse returns a stable structured error payload', () =>
});
void test('generatePresignedUrls returns backend download URLs for local storage provider', async () => {
const previousProvider = process.env.FILE_STORAGE_PROVIDER;
process.env.FILE_STORAGE_PROVIDER = 'local';
const previousProvider = config.fileStorage.provider;
config.fileStorage.provider = 'local';
try {
assert.deepEqual(
await generatePresignedUrls(['projects/demo/image 1.jpg']),
@ -48,10 +49,6 @@ void test('generatePresignedUrls returns backend download URLs for local storage
},
);
} finally {
if (previousProvider === undefined) {
delete process.env.FILE_STORAGE_PROVIDER;
} else {
process.env.FILE_STORAGE_PROVIDER = previousProvider;
}
config.fileStorage.provider = previousProvider;
}
});

View File

@ -2,6 +2,8 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { assertRouteIdMatchesBody } from '../src/helpers.ts';
import { CircuitBreaker, CircuitBreakerOpenError } from '../src/utils/circuit-breaker.ts';
import { envSchema } from '../src/utils/env-validation.ts';
import { normalizeLoggedError } from '../src/utils/logger.ts';
void test('assertRouteIdMatchesBody allows matching top-level body id', () => {
@ -52,3 +54,56 @@ void test('normalizeLoggedError uses string reasons as messages', () => {
assert.equal(error.message, 'plain rejection');
});
void test('envSchema normalizes file storage provider override', () => {
const result = envSchema.validate({ FILE_STORAGE_PROVIDER: ' S3 ' });
const providerDescriptor =
typeof result.value === 'object' && result.value !== null
? Object.getOwnPropertyDescriptor(result.value, 'FILE_STORAGE_PROVIDER')
: undefined;
assert.ifError(result.error);
assert.equal(providerDescriptor?.value, 's3');
});
void test('CircuitBreaker can ignore non-breaker failures', async () => {
const breaker = new CircuitBreaker({
name: 'test-breaker',
failureThreshold: 1,
shouldRecordFailure: () => false,
});
await assert.rejects(
() =>
breaker.execute('ignored-failure', () =>
Promise.reject(new Error('Validation failure')),
),
/Validation failure/,
);
assert.equal(breaker.getState(), 'closed');
await assert.doesNotReject(() =>
breaker.execute('next-call', () => Promise.resolve('ok')),
);
});
void test('CircuitBreaker opens after recorded failures', async () => {
const breaker = new CircuitBreaker({
name: 'test-breaker',
failureThreshold: 1,
});
await assert.rejects(
() =>
breaker.execute('recorded-failure', () =>
Promise.reject(new Error('Transient failure')),
),
/Transient failure/,
);
await assert.rejects(
() => breaker.execute('blocked-call', () => Promise.resolve('ok')),
CircuitBreakerOpenError,
);
});

View File

@ -2,6 +2,7 @@ import React, { Component, ErrorInfo, ReactNode } from 'react';
import { mdiAlertCircle } from '@mdi/js';
import BaseIcon from './BaseIcon';
import { logger } from '../lib/logger';
import { isProduction } from '../config';
// Define the props and state interfaces
interface ErrorBoundaryProps {
@ -42,13 +43,13 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
prevState: Readonly<ErrorBoundaryState>,
snapshot?: any,
) {
if (process.env.NODE_ENV !== 'production') {
if (!isProduction) {
logger.debug('componentDidUpdate');
}
}
componentWillUnmount() {
if (process.env.NODE_ENV !== 'production') {
if (!isProduction) {
logger.debug('componentWillUnmount');
}
}
@ -60,7 +61,7 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
});
// Only perform logging in non-production environments
if (process.env.NODE_ENV !== 'production') {
if (!isProduction) {
logger.info('Error caught in boundary:', { error, errorInfo });
}
}

View File

@ -1,12 +1,17 @@
export const isDevelopment = process.env.NODE_ENV === 'development';
export const isProduction = process.env.NODE_ENV === 'production';
export const frontendLogLevel = process.env.NEXT_PUBLIC_LOG_LEVEL || 'info';
export const hostApi =
process.env.NODE_ENV === 'development' && !process.env.NEXT_PUBLIC_BACK_API
isDevelopment && !process.env.NEXT_PUBLIC_BACK_API
? 'http://localhost'
: '';
export const portApi =
process.env.NODE_ENV === 'development' && !process.env.NEXT_PUBLIC_BACK_API
isDevelopment && !process.env.NEXT_PUBLIC_BACK_API
? 3000
: '';
export const baseURLApi = `${hostApi}${portApi ? `:${portApi}` : ``}/api`;
export const apiBaseURL = process.env.NEXT_PUBLIC_BACK_API || baseURLApi;
export const swaggerDocsUrl = baseURLApi.replace(/\/api\/?$/, '/api-docs');

View File

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import { isDevelopment } from '../config';
type CompilationStatus = 'ready' | 'compiling' | 'error' | 'initial';
@ -8,7 +9,7 @@ const useDevCompilationStatus = (): CompilationStatus => {
const [status, setStatus] = useState<CompilationStatus>('initial');
useEffect(() => {
if (process.env.NODE_ENV !== 'development') {
if (!isDevelopment) {
setStatus('ready');
return;
}

View File

@ -36,6 +36,7 @@ import {
getCrossfadeDuration,
} from '../lib/browserUtils';
import { logger } from '../lib/logger';
import { isDevelopment } from '../config';
import type { ResolvedTransitionSettings } from '../types/transition';
// ============================================================================
@ -172,7 +173,7 @@ function navigationReducer(
action: NavigationAction,
): NavigationState {
// DevTools logging in development
if (process.env.NODE_ENV === 'development') {
if (isDevelopment) {
logger.info('[NavigationState] Action:', {
type: action.type,
currentPhase: state.phase,

View File

@ -1,3 +1,5 @@
import { frontendLogLevel, isDevelopment } from '../config';
/**
* Frontend Logger
*
@ -29,13 +31,22 @@ const LOG_LEVELS: Record<LogLevel, number> = {
error: 3,
};
function toLogLevel(value: string): LogLevel {
return value === 'debug' ||
value === 'info' ||
value === 'warn' ||
value === 'error'
? value
: 'info';
}
class Logger {
private config: LoggerConfig;
constructor() {
this.config = {
level: (process.env.NEXT_PUBLIC_LOG_LEVEL as LogLevel) || 'info',
isDevelopment: process.env.NODE_ENV === 'development',
level: toLogLevel(frontendLogLevel),
isDevelopment,
service: 'tour-builder-frontend',
};
}

View File

@ -13,7 +13,7 @@ import '@fontsource-variable/instrument-sans';
import '@fontsource-variable/instrument-sans/wdth.css'; // Condensed width axis
import '../css/main.css';
import axios from 'axios';
import { baseURLApi } from '../config';
import { apiBaseURL, isProduction } from '../config';
import { useRouter } from 'next/router';
import ErrorBoundary from '../components/ErrorBoundary';
import 'intro.js/introjs.css';
@ -38,9 +38,7 @@ const isPresignedS3Url = (url: string): boolean => {
};
// Initialize axios
axios.defaults.baseURL = process.env.NEXT_PUBLIC_BACK_API
? process.env.NEXT_PUBLIC_BACK_API
: baseURLApi;
axios.defaults.baseURL = apiBaseURL;
axios.defaults.headers.common['Content-Type'] = 'application/json';
@ -128,7 +126,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
return;
}
if (process.env.NODE_ENV !== 'production') {
if (!isProduction) {
navigator.serviceWorker.getRegistrations().then((registrations) => {
registrations.forEach((registration) => {
registration.unregister();
@ -153,7 +151,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
return;
}
if (process.env.NODE_ENV === 'production') {
if (isProduction) {
navigator.serviceWorker
.register('/sw.js')
.then((registration) => {