receta
Async

Retry and Timeout Patterns

Building resilient async operations that handle transient failures gracefully.

Table of Contents

  1. Retry Patterns
  2. Timeout Patterns
  3. Result Integration
  4. Combined Patterns
  5. Real-World Examples
  6. Best Practices
  7. Common Mistakes

Retry Patterns

Basic Retry with Exponential Backoff

The retry function automatically handles transient failures with exponential backoff. It runs your function again whenever it throws or rejects, and returns a Result:

import { retry } from '@ontologyio/receta/async'

// Basic retry with defaults (3 attempts, 1s initial delay, 2x backoff)
const fetchUser = async (id: string) => {
  return retry(async () => {
    const res = await fetch(`/api/users/${id}`)
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    return res.json()
  })
}

// Custom retry configuration
const fetchWithCustomRetry = async (url: string) => {
  return retry(
    async () => {
      const res = await fetch(url)
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      return res.json()
    },
    {
      maxAttempts: 5,        // Try up to 5 times
      delay: 500,            // Start with 500ms delay
      backoff: 2,            // Double delay each time
      maxDelay: 10000,       // Cap at 10 seconds
    }
  )
}

Delay progression with exponential backoff:

  • Attempt 1: Immediate
  • Attempt 2: 500ms delay
  • Attempt 3: 1000ms delay (500 × 2)
  • Attempt 4: 2000ms delay (1000 × 2)
  • Attempt 5: 4000ms delay (2000 × 2)

Adding jitter: when many clients fail at once (e.g. a rate limit), identical backoff makes them all retry at the same instant. Set jitter to add a uniform random 0..jitter ms to each delay, before the maxDelay cap:

await retry(() => callApi(), {
  delay: 500,
  backoff: 2,
  maxDelay: 10000,
  jitter: 250, // wait = min(500 × 2^n + random(0, 250), 10000)
})

onRetry receives the jittered delay that will actually be waited. The default is jitter: 0 (no randomness).

Conditional Retry with shouldRetry

Only retry specific types of errors:

// Retry only on network errors, not client errors
const fetchData = async (url: string) => {
  return retry(
    async () => {
      const res = await fetch(url)
      if (!res.ok) {
        const error: any = new Error(`HTTP ${res.status}`)
        error.status = res.status
        throw error
      }
      return res.json()
    },
    {
      maxAttempts: 3,
      shouldRetry: (error, attempt) => {
        // Don't retry client errors (4xx)
        if (error instanceof Error && 'status' in error) {
          const status = (error as any).status
          if (status >= 400 && status < 500) {
            return false
          }
        }
        // Retry server errors (5xx) and network errors
        return true
      }
    }
  )
}

// Retry based on specific error types
class NetworkError extends Error {
  constructor(message: string) {
    super(message)
    this.name = 'NetworkError'
  }
}

class RateLimitError extends Error {
  constructor(public resetAt: number) {
    super('Rate limit exceeded')
    this.name = 'RateLimitError'
  }
}

const smartRetry = async (operation: () => Promise<any>) => {
  return retry(operation, {
    maxAttempts: 5,
    shouldRetry: (error, attempt) => {
      // Always retry network errors
      if (error instanceof NetworkError) {
        return true
      }

      // Retry rate limits only on first few attempts
      if (error instanceof RateLimitError) {
        return attempt <= 2
      }

      // Don't retry other errors
      return false
    }
  })
}

Retry with Monitoring

Use the onRetry callback to log, monitor, or notify:

import { retry } from '@ontologyio/receta/async'

const fetchWithMonitoring = async (url: string) => {
  return retry(
    async () => {
      const res = await fetch(url)
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      return res.json()
    },
    {
      maxAttempts: 3,
      delay: 1000,
      onRetry: (error, attempt, delay) => {
        // Log retry attempts
        console.warn(`Retry attempt ${attempt} after ${delay}ms`, {
          url,
          error: error instanceof Error ? error.message : String(error),
          nextDelay: delay,
        })

        // Send metrics to monitoring service
        metrics.increment('api.retry', {
          url,
          attempt: String(attempt),
        })

        // Alert on multiple retries
        if (attempt >= 2) {
          alerts.send({
            severity: 'warning',
            message: `Multiple retry attempts for ${url}`,
            metadata: { attempt, error },
          })
        }
      }
    }
  )
}

Constant Delay (No Backoff)

For operations that need consistent retry intervals:

// Poll an endpoint every second
const pollStatus = async (jobId: string) => {
  return retry(
    async () => {
      const res = await fetch(`/api/jobs/${jobId}/status`)
      const data = await res.json()

      if (data.status === 'pending') {
        throw new Error('Job still pending')
      }

      return data
    },
    {
      maxAttempts: 10,
      delay: 1000,
      backoff: 1,  // No backoff, constant 1 second delay
    }
  )
}

Timeout Patterns

Basic Timeout

Add a timeout to any promise. You get a Result back:

import { timeout } from '@ontologyio/receta/async'
import { isErr, mapErr } from '@ontologyio/receta/result'

// Basic timeout
const fetchWithTimeout = async (url: string) => {
  const result = await timeout(
    fetch(url).then(r => r.json()),
    5000  // 5 second timeout
  )

  if (isErr(result)) {
    console.error('Request timed out after 5 seconds')
  }

  return result
}

// Custom timeout error
const fetchWithCustomError = async (url: string) => {
  const result = await timeout(fetch(url).then(r => r.json()), 3000)

  return mapErr(result, () => new Error(`Request to ${url} timed out`))
}

Two things to keep in mind:

  • Only the timeout becomes an Err. If the promise rejects first, timeout rejects with that error, so handle both when that can happen.
  • timeout stops waiting, but the operation keeps running. To stop it too, pass it an AbortSignal and abort when you get an Err.

Function-Level Timeout

Wrap functions to automatically apply timeouts. Receta has no wrapper for this, but timeout makes it short:

import { timeout } from '@ontologyio/receta/async'

const withTimeout = <Args extends unknown[], T>(
  fn: (...args: Args) => Promise<T>,
  ms: number
) => (...args: Args) => timeout(fn(...args), ms)

// Create a fetch function with built-in timeout
const fetchJSON = withTimeout(
  async (url: string) => {
    const res = await fetch(url)
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    return res.json()
  },
  5000  // All calls time out after 5 seconds
)

// Use it like a normal function that returns a Result
const user = await fetchJSON('/api/users/123')
const posts = await fetchJSON('/api/posts')

// Each call has its own 5-second timeout

Per-Operation Timeout

Different operations need different timeouts:

// Short timeout for critical data
const fetchCriticalData = withTimeout(
  async (id: string) => {
    const res = await fetch(`/api/critical/${id}`)
    return res.json()
  },
  2000  // 2 seconds max
)

// Longer timeout for uploads
const uploadFile = withTimeout(
  async (file: File) => {
    const formData = new FormData()
    formData.append('file', file)
    const res = await fetch('/api/upload', {
      method: 'POST',
      body: formData,
    })
    return res.json()
  },
  30000  // 30 seconds for large files
)

// Very long timeout for exports
const exportData = withTimeout(
  async (query: string) => {
    const res = await fetch('/api/export', {
      method: 'POST',
      body: JSON.stringify({ query }),
    })
    return res.json()
  },
  120000  // 2 minutes for complex exports
)

Handling TimeoutError

Distinguish timeout errors from other failures. tryCatchAsync catches what the operation rejects with, and flatten puts both kinds of failure in one Result:

import { timeout, TimeoutError } from '@ontologyio/receta/async'
import { flatten, isOk, tryCatchAsync } from '@ontologyio/receta/result'

const robustFetch = async (url: string) => {
  const result = flatten(
    await tryCatchAsync(() => timeout(fetch(url).then(r => r.json()), 5000))
  )

  if (isOk(result)) return result.value

  if (result.error instanceof TimeoutError) {
    // Timeout-specific handling
    console.error('Request timed out, using cached data')
    return getCachedData(url)
  }

  if (result.error instanceof TypeError && result.error.message.includes('fetch')) {
    // Network error
    console.error('Network error, retrying...')
    return robustFetch(url)  // Retry
  }

  // Other errors
  throw result.error
}

Result Integration

Why Result Pattern?

Hand-written retry and timeout helpers usually throw, making error handling verbose and error-prone. Receta's return errors as values:

// Traditional: Requires try-catch
try {
  const data = await fetchWithManualRetry('/api/data')
} catch (error) {
  // Error handling here
}

// Result: Errors as values
const result = await retry(() => fetch('/api/data'))
if (isOk(result)) {
  // Use result.value
} else {
  // Handle result.error
}

Benefits:

  • ✅ Errors visible in function signature
  • ✅ Composable with Result utilities (map, flatMap, unwrapOr)
  • ✅ No exceptions - pure functional error handling
  • ✅ Full error context (attempts, last error, etc.)

retry()

Type-safe retry that returns a Result instead of throwing exceptions.

Signature

Signature
function retry<T>(
  fn: () => Promise<T>,
  options?: RetryOptions
): Promise<Result<T, RetryError>>

type RetryError = {
  type: 'max_attempts_exceeded'
  lastError: unknown
  attempts: number
}

When shouldRetry returns false you get the same error, with attempts counting the tries so far.

Real-World: Database Connection with Result

import { retry } from '@ontologyio/receta/async'
import { unwrapOr, mapErr, isOk } from '@ontologyio/receta/result'
import * as R from 'remeda'

const connectToDatabase = async (): Promise<Database> => {
  const result = await retry(
    async () => {
      const db = await mongoose.connect(connectionString)
      return db
    },
    {
      maxAttempts: 5,
      delay: 1000,
      backoff: 2,
      onRetry: (error, attempt, delay) => {
        logger.warn(`DB connection attempt ${attempt} failed, retrying in ${delay}ms`)
      }
    }
  )

  return R.pipe(
    result,
    mapErr(error => {
      logger.error(`DB connection failed after ${error.attempts} attempts`)
      return error
    }),
    unwrapOr(fallbackDatabase)
  )
}

// No try-catch needed!
const db = await connectToDatabase()

Real-World: API Call with Error Recovery

import { retry } from '@ontologyio/receta/async'
import { match, ok } from '@ontologyio/receta/result'

const fetchUser = async (id: string) => {
  const result = await retry(
    async () => {
      const res = await fetch(`/api/users/${id}`)
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      return res.json()
    },
    {
      maxAttempts: 3,
      delay: 1000,
      backoff: 2,
    }
  )

  return match(result, {
    onOk: (user) => user,
    onErr: (error) => {
      // Graceful fallback
      logger.error('Failed to fetch user, using cache', {
        id,
        attempts: error.attempts,
        lastError: error.lastError
      })
      return getCachedUser(id)
    }
  })
}

Comparison: Traditional vs Result

AspectHand-written retry that throwsretry()
Error handlingtry-catch requiredExplicit Result type
Error visibilityHidden (throws)Visible in signature
ComposabilityLimitedFull Result API (map, flatMap, etc.)
Error contextLost in exceptionPreserved (attempts, lastError)
Type safety❌ Error type unknown✅ Result<T, RetryError>
Pure function❌ Throws exceptions✅ Returns values

Advanced: Combining Multiple Retries

import { retry } from '@ontologyio/receta/async'
import { isErr, isOk, map } from '@ontologyio/receta/result'

// Fetch user, then their posts (both with retry)
const fetchUserWithPosts = async (userId: string) => {
  const userResult = await retry(
    () => fetchUser(userId),
    { maxAttempts: 3, delay: 1000 }
  )

  if (isErr(userResult)) return userResult

  const postsResult = await retry(
    () => fetchPosts(userId),
    { maxAttempts: 3, delay: 500 }
  )

  return map(postsResult, (posts) => ({ user: userResult.value, posts }))
}

// Usage
const result = await fetchUserWithPosts('123')
if (isOk(result)) {
  console.log('User with posts:', result.value)
} else {
  console.error('Error:', result.error)
}

timeout()

Add a timeout with a Result for type-safe timeout handling.

Signature

Signature
function timeout<T>(
  promise: Promise<T>,
  ms: number
): Promise<Result<T, TimeoutError>>

class TimeoutError extends Error {
  constructor(message?: string)
}

Real-World: API Call with Timeout

import { timeout } from '@ontologyio/receta/async'
import { unwrapOr, mapErr, isErr } from '@ontologyio/receta/result'
import * as R from 'remeda'

const fetchWithTimeout = async (url: string) => {
  const result = await timeout(
    fetch(url).then(r => r.json()),
    5000
  )

  return R.pipe(
    result,
    mapErr(error => {
      logger.warn(`Request to ${url} failed: ${error.message}`)
      return error
    }),
    unwrapOr({ error: 'Request timed out' })
  )
}

Real-World: Multiple Services with Fallback

import { timeout, retry } from '@ontologyio/receta/async'
import { isErr, isOk, match } from '@ontologyio/receta/result'

// Try primary service, fall back to secondary
const fetchDataResilient = async (query: string) => {
  // Try primary service with timeout
  const primaryResult = await timeout(
    fetch(`https://primary-api.com/search?q=${query}`).then(r => r.json()),
    3000
  )

  if (isOk(primaryResult)) {
    return primaryResult.value
  }

  // Primary failed, try secondary with retry
  logger.warn('Primary service failed, trying secondary', {
    error: primaryResult.error
  })

  const secondaryResult = await retry(
    async () => {
      const res = await timeout(
        fetch(`https://secondary-api.com/search?q=${query}`).then(r => r.json()),
        5000
      )
      if (isErr(res)) throw res.error
      return res.value
    },
    { maxAttempts: 2, delay: 500 }
  )

  return match(secondaryResult, {
    onOk: (data) => data,
    onErr: (error) => {
      logger.error('Both services failed', {
        primary: primaryResult.error,
        secondary: error
      })
      return { error: 'All services unavailable' }
    }
  })
}

Comparison: Traditional vs Result

AspectHand-written timeout that throwstimeout()
Error handlingtry-catch requiredExplicit Result type
Timeout detectioninstanceof checkisErr
ComposabilityLimitedFull Result API
Type safety❌ Error type unknown✅ Result<T, TimeoutError>

Advanced: Combining Timeout and Retry with Result

import { retry, timeout } from '@ontologyio/receta/async'
import { isErr, isOk } from '@ontologyio/receta/result'

// Each retry attempt has its own timeout
const resilientFetch = async (url: string) => {
  return retry(
    async () => {
      const res = await timeout(
        fetch(url).then(r => r.json()),
        3000
      )

      // Convert timeout Result to exception for retry logic
      if (isErr(res)) throw res.error
      return res.value
    },
    {
      maxAttempts: 3,
      delay: 500,
      onRetry: (error, attempt, delay) => {
        logger.warn(`Retry ${attempt} after timeout`, { url, delay })
      }
    }
  )
}

// Usage
const result = await resilientFetch('https://api.example.com/data')
if (isOk(result)) {
  console.log('Data:', result.value)
} else {
  console.error('Failed after retries:', result.error)
}

Real-World: Microservice Circuit Breaker with Result

import { timeout, retry } from '@ontologyio/receta/async'
import { type Result, ok, err, isErr, isOk } from '@ontologyio/receta/result'

class ServiceClient {
  private circuitOpen = false
  private failures = 0
  private readonly threshold = 5

  async request<T>(
    endpoint: string,
    timeoutMs: number = 5000
  ): Promise<Result<T, string>> {
    // Check circuit breaker
    if (this.circuitOpen) {
      return err('Circuit breaker open - service unavailable')
    }

    const result = await retry(
      async () => {
        const res = await timeout(
          fetch(endpoint).then(r => r.json()),
          timeoutMs
        )

        if (isErr(res)) throw res.error
        return res.value as T
      },
      { maxAttempts: 2, delay: 500 }
    )

    if (isOk(result)) {
      // Reset on success
      this.failures = 0
      return ok(result.value)
    }

    // Track failures
    this.failures++
    if (this.failures >= this.threshold) {
      this.circuitOpen = true
      setTimeout(() => {
        this.circuitOpen = false
        this.failures = 0
      }, 30000) // Reset after 30s
    }

    return err(`Service error: ${result.error.lastError}`)
  }
}

// Usage
const client = new ServiceClient()

const result = await client.request('/api/users/123', 3000)
if (isOk(result)) {
  console.log('User:', result.value)
} else {
  console.error('Error:', result.error)
  // No exception thrown - safe to continue
}

Combined Patterns

Retry with Timeout

Combine retry and timeout for maximum resilience. retry only runs again when the function throws, and timeout reports a timeout as an Err, so unwrap turns that Err back into a throw:

import { retry, timeout } from '@ontologyio/receta/async'
import { unwrap } from '@ontologyio/receta/result'

// Each retry attempt has a timeout
const resilientFetch = async (url: string) => {
  return retry(
    async () => {
      // Each attempt times out after 5 seconds
      return unwrap(await timeout(
        fetch(url).then(r => r.json()),
        5000
      ))
    },
    {
      maxAttempts: 3,
      delay: 1000,
    }
  )
}

// More sophisticated: increase timeout with each retry
const adaptiveRetry = async (url: string) => {
  let attempt = 0

  return retry(
    async () => {
      attempt++
      const timeoutMs = 3000 + (attempt * 2000)  // 5s, 7s, 9s

      return unwrap(await timeout(
        fetch(url).then(r => r.json()),
        timeoutMs
      ))
    },
    {
      maxAttempts: 3,
      delay: 1000,
    }
  )
}

Circuit Breaker Pattern

Prevent cascading failures by stopping requests after repeated failures:

import { timeout } from '@ontologyio/receta/async'
import { unwrap } from '@ontologyio/receta/result'

class CircuitBreaker {
  private failures = 0
  private lastFailureTime = 0
  private state: 'closed' | 'open' | 'half-open' = 'closed'

  constructor(
    private threshold: number = 5,
    private timeout: number = 60000,  // 1 minute
    private resetTimeout: number = 30000  // 30 seconds
  ) {}

  async execute<T>(operation: () => Promise<T>): Promise<T> {
    // Check if circuit is open
    if (this.state === 'open') {
      const timeSinceFailure = Date.now() - this.lastFailureTime

      if (timeSinceFailure < this.resetTimeout) {
        throw new Error('Circuit breaker is open')
      }

      // Try to close circuit
      this.state = 'half-open'
    }

    try {
      // Execute with timeout
      const result = unwrap(await timeout(operation(), this.timeout))

      // Success - reset failures
      if (this.state === 'half-open') {
        this.state = 'closed'
        this.failures = 0
      }

      return result
    } catch (error) {
      this.failures++
      this.lastFailureTime = Date.now()

      // Open circuit if threshold exceeded
      if (this.failures >= this.threshold) {
        this.state = 'open'
      }

      throw error
    }
  }
}

// Usage
const breaker = new CircuitBreaker()

const fetchWithBreaker = async (url: string) => {
  return breaker.execute(async () => {
    const res = await fetch(url)
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    return res.json()
  })
}

Retry with Rate Limit Handling

Handle API rate limits intelligently. retry doesn't wait for onRetry, so honour the limit inside the function, before it throws:

import { retry, sleep } from '@ontologyio/receta/async'

const fetchWithRateLimit = async (url: string) => {
  return retry(
    async () => {
      const res = await fetch(url)

      // Check rate limit headers
      if (res.status === 429) {
        const resetTime = res.headers.get('X-RateLimit-Reset')
        const retryAfter = res.headers.get('Retry-After')

        // Wait for rate limit reset if available
        if (retryAfter) {
          await sleep(parseInt(retryAfter) * 1000)
        } else if (resetTime) {
          await sleep(Math.max(0, parseInt(resetTime) * 1000 - Date.now()))
        }

        throw new Error('Rate limited')
      }

      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      return res.json()
    },
    {
      maxAttempts: 3,
      shouldRetry: (error, attempt) => {
        // Only retry rate limit errors
        return error instanceof Error && error.message === 'Rate limited'
      }
    }
  )
}

Real-World Examples

Fetching from Unreliable APIs

import { retry, timeout } from '@ontologyio/receta/async'
import { unwrap } from '@ontologyio/receta/result'

// Third-party API that's often slow or down
const fetchWeatherData = async (city: string) => {
  return retry(
    async () => {
      return unwrap(await timeout(
        fetch(`https://api.weather.com/v1/current?city=${city}`).then(async res => {
          if (!res.ok) {
            const error: any = new Error(`Weather API error: ${res.status}`)
            error.status = res.status
            throw error
          }
          return res.json()
        }),
        10000  // 10 second timeout per attempt
      ))
    },
    {
      maxAttempts: 3,
      delay: 2000,
      backoff: 2,
      shouldRetry: (error, attempt) => {
        // Don't retry client errors
        if (error instanceof Error && 'status' in error) {
          const status = (error as any).status
          if (status >= 400 && status < 500) return false
        }

        // Retry timeouts and server errors
        return true
      },
      onRetry: (error, attempt, delay) => {
        console.warn(`Weather API retry ${attempt}, waiting ${delay}ms`, {
          city,
          error: error instanceof Error ? error.message : String(error),
        })
      }
    }
  )
}

Database Connection with Retry

import { retry, type RetryError } from '@ontologyio/receta/async'
import { type Result, unwrap } from '@ontologyio/receta/result'
import { createConnection, Connection } from 'mysql2/promise'

class DatabasePool {
  private connection: Connection | null = null

  async getConnection(): Promise<Connection> {
    if (this.connection) return this.connection

    const result = await retry(
      async () => {
        console.log('Attempting database connection...')
        return createConnection({
          host: process.env.DB_HOST,
          user: process.env.DB_USER,
          password: process.env.DB_PASSWORD,
          database: process.env.DB_NAME,
        })
      },
      {
        maxAttempts: 5,
        delay: 1000,
        backoff: 2,
        maxDelay: 10000,
        onRetry: (error, attempt, delay) => {
          console.error(`Database connection failed (attempt ${attempt}), retrying in ${delay}ms...`, {
            error: error instanceof Error ? error.message : String(error),
          })
        }
      }
    )

    // Nothing works without a database, so fail loudly here
    this.connection = unwrap(result)
    return this.connection
  }

  async query<T>(sql: string, params?: any[]): Promise<Result<T, RetryError>> {
    const conn = await this.getConnection()

    return retry(
      async () => {
        const [rows] = await conn.execute(sql, params)
        return rows as T
      },
      {
        maxAttempts: 3,
        delay: 100,
        shouldRetry: (error) => {
          // Retry on connection errors
          return error instanceof Error &&
                 (error.message.includes('ECONNREFUSED') ||
                  error.message.includes('ETIMEDOUT'))
        }
      }
    )
  }
}

S3 Upload with Retry

import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
import { retry, timeout } from '@ontologyio/receta/async'
import { unwrap } from '@ontologyio/receta/result'

const s3Client = new S3Client({ region: 'us-east-1' })

const uploadToS3 = async (
  bucket: string,
  key: string,
  body: Buffer | ReadableStream,
  contentType: string
) => {
  return retry(
    async () => {
      return unwrap(await timeout(
        s3Client.send(new PutObjectCommand({
          Bucket: bucket,
          Key: key,
          Body: body,
          ContentType: contentType,
        })),
        60000  // 1 minute timeout for uploads
      ))
    },
    {
      maxAttempts: 3,
      delay: 2000,
      backoff: 2,
      shouldRetry: (error, attempt) => {
        // Retry on network errors and throttling
        if (error instanceof Error) {
          const message = error.message.toLowerCase()
          return message.includes('network') ||
                 message.includes('timed out') ||
                 message.includes('throttl') ||
                 message.includes('slowdown')
        }
        return false
      },
      onRetry: (error, attempt, delay) => {
        console.warn(`S3 upload retry ${attempt} for ${key}`, {
          error: error instanceof Error ? error.message : String(error),
          delay,
        })
      }
    }
  )
}

// Usage
const result = await uploadToS3(
  'my-bucket',
  'uploads/avatar.jpg',
  fileBuffer,
  'image/jpeg'
)

Webhook Delivery with Timeout and Retry

import { mapAsync, retry, timeout, TimeoutError } from '@ontologyio/receta/async'
import { partition, unwrap, unwrapOr } from '@ontologyio/receta/result'

interface WebhookPayload {
  event: string
  data: any
  timestamp: number
}

interface WebhookEndpoint {
  url: string
  secret: string
}

const deliverWebhook = async (
  endpoint: WebhookEndpoint,
  payload: WebhookPayload
) => {
  return retry(
    async () => {
      return unwrap(await timeout(
        fetch(endpoint.url, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'X-Webhook-Secret': endpoint.secret,
            'X-Webhook-Event': payload.event,
          },
          body: JSON.stringify(payload),
        }).then(async res => {
          if (!res.ok) {
            throw new Error(`Webhook delivery failed: HTTP ${res.status}`)
          }
          return res
        }),
        10000  // 10 second timeout for webhook delivery
      ))
    },
    {
      maxAttempts: 3,
      delay: 5000,   // 5 second initial delay
      backoff: 2,
      shouldRetry: (error, attempt) => {
        // Don't retry on client errors (4xx)
        if (error instanceof Error && error.message.includes('HTTP 4')) {
          return false
        }

        // Retry on timeouts and server errors
        return true
      },
      onRetry: (error, attempt, delay) => {
        // Log failed webhook attempts
        console.error(`Webhook delivery failed (attempt ${attempt})`, {
          url: endpoint.url,
          event: payload.event,
          error: error instanceof Error ? error.message : String(error),
          nextRetry: `${delay}ms`,
        })

        // Store failed attempt for monitoring
        webhookMetrics.recordFailure({
          url: endpoint.url,
          event: payload.event,
          attempt,
          error: error instanceof TimeoutError ? 'timeout' : 'error',
        })
      }
    }
  )
}

// Batch webhook delivery
const deliverWebhooks = async (
  endpoints: WebhookEndpoint[],
  payload: WebhookPayload
) => {
  // deliverWebhook returns a Result, so the map itself can't fail
  const outcome = await mapAsync(endpoints, (endpoint) =>
    deliverWebhook(endpoint, payload)
  )
  const results = unwrapOr(outcome, [])

  const [delivered, failed] = partition(results)

  console.log(`Webhook delivery: ${delivered.length}/${endpoints.length} successful`)

  if (failed.length > 0) {
    console.error('Failed webhooks:', failed.map(({ lastError }) => lastError))
  }

  return {
    successful: delivered.length,
    failed: failed.length,
    results,
  }
}

Microservice Communication

import { retry, timeout, type RetryError } from '@ontologyio/receta/async'
import { isOk, type Result, unwrap } from '@ontologyio/receta/result'

class ServiceClient {
  constructor(
    private baseUrl: string,
    private serviceName: string
  ) {}

  async request<T>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<Result<T, RetryError>> {
    const url = `${this.baseUrl}${endpoint}`

    return retry(
      async () => {
        return unwrap(await timeout(
          fetch(url, {
            ...options,
            headers: {
              'Content-Type': 'application/json',
              'X-Service-Name': this.serviceName,
              ...options.headers,
            },
          }).then(async res => {
            if (!res.ok) {
              const error: any = new Error(
                `Service ${this.serviceName} error: ${res.status}`
              )
              error.status = res.status
              error.service = this.serviceName
              throw error
            }
            return (await res.json()) as T
          }),
          5000  // 5 second timeout per request
        ))
      },
      {
        maxAttempts: 3,
        delay: 1000,
        backoff: 1.5,
        shouldRetry: (error, attempt) => {
          if (error instanceof Error && 'status' in error) {
            const status = (error as any).status
            // Don't retry 4xx errors except 429 (rate limit)
            if (status >= 400 && status < 500 && status !== 429) {
              return false
            }
          }
          return true
        },
        onRetry: (error, attempt, delay) => {
          console.warn(`Service ${this.serviceName} retry ${attempt}`, {
            endpoint,
            error: error instanceof Error ? error.message : String(error),
            delay,
          })

          // Report service issues
          metrics.increment(`service.${this.serviceName}.retry`, {
            endpoint,
            attempt: String(attempt),
          })
        }
      }
    )
  }
}

// Usage
const userService = new ServiceClient('http://users-service', 'user-service')
const orderService = new ServiceClient('http://orders-service', 'order-service')

const user = await userService.request<User>('/api/users/123', { method: 'GET' })

if (isOk(user)) {
  const orders = await orderService.request('/api/orders', {
    method: 'POST',
    body: JSON.stringify({ userId: user.value.id }),
  })
}

Best Practices

1. Choose Appropriate Retry Limits

// Don't retry forever
const bad = await retry(operation, {
  maxAttempts: 100,  // Too many
  delay: 100,
})

// Use reasonable limits based on operation type
const good = await retry(operation, {
  maxAttempts: 3,    // Good for most operations
  delay: 1000,
})

// Higher limits for critical operations
const critical = await retry(criticalOperation, {
  maxAttempts: 5,
  delay: 2000,
  maxDelay: 30000,
})

2. Use Exponential Backoff for External APIs

// Good: Exponential backoff reduces server load
const data = await retry(
  () => fetch('https://api.example.com/data'),
  {
    maxAttempts: 3,
    delay: 1000,
    backoff: 2,      // Delays: 1s, 2s, 4s
    maxDelay: 10000,
  }
)

// Bad: Constant retry hammers the server
const data = await retry(
  () => fetch('https://api.example.com/data'),
  {
    maxAttempts: 10,
    delay: 100,
    backoff: 1,  // Always 100ms - can overload server
  }
)

3. Set Timeouts Based on Operation Type

// Each of these returns a Result
// Fast operations: short timeout
const cache = await timeout(redis.get(key), 500)

// API calls: medium timeout
const data = await timeout(fetch('/api/data'), 5000)

// File uploads: long timeout
const upload = await timeout(uploadFile(largeFile), 60000)

// Background jobs: very long timeout
const report = await timeout(generateReport(), 300000)

4. Always Check the Result of timeout

import { timeout } from '@ontologyio/receta/async'
import { isOk } from '@ontologyio/receta/result'

// Good: Specific timeout handling
const loadData = async () => {
  const result = await timeout(operation(), 5000)
  return isOk(result) ? result.value : getCachedData()
}

// Bad: Passing the Result on as if it were the data
const loadDataUnchecked = async () => {
  const data = await timeout(operation(), 5000)
  return data  // Hides that this may be a timeout
}

5. Log Retry Attempts

// Good: Monitor retry patterns
const data = await retry(operation, {
  maxAttempts: 3,
  onRetry: (error, attempt, delay) => {
    logger.warn('Retry attempt', {
      operation: 'fetchUserData',
      attempt,
      delay,
      error: error instanceof Error ? error.message : String(error),
    })
  }
})

// Even better: Send metrics
const data = await retry(operation, {
  maxAttempts: 3,
  onRetry: (error, attempt, delay) => {
    metrics.increment('operation.retry', {
      operation: 'fetchUserData',
      attempt: String(attempt),
    })
  }
})

6. Prefer Result Pattern for Error Handling

import { retry, timeout } from '@ontologyio/receta/async'
import { type Result, isErr, isOk, mapErr } from '@ontologyio/receta/result'

// Good: Result pattern - errors as values
const fetchUserSafe = async (id: string): Promise<Result<User, string>> => {
  const result = await retry(
    async () => {
      const res = await timeout(
        fetch(`/api/users/${id}`).then(r => r.json()),
        5000
      )
      if (isErr(res)) throw res.error
      return res.value
    },
    { maxAttempts: 3 }
  )

  return mapErr(result, error => String(error.lastError))
}

// Caller doesn't need try-catch
const result = await fetchUserSafe('123')
if (isOk(result)) {
  console.log('User:', result.value)
} else {
  console.error('Failed:', result.error)
}

// Bad: Throwing the error again means try-catch everywhere
const fetchUserUnsafe = async (id: string): Promise<User> => {
  const result = await retry(
    () => fetch(`/api/users/${id}`).then(r => r.json()),
    { maxAttempts: 3 }
  )

  if (isErr(result)) throw result.error
  return result.value
}

Benefits of Result Pattern:

  • ✅ No try-catch needed at call sites
  • ✅ Errors visible in function signature
  • ✅ Composable with other Result operations
  • ✅ Full error context preserved
  • ✅ Type-safe error handling

Common Mistakes

1. Not Limiting Retry Attempts

// Bad: Infinite retry loop
const badRetry = async () => {
  while (true) {
    try {
      return await operation()
    } catch {
      await sleep(1000)
      // Continues forever!
    }
  }
}

// Good: Use retry with limits
const goodRetry = async () => {
  return retry(operation, { maxAttempts: 3 })
}

2. Retrying Non-Idempotent Operations

// Bad: Retrying payment processing can charge multiple times
const badPayment = async (userId: string, amount: number) => {
  return retry(
    () => stripe.charges.create({ customer: userId, amount }),
    { maxAttempts: 3 }
  )
}

// Good: Make operation idempotent with request ID
const goodPayment = async (userId: string, amount: number) => {
  const idempotencyKey = `payment-${userId}-${Date.now()}`

  return retry(
    () => stripe.charges.create({
      customer: userId,
      amount,
      idempotency_key: idempotencyKey  // Prevents duplicate charges
    }),
    { maxAttempts: 3 }
  )
}

3. Too Short Timeouts

// Bad: Timeout is too short for the operation
const badTimeout = await timeout(
  fetch('/api/generate-report'),  // Takes 10+ seconds
  1000  // Times out too quickly
)

// Good: Timeout matches operation duration
const goodTimeout = await timeout(
  fetch('/api/generate-report'),
  30000  // Reasonable for report generation
)

4. Ignoring Retry Callbacks

// Bad: Silent retries make debugging hard
const data = await retry(operation, { maxAttempts: 5 })

// Good: Log retries for visibility
const data = await retry(operation, {
  maxAttempts: 5,
  onRetry: (error, attempt, delay) => {
    console.warn(`Retry ${attempt}/${5}:`, error)
  }
})

5. Not Using shouldRetry

// Bad: Retrying client errors wastes time
const bad = await retry(
  async () => {
    const res = await fetch('/api/data')
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    return res.json()
  },
  { maxAttempts: 3 }
)
// Will retry on 404, 400, etc. - pointless!

// Good: Only retry retryable errors
const good = await retry(
  async () => {
    const res = await fetch('/api/data')
    if (!res.ok) {
      const error: any = new Error(`HTTP ${res.status}`)
      error.status = res.status
      throw error
    }
    return res.json()
  },
  {
    maxAttempts: 3,
    shouldRetry: (error) => {
      if (error instanceof Error && 'status' in error) {
        const status = (error as any).status
        return status >= 500 || status === 429
      }
      return true
    }
  }
)

6. Retrying Inside Loops

// Bad: The outer retry does nothing
const bad = async (ids: string[]) => {
  return retry(async () => {
    return mapAsync(ids, (id) => retry(() => fetch(`/api/items/${id}`)))
  })
}
// The inner retry returns a Result instead of throwing,
// so the outer retry never sees a failure

// Good: Retry at the appropriate level
const good = async (ids: string[]) => {
  return mapAsync(ids, (id) => retry(() => fetch(`/api/items/${id}`)))
}

7. Not Handling Timeout vs Operation Errors

// Bad: A timeout is an Err, so it never reaches the catch
try {
  await timeout(operation(), 5000)
} catch (error) {
  console.error('Failed')  // Only the operation's own errors land here
}

// Good: Put both failures in one Result, then tell them apart
const result = flatten(
  await tryCatchAsync(() => timeout(operation(), 5000))
)

if (isErr(result)) {
  if (result.error instanceof TimeoutError) {
    console.error('Operation timed out - might be slow network')
  } else {
    console.error('Operation failed:', result.error)
  }
} else {
  console.log('Success:', result.value)
}

8. Mixing Exception and Result Patterns

// Bad: Inconsistent error handling
const fetchData = async (url: string) => {
  const result = await retry(() => fetch(url), { maxAttempts: 3 })

  if (isErr(result)) {
    throw new Error('Fetch failed')  // Breaks Result pattern!
  }

  return result.value
}

// Good: Stay consistent with Result pattern
const fetchData = async (url: string): Promise<Result<Data, string>> => {
  const result = await retry(
    () => fetch(url).then(r => r.json()),
    { maxAttempts: 3 }
  )

  return mapErr(result, error =>
    `Failed after ${error.attempts} attempts: ${error.lastError}`
  )
}

// Or: Stay consistent with exceptions
const fetchData = async (url: string): Promise<Data> => {
  const result = await retry(
    () => fetch(url).then(r => r.json()),
    { maxAttempts: 3 }
  )

  return unwrap(result)  // Throws, so exceptions propagate
}

When to Use Result Pattern

retry() and timeout() always return a Result. Keep working with it when:

✅ Building APIs where callers need to handle errors gracefully ✅ Composing multiple async operations with error handling ✅ You want errors to be visible in function signatures ✅ You need detailed error context (attempts, last error) ✅ Building functional pipelines with pipe()

Call unwrap() on it to get an exception instead when:

✅ Working with existing exception-based code ✅ Simple scripts where try-catch is acceptable ✅ You want operations to fail fast with exceptions ✅ Integration with libraries that expect exceptions

Recommendation: Prefer Result for application code, and unwrap at the edges of scripts and tools.


Next Steps

On this page