packages feed

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

{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}

{- |
Module      : Langchain.Resilience.CircuitBreaker
Description : Circuit breaker pattern for LLM provider failover and graceful degradation
Copyright   : (c) 2026 Tushar Adhatrao
License     : MIT
Maintainer  : Tushar Adhatrao <tusharadhatrao@gmail.com>
Stability   : experimental

Implements the Circuit Breaker pattern (Closed, Open, HalfOpen) to prevent cascading failures
when upstream LLM APIs or vector stores experience outages.
-}
module Langchain.Resilience.CircuitBreaker
  ( CircuitState (..)
  , CircuitBreakerConfig (..)
  , defaultCircuitConfig
  , CircuitBreaker (..)
  , newCircuitBreaker
  , getCircuitState
  , withCircuitBreaker
  ) where

import Control.Concurrent.STM
import Control.Monad.Except (MonadError, catchError, throwError)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Data.Aeson (FromJSON, ToJSON)
import Data.Text (Text)
import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime)
import GHC.Generics (Generic)

import Langchain.Core.Error (LangchainError, internalError)

-- | State of the circuit breaker
data CircuitState
  = CircuitClosed
  | CircuitOpen !UTCTime -- Timestamp when opened
  | CircuitHalfOpen
  deriving (Show, Eq, Generic, ToJSON, FromJSON)

-- | Configuration parameters for the circuit breaker
data CircuitBreakerConfig = CircuitBreakerConfig
  { failureThreshold :: !Int
  , resetTimeoutSec :: !Double
  }
  deriving (Show, Eq, Generic, ToJSON, FromJSON)

-- | Sensible default configuration (5 failures to open, 30 seconds reset timeout)
defaultCircuitConfig :: CircuitBreakerConfig
defaultCircuitConfig =
  CircuitBreakerConfig
    { failureThreshold = 5
    , resetTimeoutSec = 30.0
    }

-- | Circuit breaker handle backed by STM TVar
data CircuitBreaker = CircuitBreaker
  { circuitName :: !Text
  , circuitConfig :: !CircuitBreakerConfig
  , circuitStateVar :: !(TVar (CircuitState, Int)) -- (State, consecutive failures)
  }

-- | Construct a new CircuitBreaker
newCircuitBreaker :: MonadIO m => Text -> CircuitBreakerConfig -> m CircuitBreaker
newCircuitBreaker name cfg = liftIO $ do
  var <- newTVarIO (CircuitClosed, 0)
  pure $ CircuitBreaker name cfg var

-- | Query current state of the circuit breaker
getCircuitState :: MonadIO m => CircuitBreaker -> m CircuitState
getCircuitState CircuitBreaker {..} = liftIO $ do
  (st, _) <- readTVarIO circuitStateVar
  pure st

-- | Execute a protected action through the circuit breaker
withCircuitBreaker ::
  (MonadIO m, MonadError LangchainError m) =>
  CircuitBreaker ->
  m a ->
  m a
withCircuitBreaker CircuitBreaker {..} action = do
  now <- liftIO getCurrentTime
  canProceed <- liftIO $ atomically $ do
    (st, _) <- readTVar circuitStateVar -- Only the state is needed to gate; count is managed in the error handler below
    case st of
      CircuitClosed -> pure True
      CircuitHalfOpen -> pure True
      CircuitOpen openTime ->
        if diffUTCTime now openTime >= realToFrac (resetTimeoutSec circuitConfig)
          then do
            writeTVar circuitStateVar (CircuitHalfOpen, 0)
            pure True
          else pure False

  if not canProceed
    then
      throwError $
        internalError
          ("Circuit breaker '" <> circuitName <> "' is OPEN. Fast-failing request.")
          (Just circuitName)
          Nothing
    else do
      res <-
        action `catchError` \err -> do
          liftIO $ atomically $ do
            (st, fails) <- readTVar circuitStateVar
            let newFails = fails + 1
            if newFails >= failureThreshold circuitConfig
              then writeTVar circuitStateVar (CircuitOpen now, newFails)
              else writeTVar circuitStateVar (st, newFails)
          throwError err

      -- On success, reset circuit to closed and reset failure count
      liftIO $ atomically $ writeTVar circuitStateVar (CircuitClosed, 0)
      pure res