analysis

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 IDDescriptionDefault Severity
unhandled-promise-rejectionawait without try/catch or fire-and-forget async callConfigurable
missing-try-catchAsync function with multiple awaits but no try/catchConfigurable
external-call-no-timeoutfetch()/axios/got without timeout optionConfigurable
retry-without-backoffRetry loop without exponential backoffConfigurable
empty-catch-blockcatch block with no statementsConfigurable
missing-error-loggingcatch block that swallows the error without loggingConfigurable
transaction-no-timeoutprisma.$transaction() without timeout optionConfigurable
missing-null-guardProperty access after findFirst/findUnique without null checkConfigurable

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 @Injectable services -- not flagged (exception filter catches it)
  • Fire-and-forget promise in @Injectable services -- still flagged (exception filter cannot catch unawaited promises)
  • await without try/catch in @Cron/@Process methods -- 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, and timeout keywords in the surrounding scope. If you set up an AbortController earlier in the function, the fetch() call is not flagged even if the signal is 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) or 2 ** attempt (exponential)
  • attempt * delay or delay * attempt (linear)
  • await sleep() or await 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) or if (result === null) before property access
  • Optional chaining: result?.property
  • Ternary guards: result ? result.property : default
  • findUniqueOrThrow / findFirstOrThrow methods (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"
Technical Debt Radar Documentation