packages feed

langchain-hs-0.0.5.0: src/Langchain/Resilience/Retry.hs

{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE RecordWildCards #-}

{- |
Module      : Langchain.Resilience.Retry
Description : Retry policies with exponential backoff and token-bucket rate limiting
Copyright   : (c) 2025-2026 Tushar Adhatrao
License     : MIT
Maintainer  : Tushar Adhatrao <tusharadhatrao@gmail.com>
Stability   : experimental

Resilience combinators for network calls and LLM provider invocations.
-}
module Langchain.Resilience.Retry
  ( RetryPolicy (..)
  , defaultRetryPolicy
  , withRetry
  , RateLimiter (..)
  , newRateLimiter
  , withRateLimit
  ) where

import Control.Concurrent (threadDelay)
import Control.Concurrent.STM
import Control.Monad (when)
import Control.Monad.Except (MonadError, catchError, throwError)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Data.Time.Clock
import System.Random (randomRIO)

import Langchain.Core.Error (LangchainError)

-- | Exponential backoff retry policy
data RetryPolicy = RetryPolicy
  { maxRetries :: !Int
  , baseDelayMicros :: !Int
  , maxDelayMicros :: !Int
  , useJitter :: !Bool
  }
  deriving (Show, Eq)

-- | Default retry policy (3 retries, base 50ms, max 2s, with jitter)
defaultRetryPolicy :: RetryPolicy
defaultRetryPolicy =
  RetryPolicy
    { maxRetries = 3
    , baseDelayMicros = 50000
    , maxDelayMicros = 2000000
    , useJitter = True
    }

-- | Execute an action with retry according to RetryPolicy on LangchainError
withRetry ::
  (MonadIO m, MonadError LangchainError m) =>
  RetryPolicy ->
  m a ->
  m a
withRetry policy action = go (maxRetries policy) (baseDelayMicros policy)
  where
    go retriesLeft currentDelay =
      action `catchError` \err ->
        if retriesLeft <= 0
          then throwError err
          else do
            delayWithJitter <-
              if useJitter policy
                then liftIO $ randomRIO (currentDelay `div` 2, currentDelay)
                else pure currentDelay
            liftIO $ threadDelay delayWithJitter
            let nextDelay = min (maxDelayMicros policy) (currentDelay * 2)
            go (retriesLeft - 1) nextDelay

-- | Token bucket rate limiter backed by STM TVars
data RateLimiter = RateLimiter
  { bucketCapacity :: !Double
  , refillRatePerSec :: !Double
  , tokensVar :: !(TVar Double)
  , lastRefillVar :: !(TVar UTCTime)
  }

-- | Construct a new Token Bucket RateLimiter (e.g. capacity = 10 tokens, refill = 5 tokens/sec)
newRateLimiter :: MonadIO m => Double -> Double -> m RateLimiter
newRateLimiter cap rate = liftIO $ do
  now <- getCurrentTime
  tVar <- newTVarIO cap
  rVar <- newTVarIO now
  pure $ RateLimiter cap rate tVar rVar

-- | Execute an action subject to token-bucket rate limiting (blocks if bucket empty)
withRateLimit :: (MonadIO m) => RateLimiter -> m a -> m a
withRateLimit RateLimiter {..} action = do
  liftIO $ do
    waitForToken
  action
  where
    waitForToken = do
      now <- getCurrentTime
      waitNeeded <- atomically $ do
        lastTime <- readTVar lastRefillVar
        tokens <- readTVar tokensVar
        let elapsedSecs = realToFrac (diffUTCTime now lastTime) :: Double
            refilledTokens = min bucketCapacity (tokens + elapsedSecs * refillRatePerSec)
        if refilledTokens >= 1.0
          then do
            writeTVar tokensVar (refilledTokens - 1.0)
            writeTVar lastRefillVar now
            pure (0 :: Int)
          else do
            let deficit = 1.0 - refilledTokens
                sleepSecs = deficit / refillRatePerSec
            pure (ceiling (sleepSecs * 1000000) :: Int)
      when (waitNeeded > 0) $ do
        threadDelay waitNeeded
        waitForToken