Reliability
Eight reliability patterns that detect error-handling and resilience anti-patterns, with NestJS-aware exception filter detection.
Reliability Patterns
The reliability detector identifies 8 error-handling and resilience anti-patterns that cause silent failures, process crashes, or cascading outages. Detection is NestJS-aware -- it understands that @Injectable services behind exception filters handle errors differently than standalone functions.
Pattern Reference
| Rule ID | Description | Default Severity |
|---|---|---|
unhandled-promise-rejection | await without try/catch or fire-and-forget async call | Configurable |
missing-try-catch | Async function with multiple awaits but no try/catch | Configurable |
external-call-no-timeout | fetch()/axios/got without timeout option | Configurable |
retry-without-backoff | Retry loop without exponential backoff | Configurable |
empty-catch-block | catch block with no statements | Configurable |
missing-error-logging | catch block that swallows the error without logging | Configurable |
transaction-no-timeout | prisma.$transaction() without timeout option | Configurable |
missing-null-guard | Property access after findFirst/findUnique without null check | Configurable |
NestJS Exception Filter Awareness
The detector understands NestJS's exception handling architecture. Methods inside @Injectable, @Controller, or @Resolver classes are covered by NestJS exception filters, which catch unhandled async errors and convert them to HTTP responses. This means:
- await without try/catch in
@Injectableservices -- not flagged (exception filter catches it) - Fire-and-forget promise in
@Injectableservices -- still flagged (exception filter cannot catch unawaited promises) - await without try/catch in
@Cron/@Processmethods -- flagged (background workers are NOT covered by exception filters)
@Injectable()
export class OrderService {
async findOrder(id: string): Promise<Order> {
// NOT flagged: NestJS exception filter catches any rejection
const order = await this.prisma.order.findUniqueOrThrow({ where: { id } });
return order;
}
@Cron('0 * * * *')
async cleanupExpired(): Promise<void> {
// FLAGGED: @Cron method is not covered by exception filters
const expired = await this.prisma.order.findMany({
where: { expiresAt: { lt: new Date() } },
});
await this.prisma.order.deleteMany({
where: { id: { in: expired.map(o => o.id) } },
});
}
}
Bootstrap Function Exemption
Functions named bootstrap, main, start, init, setup, run, or serve are exempt from reliability rules. These startup functions should fail-fast if something goes wrong -- wrapping them in try/catch would mask critical configuration errors.
1. unhandled-promise-rejection
An await expression not wrapped in try/catch inside a function with multiple risky awaits. Also detects fire-and-forget patterns where an async function is called without await.
Fire-and-forget -- Violation:
@Injectable()
export class NotificationService {
async onOrderCreated(order: Order): Promise<void> {
await this.prisma.notification.create({ data: { orderId: order.id } });
// Fire-and-forget: async method called without await
// If sendEmail fails, the error is silently swallowed
this.emailService.sendOrderConfirmation(order);
}
}
Fire-and-forget -- Fix:
@Injectable()
export class NotificationService {
async onOrderCreated(order: Order): Promise<void> {
await this.prisma.notification.create({ data: { orderId: order.id } });
// Option A: await the result
await this.emailService.sendOrderConfirmation(order);
// Option B: explicit error handling if fire-and-forget is intentional
this.emailService.sendOrderConfirmation(order).catch(err => {
this.logger.error('Failed to send order confirmation', err);
});
}
}
Multiple risky awaits -- Violation:
// Non-NestJS function with multiple risky operations
async function processWebhook(payload: WebhookPayload): Promise<void> {
const user = await fetchUserFromAPI(payload.userId);
const enriched = await enrichUserData(user);
await saveToDatabase(enriched);
await notifyDownstream(enriched);
// No try/catch: any failure crashes the process
}
Multiple risky awaits -- Fix:
async function processWebhook(payload: WebhookPayload): Promise<void> {
try {
const user = await fetchUserFromAPI(payload.userId);
const enriched = await enrichUserData(user);
await saveToDatabase(enriched);
await notifyDownstream(enriched);
} catch (error) {
logger.error('Webhook processing failed', { payload, error });
throw error; // Re-throw if caller should handle it
}
}
2. missing-try-catch
An async function with two or more await expressions and no try/catch block anywhere in the function body. Functions with a single await are considered simple delegation and are not flagged.
Violation:
async function syncInventory(warehouseId: string): Promise<void> {
const warehouse = await this.warehouseService.findById(warehouseId);
const items = await this.inventoryAPI.fetchItems(warehouse.externalId);
const mapped = items.map(mapToInternal);
await this.itemRepo.upsertMany(mapped);
await this.auditLog.record('inventory-sync', { warehouseId, count: items.length });
}
Fix:
async function syncInventory(warehouseId: string): Promise<void> {
try {
const warehouse = await this.warehouseService.findById(warehouseId);
const items = await this.inventoryAPI.fetchItems(warehouse.externalId);
const mapped = items.map(mapToInternal);
await this.itemRepo.upsertMany(mapped);
await this.auditLog.record('inventory-sync', { warehouseId, count: items.length });
} catch (error) {
this.logger.error(`Inventory sync failed for warehouse ${warehouseId}`, error);
throw new InternalServerErrorException('Inventory sync failed');
}
}
3. external-call-no-timeout
An HTTP call via fetch(), axios, or got() without a timeout or AbortController signal. Without a timeout, a slow or unresponsive upstream service hangs the request indefinitely, exhausting connection pools.
Violation:
@Injectable()
export class PaymentGateway {
async charge(amount: number, token: string): Promise<ChargeResult> {
// No timeout: if payment provider is slow, this hangs forever
const response = await fetch('https://api.payments.com/charge', {
method: 'POST',
body: JSON.stringify({ amount, token }),
});
return response.json();
}
}
Fix:
@Injectable()
export class PaymentGateway {
async charge(amount: number, token: string): Promise<ChargeResult> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch('https://api.payments.com/charge', {
method: 'POST',
body: JSON.stringify({ amount, token }),
signal: controller.signal,
});
return response.json();
} finally {
clearTimeout(timeout);
}
}
}
// With axios -- use the timeout option directly
const response = await axios.post('https://api.payments.com/charge', {
amount, token,
}, {
timeout: 5000,
});
Tip: The detector recognizes
AbortController,signal, andtimeoutkeywords in the surrounding scope. If you set up anAbortControllerearlier in the function, thefetch()call is not flagged even if thesignalis passed indirectly.
4. retry-without-backoff
A loop containing a try/catch block (retry pattern) without any delay, sleep, or exponential backoff. Without backoff, a failing service receives a burst of retry requests that can cause cascading failures.
Violation:
async function fetchWithRetry(url: string, maxRetries = 3): Promise<Response> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fetch(url, { timeout: 5000 });
} catch (error) {
if (attempt === maxRetries - 1) throw error;
// No delay between retries -- hammers the failing service
}
}
throw new Error('Unreachable');
}
Fix:
async function fetchWithRetry(url: string, maxRetries = 3): Promise<Response> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fetch(url, { timeout: 5000 });
} catch (error) {
if (attempt === maxRetries - 1) throw error;
// Exponential backoff with jitter
const baseDelay = 1000;
const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 500;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error('Unreachable');
}
The detector recognizes these backoff indicators and will not flag the retry loop:
Math.pow(2, attempt)or2 ** attempt(exponential)attempt * delayordelay * attempt(linear)await sleep()orawait delay()helper functions- Keywords:
backoff,exponential,jitter
5. empty-catch-block
A catch block with zero statements. Empty catches silently swallow errors, making bugs impossible to diagnose.
Violation:
async function importData(source: string): Promise<void> {
try {
const data = await fetchExternalData(source);
await processAndStore(data);
} catch (error) {
// Empty catch: error is silently swallowed
}
}
Fix:
async function importData(source: string): Promise<void> {
try {
const data = await fetchExternalData(source);
await processAndStore(data);
} catch (error) {
logger.error('Data import failed', { source, error });
throw error; // Or handle gracefully with fallback logic
}
}
6. missing-error-logging
A catch block that has statements but does not log or report the error. Only console.error, console.warn, and structured loggers (logger, winston, pino, bunyan, sentry, bugsnag, datadog, newrelic) count as error logging. Plain console.log is not sufficient.
Violation:
try {
await syncExternalSystem();
} catch (error) {
// Has code, but no logging -- error details are lost
failedCount++;
continue;
}
Fix:
try {
await syncExternalSystem();
} catch (error) {
logger.error('External sync failed', error);
failedCount++;
continue;
}
The detector does not flag catch blocks that re-throw the error (throw) or have meaningful side effects (increment counters, call this.method(), use await/setTimeout/sleep).
7. transaction-no-timeout
A prisma.$transaction() call without a timeout option. Without an explicit timeout, a long-running transaction holds database locks indefinitely, blocking other queries and potentially causing deadlocks.
Violation:
async function transferFunds(from: string, to: string, amount: number) {
await this.prisma.$transaction(async (tx) => {
await tx.account.update({
where: { id: from },
data: { balance: { decrement: amount } },
});
await tx.account.update({
where: { id: to },
data: { balance: { increment: amount } },
});
await tx.ledger.create({
data: { fromAccount: from, toAccount: to, amount },
});
});
// No timeout: if any step is slow, locks are held indefinitely
}
Fix:
async function transferFunds(from: string, to: string, amount: number) {
await this.prisma.$transaction(
async (tx) => {
await tx.account.update({
where: { id: from },
data: { balance: { decrement: amount } },
});
await tx.account.update({
where: { id: to },
data: { balance: { increment: amount } },
});
await tx.ledger.create({
data: { fromAccount: from, toAccount: to, amount },
});
},
{
timeout: 5000, // Abort after 5 seconds
maxWait: 2000, // Wait at most 2 seconds for a connection
isolationLevel: 'Serializable',
},
);
}
8. missing-null-guard
Property access on the result of findFirst(), findUnique(), or findOne() without checking for null first. These methods return null when no record matches, and accessing a property on null throws a TypeError at runtime.
Violation:
async function getOrderTotal(orderId: string): Promise<number> {
const order = await this.prisma.order.findFirst({
where: { id: orderId },
});
// order might be null -- this throws TypeError
return order.total;
}
Fix:
async function getOrderTotal(orderId: string): Promise<number> {
const order = await this.prisma.order.findFirst({
where: { id: orderId },
});
if (!order) {
throw new NotFoundException(`Order ${orderId} not found`);
}
return order.total;
}
The detector accepts these null-guard patterns:
if (!result)orif (result === null)before property access- Optional chaining:
result?.property - Ternary guards:
result ? result.property : default findUniqueOrThrow/findFirstOrThrowmethods (already throw on null)
Configuration
Reliability rules are configured in radar.yml under reliability_rules:
reliability_rules:
block:
- unhandled-promise-rejection
- external-call-no-timeout
- transaction-no-timeout
warn:
- missing-try-catch
- retry-without-backoff
- empty-catch-block
- missing-error-logging
- missing-null-guard
Scoring weights:
scoring:
reliability_critical: 5
reliability_warning: 3
reliability_fixed: -3 # Credit for fixing reliability issues
Exceptions
exceptions:
- rule: external-call-no-timeout
file: "src/health/**"
expires: "2026-06-01"
reason: "Health check endpoints have their own timeout at the load balancer level"
- rule: missing-try-catch
file: "src/scripts/**"
expires: "2026-12-31"
reason: "One-off migration scripts are fine with fail-fast behavior"