chore: dedup dead code + compress utility
Deploy FileDrop / deploy (push) Successful in 43s
Deploy FileDrop / deploy (push) Successful in 43s
- Hapus src/shared/utils/retry.ts (0 imports, dead) - Hapus src/shared/errors/index.ts (7 classes, 0 imports) - Hapus drizzle type exports dari schema.ts (tidak dipake) - Hapus src/config/index.ts, redirect 8 imports langsung ke env.ts - Ekstrak maybeCompressChunk ke shared/utils/compress.ts - Hapus duplikasi gzipSync di upload-file.ts + chunked-storage.ts - Lint clean, build clean
This commit is contained in:
@@ -1,18 +1,14 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { createReadStream } from 'node:fs';
|
import { createReadStream } from 'node:fs';
|
||||||
import { open } from 'node:fs/promises';
|
import { open } from 'node:fs/promises';
|
||||||
import { gzipSync } from 'node:zlib';
|
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import type { NewFilePart } from '../../domain/entities/file-part';
|
import type { NewFilePart } from '../../domain/entities/file-part';
|
||||||
import type { IFilePartRepository } from '../../domain/ports/file-part-repository';
|
import type { IFilePartRepository } from '../../domain/ports/file-part-repository';
|
||||||
import type { IFileRepository } from '../../domain/ports/file-repository';
|
import type { IFileRepository } from '../../domain/ports/file-repository';
|
||||||
import type { ITelegramService } from '../../domain/ports/telegram-service';
|
import type { ITelegramService } from '../../domain/ports/telegram-service';
|
||||||
|
import { type CompressionAlgorithm, maybeCompressChunk } from '../../shared/utils/compress';
|
||||||
import { checkFileSize, computeHash, ensureExtension, getFileType } from '../../shared/utils/file';
|
import { checkFileSize, computeHash, ensureExtension, getFileType } from '../../shared/utils/file';
|
||||||
import type { UploadInput, UploadOutput } from '../dto/upload';
|
import type { UploadInput, UploadOutput } from '../dto/upload';
|
||||||
|
|
||||||
/** Compression algorithm string literal used in chunked storage. */
|
|
||||||
type ChunkCompressionAlgorithm = 'gzip' | null;
|
|
||||||
|
|
||||||
/** Metadata for a single uploaded chunk/part. */
|
/** Metadata for a single uploaded chunk/part. */
|
||||||
interface UploadedPart {
|
interface UploadedPart {
|
||||||
/** 1-based part number. */
|
/** 1-based part number. */
|
||||||
@@ -28,7 +24,7 @@ interface UploadedPart {
|
|||||||
/** Stored (post-compression) size in bytes. */
|
/** Stored (post-compression) size in bytes. */
|
||||||
storedSizeBytes: number;
|
storedSizeBytes: number;
|
||||||
/** Compression algorithm applied, or null if uncompressed. */
|
/** Compression algorithm applied, or null if uncompressed. */
|
||||||
compressionAlgorithm: ChunkCompressionAlgorithm;
|
compressionAlgorithm: CompressionAlgorithm;
|
||||||
/** SHA-256 hash of the original chunk content. */
|
/** SHA-256 hash of the original chunk content. */
|
||||||
etag: string;
|
etag: string;
|
||||||
}
|
}
|
||||||
@@ -82,32 +78,6 @@ const asSafeChunkSize = (chunkSizeBytes: number): number => {
|
|||||||
return chunkSizeBytes;
|
return chunkSizeBytes;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Optionally gzip-compresses a chunk if compression is enabled and the chunk
|
|
||||||
* is large enough to benefit from it.
|
|
||||||
*
|
|
||||||
* @param chunk - The raw chunk buffer.
|
|
||||||
* @param compress - Whether compression is enabled.
|
|
||||||
* @param compressionMinSizeBytes - Minimum chunk size to attempt compression.
|
|
||||||
* @returns The (possibly compressed) buffer and the algorithm used.
|
|
||||||
*/
|
|
||||||
const maybeCompressChunk = (
|
|
||||||
chunk: Buffer,
|
|
||||||
compress: boolean,
|
|
||||||
compressionMinSizeBytes: number,
|
|
||||||
): { bytes: Buffer; compressionAlgorithm: ChunkCompressionAlgorithm } => {
|
|
||||||
if (!compress || chunk.byteLength < compressionMinSizeBytes) {
|
|
||||||
return { bytes: chunk, compressionAlgorithm: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const gzipped = gzipSync(chunk);
|
|
||||||
if (gzipped.byteLength >= chunk.byteLength) {
|
|
||||||
return { bytes: chunk, compressionAlgorithm: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { bytes: gzipped, compressionAlgorithm: 'gzip' };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads the first 16 bytes from a file on disk for magic-byte detection.
|
* Reads the first 16 bytes from a file on disk for magic-byte detection.
|
||||||
*
|
*
|
||||||
@@ -258,7 +228,7 @@ export function createUploadFileUseCase(deps: UploadFileUseCaseDeps) {
|
|||||||
throw new Error('Chunked upload produced no parts');
|
throw new Error('Chunked upload produced no parts');
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileId = randomUUID();
|
const fileId = nanoid();
|
||||||
const publicId = nanoid();
|
const publicId = nanoid();
|
||||||
|
|
||||||
const newFile = await deps.fileRepo.create({
|
const newFile = await deps.fileRepo.create({
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
import { config } from '../env';
|
|
||||||
|
|
||||||
export { config };
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { serve } from 'bun';
|
import { serve } from 'bun';
|
||||||
import { config } from './config/index';
|
import { config } from './env';
|
||||||
import { fileInfoCache } from './infrastructure/cache/index';
|
import { fileInfoCache } from './infrastructure/cache/index';
|
||||||
import { startBot } from './interfaces/bot/handler';
|
import { startBot } from './interfaces/bot/handler';
|
||||||
import { handleS3Request } from './interfaces/http/controllers/s3-controller';
|
import { handleS3Request } from './interfaces/http/controllers/s3-controller';
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm';
|
|
||||||
import {
|
import {
|
||||||
bigint,
|
bigint,
|
||||||
boolean,
|
boolean,
|
||||||
@@ -63,15 +62,3 @@ export const fileParts = pgTable('file_parts', {
|
|||||||
etag: text('etag').notNull(),
|
etag: text('etag').notNull(),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Type representing a file row selected from the database. */
|
|
||||||
export type File = InferSelectModel<typeof files>;
|
|
||||||
|
|
||||||
/** Type representing a file row being inserted into the database. */
|
|
||||||
export type NewFile = InferInsertModel<typeof files>;
|
|
||||||
|
|
||||||
/** Type representing a file part row selected from the database. */
|
|
||||||
export type FilePart = InferSelectModel<typeof fileParts>;
|
|
||||||
|
|
||||||
/** Type representing a file part row being inserted into the database. */
|
|
||||||
export type NewFilePart = InferInsertModel<typeof fileParts>;
|
|
||||||
|
|||||||
@@ -1,22 +1,16 @@
|
|||||||
import { createReadStream } from 'node:fs';
|
import { createReadStream } from 'node:fs';
|
||||||
import { gzipSync } from 'node:zlib';
|
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import type { File as FileEntity } from '../../domain/entities/file';
|
import type { File as FileEntity } from '../../domain/entities/file';
|
||||||
import type { CompressionAlgorithm, NewFilePart } from '../../domain/entities/file-part';
|
import type { NewFilePart } from '../../domain/entities/file-part';
|
||||||
import type { IFilePartRepository } from '../../domain/ports/file-part-repository';
|
import type { IFilePartRepository } from '../../domain/ports/file-part-repository';
|
||||||
import type { IFileRepository } from '../../domain/ports/file-repository';
|
import type { IFileRepository } from '../../domain/ports/file-repository';
|
||||||
import type { ITelegramService } from '../../domain/ports/telegram-service';
|
import type { ITelegramService } from '../../domain/ports/telegram-service';
|
||||||
import { config } from '../../env';
|
import { config } from '../../env';
|
||||||
import { createGetObjectResponse, type ObjectPartSource } from '../../interfaces/s3/object-stream';
|
import { createGetObjectResponse, type ObjectPartSource } from '../../interfaces/s3/object-stream';
|
||||||
import type { RangeParseResult } from '../../interfaces/s3/range';
|
import type { RangeParseResult } from '../../interfaces/s3/range';
|
||||||
|
import { type CompressionAlgorithm, maybeCompressChunk } from '../../shared/utils/compress';
|
||||||
import { computeHash } from '../../shared/utils/file';
|
import { computeHash } from '../../shared/utils/file';
|
||||||
|
|
||||||
/**
|
|
||||||
* Chunk compression algorithm identifier.
|
|
||||||
* `"gzip"` if gzip compression was applied, `null` for uncompressed.
|
|
||||||
*/
|
|
||||||
export type ChunkCompressionAlgorithm = CompressionAlgorithm;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Metadata about a single uploaded chunk (part) stored in Telegram.
|
* Metadata about a single uploaded chunk (part) stored in Telegram.
|
||||||
*/
|
*/
|
||||||
@@ -34,7 +28,7 @@ export interface ChunkedUploadPart {
|
|||||||
/** Stored (post-compression) size in bytes */
|
/** Stored (post-compression) size in bytes */
|
||||||
storedSizeBytes: number;
|
storedSizeBytes: number;
|
||||||
/** Compression algorithm applied, or null */
|
/** Compression algorithm applied, or null */
|
||||||
compressionAlgorithm: ChunkCompressionAlgorithm;
|
compressionAlgorithm: CompressionAlgorithm;
|
||||||
/** ETag (SHA-256 hash) of the original chunk */
|
/** ETag (SHA-256 hash) of the original chunk */
|
||||||
etag: string;
|
etag: string;
|
||||||
}
|
}
|
||||||
@@ -89,36 +83,6 @@ const asSafeChunkSize = (chunkSizeBytes: number): number => {
|
|||||||
return chunkSizeBytes;
|
return chunkSizeBytes;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Optionally compress a chunk with gzip.
|
|
||||||
*
|
|
||||||
* Compression is skipped if:
|
|
||||||
* - The `compress` flag is false.
|
|
||||||
* - The chunk is smaller than `compressionMinSizeBytes`.
|
|
||||||
* - The compressed result is larger than the original.
|
|
||||||
*
|
|
||||||
* @param chunk - The raw chunk buffer.
|
|
||||||
* @param compress - Whether compression is enabled.
|
|
||||||
* @param compressionMinSizeBytes - Minimum chunk size to attempt compression.
|
|
||||||
* @returns The (possibly compressed) bytes and the algorithm used.
|
|
||||||
*/
|
|
||||||
const maybeCompressChunk = (
|
|
||||||
chunk: Buffer,
|
|
||||||
compress: boolean,
|
|
||||||
compressionMinSizeBytes: number,
|
|
||||||
): { bytes: Buffer; compressionAlgorithm: ChunkCompressionAlgorithm } => {
|
|
||||||
if (!compress || chunk.byteLength < compressionMinSizeBytes) {
|
|
||||||
return { bytes: chunk, compressionAlgorithm: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const gzipped = gzipSync(chunk);
|
|
||||||
if (gzipped.byteLength >= chunk.byteLength) {
|
|
||||||
return { bytes: chunk, compressionAlgorithm: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { bytes: gzipped, compressionAlgorithm: 'gzip' };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages chunked storage of large files in Telegram.
|
* Manages chunked storage of large files in Telegram.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
createLogoutUseCase,
|
createLogoutUseCase,
|
||||||
createMeUseCase,
|
createMeUseCase,
|
||||||
} from '../../../application/use-cases/authenticate';
|
} from '../../../application/use-cases/authenticate';
|
||||||
import { config } from '../../../config/index';
|
import { config } from '../../../env';
|
||||||
import {
|
import {
|
||||||
checkBearerToken,
|
checkBearerToken,
|
||||||
clearSessionCookie,
|
clearSessionCookie,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createReadStream } from 'node:fs';
|
import { createReadStream } from 'node:fs';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { config } from '../../../config/index';
|
|
||||||
import type { File as FileEntity } from '../../../domain/entities/file';
|
import type { File as FileEntity } from '../../../domain/entities/file';
|
||||||
import type { ForwardResult } from '../../../domain/ports/telegram-service';
|
import type { ForwardResult } from '../../../domain/ports/telegram-service';
|
||||||
|
import { config } from '../../../env';
|
||||||
import {
|
import {
|
||||||
bucketRepository,
|
bucketRepository,
|
||||||
chunkedStorage,
|
chunkedStorage,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createWriteStream } from 'node:fs';
|
import { createWriteStream } from 'node:fs';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { config } from '../../../config/index';
|
import { config } from '../../../env';
|
||||||
import { chunkedStorage, fileRepository, uploadBatcher } from '../../../infrastructure/di';
|
import { chunkedStorage, fileRepository, uploadBatcher } from '../../../infrastructure/di';
|
||||||
import type { PreparedUpload } from '../../../infrastructure/telegram/upload-batcher';
|
import type { PreparedUpload } from '../../../infrastructure/telegram/upload-batcher';
|
||||||
import logger from '../../../shared/logger/index';
|
import logger from '../../../shared/logger/index';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createReadStream } from 'node:fs';
|
import { createReadStream } from 'node:fs';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { config } from '../../../config/index';
|
import { config } from '../../../env';
|
||||||
import { bucketRepository, chunkedStorage, fileRepository } from '../../../infrastructure/di';
|
import { bucketRepository, chunkedStorage, fileRepository } from '../../../infrastructure/di';
|
||||||
import { db, files as fileSchema } from '../../../infrastructure/persistence/drizzle/index';
|
import { db, files as fileSchema } from '../../../infrastructure/persistence/drizzle/index';
|
||||||
import { botPool } from '../../../infrastructure/telegram/bot-pool';
|
import { botPool } from '../../../infrastructure/telegram/bot-pool';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
import { config } from '../../../config/index';
|
import { config } from '../../../env';
|
||||||
|
|
||||||
const ADMIN_USERNAME = 'admin';
|
const ADMIN_USERNAME = 'admin';
|
||||||
const SIGNATURE_SEPARATOR = '.';
|
const SIGNATURE_SEPARATOR = '.';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { config } from '../../../config/index';
|
import { config } from '../../../env';
|
||||||
import logger from '../../../shared/logger/index';
|
import logger from '../../../shared/logger/index';
|
||||||
import { extractClientIp } from '../../../shared/utils/ip';
|
import { extractClientIp } from '../../../shared/utils/ip';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { config } from '../../../config/index';
|
import { config } from '../../../env';
|
||||||
import { handleSwaggerHtml, handleSwaggerJson } from '../../../routes/swagger';
|
import { handleSwaggerHtml, handleSwaggerJson } from '../../../routes/swagger';
|
||||||
import { isS3Request } from '../../s3/auth';
|
import { isS3Request } from '../../s3/auth';
|
||||||
import { extractS3BucketFromHost } from '../../s3/virtual-host';
|
import { extractS3BucketFromHost } from '../../s3/virtual-host';
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
/**
|
|
||||||
* Base domain error class for all application-specific errors.
|
|
||||||
* Extends the built-in Error with a fixed name property for reliable
|
|
||||||
* instance checking across layers.
|
|
||||||
*/
|
|
||||||
export class DomainError extends Error {
|
|
||||||
constructor(msg: string) {
|
|
||||||
super(msg);
|
|
||||||
this.name = 'DomainError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thrown when a requested file cannot be found in storage.
|
|
||||||
*/
|
|
||||||
export class FileNotFoundError extends DomainError {
|
|
||||||
constructor(msg: string) {
|
|
||||||
super(msg);
|
|
||||||
this.name = 'FileNotFoundError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thrown when a requested bucket does not exist.
|
|
||||||
*/
|
|
||||||
export class BucketNotFoundError extends DomainError {
|
|
||||||
constructor(msg: string) {
|
|
||||||
super(msg);
|
|
||||||
this.name = 'BucketNotFoundError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thrown when a file exceeds the maximum allowed size for upload.
|
|
||||||
*/
|
|
||||||
export class FileTooLargeError extends DomainError {
|
|
||||||
constructor(msg: string) {
|
|
||||||
super(msg);
|
|
||||||
this.name = 'FileTooLargeError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thrown when an attempt is made to upload a file that already exists
|
|
||||||
* (detected by content hash deduplication).
|
|
||||||
*/
|
|
||||||
export class DuplicateFileError extends DomainError {
|
|
||||||
constructor(msg: string) {
|
|
||||||
super(msg);
|
|
||||||
this.name = 'DuplicateFileError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thrown when authentication fails or a valid session is not present.
|
|
||||||
*/
|
|
||||||
export class AuthenticationError extends DomainError {
|
|
||||||
constructor(msg: string) {
|
|
||||||
super(msg);
|
|
||||||
this.name = 'AuthenticationError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thrown when input validation fails (e.g. missing required fields,
|
|
||||||
* invalid format, or constraint violations).
|
|
||||||
*/
|
|
||||||
export class ValidationError extends DomainError {
|
|
||||||
constructor(msg: string) {
|
|
||||||
super(msg);
|
|
||||||
this.name = 'ValidationError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/** Compression algorithm for chunked file storage. */
|
||||||
|
export type CompressionAlgorithm = 'gzip' | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optionally compress a chunk with gzip.
|
||||||
|
*
|
||||||
|
* Compression is skipped if:
|
||||||
|
* - The `compress` flag is false.
|
||||||
|
* - The chunk is smaller than `compressionMinSizeBytes`.
|
||||||
|
* - The compressed result is larger than the original.
|
||||||
|
*
|
||||||
|
* @param chunk - The raw chunk buffer.
|
||||||
|
* @param compress - Whether compression is enabled.
|
||||||
|
* @param compressionMinSizeBytes - Minimum chunk size to attempt compression.
|
||||||
|
* @returns The (possibly compressed) bytes and the algorithm used.
|
||||||
|
*/
|
||||||
|
export const maybeCompressChunk = (
|
||||||
|
chunk: Buffer,
|
||||||
|
compress: boolean,
|
||||||
|
compressionMinSizeBytes: number,
|
||||||
|
): { bytes: Buffer; compressionAlgorithm: CompressionAlgorithm } => {
|
||||||
|
if (!compress || chunk.byteLength < compressionMinSizeBytes) {
|
||||||
|
return { bytes: chunk, compressionAlgorithm: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const gzipped = Bun.gzipSync(chunk);
|
||||||
|
if (gzipped.byteLength >= chunk.byteLength) {
|
||||||
|
return { bytes: chunk, compressionAlgorithm: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bytes: gzipped, compressionAlgorithm: 'gzip' };
|
||||||
|
};
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import logger from '../logger/index';
|
|
||||||
|
|
||||||
/** Configuration options for retry behaviour. */
|
|
||||||
interface RetryOptions {
|
|
||||||
/** Maximum number of retry attempts (default: 3). */
|
|
||||||
maxRetries?: number;
|
|
||||||
/** Delay before the first retry in milliseconds (default: 100). */
|
|
||||||
initialDelayMs?: number;
|
|
||||||
/** Maximum delay between retries in milliseconds (default: 5000). */
|
|
||||||
maxDelayMs?: number;
|
|
||||||
/** Multiplier for exponential backoff (default: 2). */
|
|
||||||
backoffMultiplier?: number;
|
|
||||||
/**
|
|
||||||
* Predicate that determines whether a given error should trigger a retry.
|
|
||||||
* When omitted, transient network / timeout errors are retried.
|
|
||||||
*/
|
|
||||||
shouldRetry?: (error: unknown) => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_OPTIONS: Required<RetryOptions> = {
|
|
||||||
maxRetries: 3,
|
|
||||||
initialDelayMs: 100,
|
|
||||||
maxDelayMs: 5000,
|
|
||||||
backoffMultiplier: 2,
|
|
||||||
shouldRetry: (error: unknown) => {
|
|
||||||
const errorStr = error instanceof Error ? error.message : String(error);
|
|
||||||
// Retry on transient errors
|
|
||||||
return (
|
|
||||||
errorStr.includes('ECONNREFUSED') ||
|
|
||||||
errorStr.includes('ETIMEDOUT') ||
|
|
||||||
errorStr.includes('ENOTFOUND') ||
|
|
||||||
errorStr.includes('429') ||
|
|
||||||
errorStr.includes('timeout')
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Executes an async function with exponential backoff retry logic.
|
|
||||||
*
|
|
||||||
* The function is retried up to `maxRetries` times. Between attempts the
|
|
||||||
* delay grows by `backoffMultiplier` (capped at `maxDelayMs`). Only errors
|
|
||||||
* for which `shouldRetry` returns `true` trigger a retry; all others are
|
|
||||||
* thrown immediately. When all retries are exhausted the last error is
|
|
||||||
* thrown.
|
|
||||||
*
|
|
||||||
* @param fn - The async function to execute.
|
|
||||||
* @param options - Optional retry configuration overrides.
|
|
||||||
* @returns The resolved value of `fn`.
|
|
||||||
*/
|
|
||||||
export const withRetry = async <T>(
|
|
||||||
fn: () => Promise<T>,
|
|
||||||
options: RetryOptions = {},
|
|
||||||
): Promise<T> => {
|
|
||||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
||||||
let lastError: unknown;
|
|
||||||
let delay = opts.initialDelayMs;
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
|
|
||||||
try {
|
|
||||||
return await fn();
|
|
||||||
} catch (error: unknown) {
|
|
||||||
lastError = error;
|
|
||||||
const errorStr = error instanceof Error ? error.message : String(error);
|
|
||||||
|
|
||||||
if (attempt === opts.maxRetries || !opts.shouldRetry(error)) {
|
|
||||||
logger.error('Retry exhausted', {
|
|
||||||
attempt,
|
|
||||||
maxRetries: opts.maxRetries,
|
|
||||||
error: errorStr,
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.warn('Retrying after error', {
|
|
||||||
attempt,
|
|
||||||
delay,
|
|
||||||
error: errorStr,
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
delay = Math.min(delay * opts.backoffMultiplier, opts.maxDelayMs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw lastError;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps an async function with a configurable timeout.
|
|
||||||
*
|
|
||||||
* If `fn` does not settle within `timeoutMs` milliseconds the returned
|
|
||||||
* promise rejects with a timeout error. The underlying `fn` continues
|
|
||||||
* executing but its result is ignored.
|
|
||||||
*
|
|
||||||
* @param fn - The async function to execute.
|
|
||||||
* @param timeoutMs - Timeout in milliseconds (default: 30000).
|
|
||||||
* @returns The resolved value of `fn`.
|
|
||||||
*/
|
|
||||||
export const withTimeout = async <T>(
|
|
||||||
fn: () => Promise<T>,
|
|
||||||
timeoutMs: number = 30000,
|
|
||||||
): Promise<T> => {
|
|
||||||
return Promise.race([
|
|
||||||
fn(),
|
|
||||||
new Promise<T>((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error(`Operation timeout after ${timeoutMs}ms`)), timeoutMs),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Executes a primary async function and falls back to a secondary function
|
|
||||||
* if the primary throws.
|
|
||||||
*
|
|
||||||
* The fallback function is called only when the primary rejects. If the
|
|
||||||
* fallback also throws the error propagates to the caller.
|
|
||||||
*
|
|
||||||
* @param primary - The primary async function to attempt first.
|
|
||||||
* @param fallback - The fallback async function invoked on failure.
|
|
||||||
* @returns The resolved value of `primary` or, on failure, of `fallback`.
|
|
||||||
*/
|
|
||||||
export const withFallback = async <T>(
|
|
||||||
primary: () => Promise<T>,
|
|
||||||
fallback: () => Promise<T>,
|
|
||||||
): Promise<T> => {
|
|
||||||
try {
|
|
||||||
return await primary();
|
|
||||||
} catch (error: unknown) {
|
|
||||||
logger.warn('Primary operation failed, using fallback', {
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
return fallback();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user