packages feed

natskell-1.1.0.0: client/Client/Implementation.hs

{-# LANGUAGE LambdaCase        #-}
{-# LANGUAGE OverloadedStrings #-}

-- | High-level client implementation for NATS.
module Client.Implementation
  ( Server
  , ServerConfigError (..)
  , server
  , serverWithDefaultPort
  , serverHost
  , serverPort
  , connect
  , newClient
  , ConfigOption
  , withConnectName
  , withEcho
  , withAuthToken
  , withAuthTokenHandler
  , withUserPass
  , withUserPassHandler
  , withNKey
  , withNKeyHandler
  , withJWT
  , withJWTHandlers
  , withTLS
  , withTLSCert
  , withTLSRootCA
  , withTLSServerName
  , withTLSInsecure
  , withMinimumLogLevel
  , withLogAction
  , withConnectionAttempts
  , withConnectTimeoutMicros
  , withCallbackConcurrency
  , withMessageLimit
  , withPendingDeliveryLimits
  , withErrorHandler
  , withServerErrorHandler
  , withConnectionEventHandler
  , withBufferLimit
  , withExitAction
  , LogLevel (..)
  , LogEntry (..)
  , renderLogEntry
  , AuthTokenData
  , AuthTokenHandler
  , UserPassData
  , UserPassHandler
  , NKeyData
  , NKeyPublicKey
  , JWTTokenData
  , JWTHandler
  , SignatureHandler
  , AuthError (..)
  , TLSPublicKey
  , TLSPrivateKey
  , TLSCertData
  , TLSConfig (..)
  , ClientExitReason (..)
  , ConnectionEvent (..)
  , ServerError
  , ServerErrorKind (..)
  , serverErrorReason
  , serverErrorKind
  , ConnectError (..)
  , ConnectAttemptError (..)
  , ConnectFailure (..)
  ) where

import           Auth.Config              (authMethods, mergeAuth)
import qualified Auth.Jwt                 as AuthJwt
import qualified Auth.NKey                as AuthNKey
import qualified Auth.None                as AuthNone
import qualified Auth.Token               as AuthToken
import           Auth.Types
    ( Auth
    , AuthError (..)
    , AuthTokenData
    , AuthTokenHandler
    , JWTHandler
    , JWTTokenData
    , NKeyData
    , NKeyPublicKey
    , SignatureHandler
    , UserPassData
    , UserPassHandler
    )
import qualified Auth.UserPass            as AuthUserPass
import           Client.API
    ( Client (..)
    , CloseConfig (..)
    , FlushConfig (..)
    , Message (..)
    , NatsError (..)
    , PingConfig (..)
    , RequestConfig (..)
    , ResetConfig (..)
    , Subscription (..)
    , UnsubscribeConfig (..)
    )
import           Control.Concurrent
    ( forkIOWithUnmask
    , killThread
    , myThreadId
    )
import           Control.Concurrent.STM
import           Control.Exception
    ( SomeException
    , finally
    , mask
    , mask_
    , onException
    )
import           Control.Monad            (forM_, void, when)
import qualified Data.ByteString          as BS
import qualified Data.ByteString.Char8    as BC
import           Data.Char                (isAsciiUpper)
import           Data.Maybe               (fromMaybe, isJust)
import           Data.Time.Clock          (NominalDiffTime)
import           Data.Version             (showVersion)
import           Engine
    ( closeClient
    , interruptConnection
    , resetClient
    , runEngine
    )
import           Lib.CallOption           (CallOption, applyCallOptions)
import           Lib.Logger
    ( LogEntry (..)
    , LogLevel (..)
    , LoggerConfig (..)
    , MonadLogger (..)
    , defaultLogger
    , newLogContext
    , renderLogEntry
    )
import           Network.Connection       (connectionApi)
import           Network.ConnectionAPI    (ConnectionAPI, newConn)
import qualified Network.ConnectionAPI    as Connection
import           Parser.Attoparsec        (parserApiWithMessageLimit)
import qualified Paths_natskell           as Package
import           Pipeline.Broadcasting    (broadcastingApi)
import           Pipeline.Streaming       (streamingApi)
import           Publish                  (defaultPublishConfig)
import           Publish.Config           (PublishConfig (..))
import           Queue.API
    ( QueueItem (QueueBatch, QueueConnectionScoped, QueueItem, QueuePayloadBound)
    , TryEnqueueResult (..)
    )
import           Queue.TransactionalQueue (newQueue)
import           State.Store
    ( ClientState
    , PingResult (..)
    , PublishEnqueueResult (..)
    , config
    , enqueue
    , enqueueOnGenerationTracked
    , enqueuePublishOnConnected
    , enqueuePublishOnConnectedGenerationTracked
    , isManagedThread
    , markClosed
    , newClientState
    , nextInbox
    , nextSid
    , readConnectionGeneration
    , readStatus
    , registerManagedThread
    , registerPingWaiterAndEnqueue
    , runClient
    , setClosing
    , setConnectName
    , startCallbackWorker
    , stopCallbackWorker
    , tryEnqueue
    , tryEnqueueOnGeneration
    , unregisterManagedThread
    , waitForCallbackWorker
    , waitForClosed
    , waitForConnected
    , waitForConnectionGenerationLoss
    , waitForInitialConnection
    , waitForNotRunning
    , withSubscriptionGate
    )
import           State.Types
    ( ClientConfig (..)
    , ClientExitReason (..)
    , ConnectAttemptError (..)
    , ConnectError (..)
    , ConnectFailure (..)
    , ConnectionEvent (..)
    , ConnectionState (..)
    , ServerError
    , ServerErrorKind (..)
    , TLSCertData
    , TLSConfig (..)
    , TLSPrivateKey
    , TLSPublicKey
    , defaultTLSConfig
    , serverErrorKind
    , serverErrorReason
    )
import           Subscription.Store
    ( SubscriptionStore
    , awaitCallbackDrain
    , awaitNoTrackedExpiries
    , closeStore
    , enqueueControl
    , hasTrackedExpiries
    , newSubscriptionStore
    , register
    , registerWithDispatchHooks
    , startExpiryWorker
    , startWorkers
    , unregister
    )
import           Subscription.Types
    ( PendingLimits (..)
    , SubscribeConfig (..)
    , SubscriptionKind (..)
    , SubscriptionMeta (SubscriptionMeta)
    , defaultPendingLimits
    , isOneShotSubscription
    )
import           System.Timeout           (timeout)
import qualified Types.Connect            as Connect
import qualified Types.Msg                as Msg
import           Types.Ping               (Ping (..))
import qualified Types.Pub                as Pub
import qualified Types.Sub                as Sub
import qualified Types.Unsub              as Unsub
import           Validators.Validators    (validate)

data ClientOptions = ClientOptions
                       { optionConnectConfig :: Connect.Connect
                       , optionAuth :: Auth
                       , optionTlsConfig :: Maybe TLSConfig
                       , optionLoggerConfig :: LoggerConfig
                       , optionConnectionAttempts :: Int
                       , optionConnectTimeoutMicros :: Int
                       , optionCallbackConcurrency :: Int
                       , optionMessageLimit :: Int
                       , optionPendingLimits :: PendingLimits
                       , optionErrorHandler :: NatsError -> IO ()
                       , optionServerErrorHandler :: ServerError -> IO ()
                       , optionConnectionEventHandler :: ConnectionEvent -> IO ()
                       , optionExitAction :: ClientExitReason -> IO ()
                       , optionConnectOptions :: [(String, Int)]
                       }

data SubscriptionQueueResult = SubscriptionQueued | SubscriptionReconnect

data UnsubscribeQueueResult = UnsubscribeQueued | UnsubscribeReconnect | UnsubscribeReset

-- | An opaque NATS server endpoint.
--
-- Keeping this representation private allows endpoint schemes and transports
-- to be added without changing the connection API.
data Server = Server String Int
  deriving (Eq, Show)

data ServerConfigError = EmptyServerHost
                       | InvalidServerPort Int
  deriving (Eq, Show)

-- | Construct a TCP NATS server endpoint.
server :: String -> Int -> Either ServerConfigError Server
server host port
  | null host = Left EmptyServerHost
  | port < 1 || port > 65535 = Left (InvalidServerPort port)
  | otherwise = Right (Server host port)

-- | Construct a server using the standard NATS port, 4222.
serverWithDefaultPort :: String -> Either ServerConfigError Server
serverWithDefaultPort host = server host 4222

serverHost :: Server -> String
serverHost (Server host _) = host

serverPort :: Server -> Int
serverPort (Server _ port) = port

-- | Connect to one of the configured NATS servers.
connect :: [Server] -> [ConfigOption] -> IO (Either ConnectError Client)
connect servers =
  newClient [(serverHost endpoint, serverPort endpoint) | endpoint <- servers]

-- | Compatibility connection entry point using raw @(host, port)@ tuples.
newClient :: [(String, Int)] -> [ConfigOption] -> IO (Either ConnectError Client)
newClient servers configOptions = mask $ \restoreInitialWait -> do
  loggerConfig' <- defaultLogger
  ctx <- newLogContext
  let defaultOptions = applyCallOptions configOptions ClientOptions
        { optionConnectConfig = defaultConnect
        , optionAuth = AuthNone.auth
        , optionTlsConfig = Nothing
        , optionLoggerConfig = loggerConfig'
        , optionConnectionAttempts = 5
        , optionConnectTimeoutMicros = 2 * 1000000
        , optionCallbackConcurrency = 1
        , optionMessageLimit = 1024 * 1024
        , optionPendingLimits = defaultPendingLimits
        , optionErrorHandler = const (pure ())
        , optionServerErrorHandler = const (pure ())
        , optionConnectionEventHandler = const (pure ())
        , optionExitAction = const (pure ())
        , optionConnectOptions = servers
        }
      clientConfig =
        ClientConfig
          { connectionAttempts = optionConnectionAttempts defaultOptions
          , connectTimeoutMicros = optionConnectTimeoutMicros defaultOptions
          , callbackConcurrency = optionCallbackConcurrency defaultOptions
          , messageLimit = optionMessageLimit defaultOptions
          , connectConfig = optionConnectConfig defaultOptions
          , loggerConfig = optionLoggerConfig defaultOptions
          , tlsConfig = optionTlsConfig defaultOptions
          , serverErrorHandler = optionServerErrorHandler defaultOptions
          , connectionEventHandler = optionConnectionEventHandler defaultOptions
          , exitAction = optionExitAction defaultOptions
          , connectOptions = optionConnectOptions defaultOptions
          }
      configuredAuth = optionAuth defaultOptions

  queue <- newQueue
  conn <- newConn connectionApi
  clientState <- newClientState clientConfig queue conn ctx
  store <-
    newSubscriptionStore
      (optionPendingLimits defaultOptions)
      (handleSlowConsumer clientState (optionErrorHandler defaultOptions))

  setConnectName clientState (Connect.name (optionConnectConfig defaultOptions))
  logStaticConfiguration clientState defaultOptions

  callbackThreads <- startWorkers
    (callbackConcurrency clientConfig)
    store
    (do
        waitForClosed clientState
        awaitCallbackDrain store
        awaitNoTrackedExpiries store)
    (handleCallbackError clientState)

  expiryThread <- startExpiryWorker store $
    shouldStopExpiryWorker clientState store

  mapM_ (registerManagedThread clientState) (expiryThread : callbackThreads)
  callbackWorker <- startCallbackWorker clientState

  engineDone <- newEmptyTMVarIO
  engineThread <- forkIOWithUnmask $ \unmask ->
    do
      threadId <- myThreadId
      registerManagedThread clientState threadId
      unmask
        ( runEngine
            connectionApi
            streamingApi
            broadcastingApi
            (parserApiWithMessageLimit (messageLimit clientConfig))
            clientState
            store
            configuredAuth
        )
        `finally` do
          unregisterManagedThread clientState threadId
          atomically (void (tryPutTMVar engineDone ()))

  let auxiliaryThreads = expiryThread : callbackThreads
      stopAuxiliaryThreads excluded =
        mapM_ killThread (filter (/= excluded) auxiliaryThreads)
      finishClose excluded = do
        atomically (readTMVar engineDone)
        Connection.close connectionApi conn
        atomically (awaitCallbackDrain store)
        stopAuxiliaryThreads excluded
        atomically (waitForCallbackWorker callbackWorker)
      scheduleFinish excluded = do
        _ <- forkIOWithUnmask $ \unmask -> unmask (finishClose excluded)
        pure ()
      closeAndFinish = mask $ \restore -> do
        current <- myThreadId
        closeClient connectionApi clientState store
          `onException` scheduleFinish current
        managed <- isManagedThread clientState current
        if managed
          then scheduleFinish current
          else restore (finishClose current) `onException` scheduleFinish current

  let client = Client
        { publish = \subject payload publishOptions -> do
            let cfg = applyCallOptions publishOptions defaultPublishConfig
            publishClient
              clientState
              (enqueueControl store . optionErrorHandler defaultOptions)
              subject
              payload
              cfg
        , subscribe = \subject subscribeOptions callback -> do
            let cfg = applyCallOptions subscribeOptions defaultSubscribeConfig
            subscribeClient clientState store StandardSubscription subject cfg callback
        , subscribeOnce = \subject subscribeOptions callback -> do
            let cfg = applyCallOptions subscribeOptions defaultSubscribeConfig
            subscribeClient clientState store OneShotSubscription subject cfg callback
        , request = \subject payload requestOptions -> do
            let cfg = applyCallOptions requestOptions defaultRequestConfig
            requestClient clientState store subject payload cfg
        , unsubscribe = \subscription options ->
            case applyCallOptions options UnsubscribeConfig of
              UnsubscribeConfig -> unsubscribeClient clientState store subscription
        , newInbox = nextInbox clientState
        , ping = \options ->
            case applyCallOptions options defaultPingConfig of
              PingConfig -> flushClient connectionApi clientState defaultRoundTripTimeout
              PingConfigTimeout timeoutSeconds ->
                flushClient connectionApi clientState timeoutSeconds
        , flush = \options ->
            case applyCallOptions options defaultFlushConfig of
              FlushConfig -> flushClient connectionApi clientState defaultRoundTripTimeout
              FlushConfigTimeout timeoutSeconds ->
                flushClient connectionApi clientState timeoutSeconds
        , connectionState = readStatus clientState
        , reset = \options ->
            case applyCallOptions options ResetConfig of
              ResetConfig -> resetClient connectionApi clientState store
        , close = \options ->
            case applyCallOptions options CloseConfig of
              CloseConfig -> closeAndFinish
        }

  let abortInitialWait = do
        setClosing clientState ExitClosedByUser
        closeStore store
        void (interruptConnection connectionApi clientState)
        killThread engineThread
        atomically (readTMVar engineDone)
        Connection.close connectionApi conn
        void (markClosed clientState ExitClosedByUser)
        stopCallbackWorker callbackWorker
        current <- myThreadId
        stopAuxiliaryThreads current
  initialResult <-
    restoreInitialWait
      (atomically $
        waitForInitialConnection clientState
          `orElse` (Left (ConnectAttemptsExhausted []) <$ readTMVar engineDone))
      `onException` abortInitialWait
  case initialResult of
    Left err -> do
      atomically (readTMVar engineDone)
      Connection.close connectionApi conn
      stopCallbackWorker callbackWorker
      current <- myThreadId
      stopAuxiliaryThreads current
      pure (Left err)
    Right () -> pure (Right client)

type ConfigOption = CallOption ClientOptions

withConnectName :: BS.ByteString -> ConfigOption
withConnectName name config =
  config
    { optionConnectConfig =
        (optionConnectConfig config) { Connect.name = Just name }
    }

withEcho :: Bool -> ConfigOption
withEcho enabled config =
  config
    { optionConnectConfig =
        (optionConnectConfig config) { Connect.echo = Just enabled }
    }

withAuthToken :: AuthTokenData -> ConfigOption
withAuthToken token = addAuth (AuthToken.auth token)

-- | Fetch a token for every connection and reconnection attempt.
withAuthTokenHandler :: AuthTokenHandler -> ConfigOption
withAuthTokenHandler handler = addAuth (AuthToken.authHandler handler)

withUserPass :: UserPassData -> ConfigOption
withUserPass userPass = addAuth (AuthUserPass.auth userPass)

-- | Fetch a username and password for every connection and reconnection attempt.
withUserPassHandler :: UserPassHandler -> ConfigOption
withUserPassHandler handler = addAuth (AuthUserPass.authHandler handler)

withNKey :: NKeyData -> ConfigOption
withNKey seed = addAuth (AuthNKey.auth seed)

-- | Authenticate with a public NKey and a handler that signs the server nonce.
-- The handler returns the raw 64-byte Ed25519 signature; natskell performs the
-- protocol's base64url encoding.
withNKeyHandler :: NKeyPublicKey -> SignatureHandler -> ConfigOption
withNKeyHandler publicKey handler = addAuth (AuthNKey.authHandler publicKey handler)

withJWT :: JWTTokenData -> ConfigOption
withJWT creds = addAuth (AuthJwt.auth creds)

-- | Fetch a user JWT and sign the server nonce for every connection attempt.
withJWTHandlers :: JWTHandler -> SignatureHandler -> ConfigOption
withJWTHandlers jwtHandler signatureHandler =
  addAuth (AuthJwt.authHandlers jwtHandler signatureHandler)

addAuth :: Auth -> ConfigOption
addAuth auth config =
  config { optionAuth = mergeAuth (optionAuth config) auth }

-- | Require TLS using the operating system trust store.
withTLS :: ConfigOption
withTLS = modifyTLSConfig id

withTLSCert :: TLSCertData -> ConfigOption
withTLSCert cert =
  modifyTLSConfig $ \tls -> tls { tlsClientCertificate = Just cert }

-- | Trust a PEM-encoded root certificate for this client. Once configured,
-- these roots replace the operating-system trust store for the connection.
withTLSRootCA :: BS.ByteString -> ConfigOption
withTLSRootCA root =
  modifyTLSConfig $ \tls ->
    tls { tlsRootCertificates = tlsRootCertificates tls ++ [root] }

-- | Override the host name used for certificate verification and SNI.
withTLSServerName :: String -> ConfigOption
withTLSServerName serverName =
  modifyTLSConfig $ \tls -> tls { tlsServerName = Just serverName }

-- | Disable server certificate verification. This is unsafe and should only
-- be used when the peer is trusted by some mechanism outside TLS.
withTLSInsecure :: ConfigOption
withTLSInsecure =
  modifyTLSConfig $ \tls -> tls { tlsInsecure = True }

modifyTLSConfig :: (TLSConfig -> TLSConfig) -> ConfigOption
modifyTLSConfig update config =
  config
    { optionTlsConfig =
        Just (update (fromMaybe defaultTLSConfig (optionTlsConfig config)))
    }

withMinimumLogLevel :: LogLevel -> ConfigOption
withMinimumLogLevel minimumLogLevel config =
  config
    { optionLoggerConfig =
        (optionLoggerConfig config) { minLogLevel = minimumLogLevel }
    }

withLogAction :: (LogEntry -> IO ()) -> ConfigOption
withLogAction logAction config =
  config
    { optionLoggerConfig =
        (optionLoggerConfig config) { logFn = logAction }
    }

withConnectionAttempts :: Int -> ConfigOption
withConnectionAttempts attempts config =
  config { optionConnectionAttempts = max 1 attempts }

-- | Set the total time budget for TCP acquisition, INFO, TLS, CONNECT, PONG,
-- and reconnect resubscription/readiness on each server attempt. Values below
-- one microsecond are clamped to one.
withConnectTimeoutMicros :: Int -> ConfigOption
withConnectTimeoutMicros timeoutMicros config =
  config { optionConnectTimeoutMicros = max 1 timeoutMicros }

withCallbackConcurrency :: Int -> ConfigOption
withCallbackConcurrency concurrency config =
  config { optionCallbackConcurrency = concurrency }

-- | Set the largest encoded message body this client accepts. For messages
-- with headers, the encoded header block and payload both count toward the
-- limit. Outbound messages are also constrained by the server's max_payload.
withMessageLimit :: Int -> ConfigOption
withMessageLimit limit config =
  config { optionMessageLimit = max 1 limit }

-- | Bound callback deliveries pending across the entire client. The first
-- limit is a message count and the second is encoded message bytes.
withPendingDeliveryLimits :: Int -> Int -> ConfigOption
withPendingDeliveryLimits maximumMessages maximumBytes config =
  config
    { optionPendingLimits =
        PendingLimits
          { pendingMessageLimit = max 1 maximumMessages
          , pendingByteLimit = max 1 maximumBytes
          }
    }

-- | Receive asynchronous client errors such as slow-consumer notifications.
-- The handler runs on the callback worker pool, never the socket reader.
withErrorHandler :: (NatsError -> IO ()) -> ConfigOption
withErrorHandler handler config =
  config { optionErrorHandler = handler }

-- | Receive protocol @-ERR@ values on a bounded, dedicated serial worker.
-- Protocol processing, including PONG handling, does not wait for the handler.
withServerErrorHandler :: (ServerError -> IO ()) -> ConfigOption
withServerErrorHandler handler config =
  config { optionServerErrorHandler = handler }

-- | Receive completed disconnect, reconnect, and close transitions on the
-- same serial callback worker as server errors. Lifecycle delivery is reserved
-- independently from the bounded server-error backlog.
withConnectionEventHandler :: (ConnectionEvent -> IO ()) -> ConfigOption
withConnectionEventHandler handler config =
  config { optionConnectionEventHandler = handler }

-- | Compatibility alias for 'withMessageLimit'.
withBufferLimit :: Int -> ConfigOption
withBufferLimit = withMessageLimit

{-# DEPRECATED withBufferLimit "Use withMessageLimit instead." #-}

withExitAction :: (ClientExitReason -> IO ()) -> ConfigOption
withExitAction action config = config { optionExitAction = action }

defaultConnect :: Connect.Connect
defaultConnect =
  Connect.Connect
    { Connect.verbose = False
    , Connect.pedantic = True
    , Connect.tls_required = False
    , Connect.auth_token = Nothing
    , Connect.user = Nothing
    , Connect.pass = Nothing
    , Connect.name = Nothing
    , Connect.lang = "haskell"
    , Connect.version = BC.pack (showVersion Package.version)
    , Connect.protocol = Nothing
    , Connect.echo = Just True
    , Connect.sig = Nothing
    , Connect.jwt = Nothing
    , Connect.nkey = Nothing
    , Connect.no_responders = Just True
    , Connect.headers = Just True
    }

defaultSubscribeConfig :: SubscribeConfig
defaultSubscribeConfig = SubscribeConfig Nothing Nothing

defaultRequestConfig :: RequestConfig
defaultRequestConfig = RequestConfig 2 Nothing

defaultPingConfig :: PingConfig
defaultPingConfig = PingConfig

defaultFlushConfig :: FlushConfig
defaultFlushConfig = FlushConfig

defaultRoundTripTimeout :: NominalDiffTime
defaultRoundTripTimeout = 2

logStaticConfiguration :: ClientState -> ClientOptions -> IO ()
logStaticConfiguration client options =
  runClient client $ do
    case authMethods (optionAuth options) of
      [] -> logMessage Info "no authentication method provided"
      methods -> forM_ methods $ \method ->
        logMessage Info ("using " ++ method)
    case optionTlsConfig options of
      Nothing ->
        pure ()
      Just tls -> do
        logMessage Info "using tls"
        when (tlsInsecure tls) $
          logMessage Warn "tls certificate verification is disabled"

handleCallbackError :: ClientState -> SomeException -> IO ()
handleCallbackError client _ =
  runClient client $
    logMessage Error "callback failed"

handleSlowConsumer :: ClientState -> (NatsError -> IO ()) -> IO ()
handleSlowConsumer client handler = do
  runClient client $
    logMessage Error "slow consumer: global pending delivery limit reached"
  handler NatsSlowConsumer

shouldStopExpiryWorker :: ClientState -> SubscriptionStore -> IO Bool
shouldStopExpiryWorker client store = do
  status <- readStatus client
  tracked <- hasTrackedExpiries store
  pure $
    case status of
      ConnectionClosed _ -> not tracked
      _                  -> False

toMessage :: Msg.Msg -> Message
toMessage msg =
  Message
    { subject = Msg.subject msg
    , sid = Msg.sid msg
    , replyTo = Msg.replyTo msg
    , payload = fromMaybe BS.empty (Msg.payload msg)
    , headers = Msg.headers msg
    }

publishClient
  :: ClientState
  -> (NatsError -> IO ())
  -> Msg.Subject
  -> Msg.Payload
  -> PublishConfig
  -> IO (Either NatsError ())
publishClient client errorHandler subject messagePayload cfg = do
  runClient client $
    logMessage Debug ("publishing to subject: " ++ show subject)
  let publishMessage =
        Pub.Pub
          { Pub.subject = subject
          , Pub.payload =
              if BS.null messagePayload then Nothing else Just messagePayload
          , Pub.replyTo = publishReplyTo cfg
          , Pub.headers = publishHeaders cfg
          }
      actualSize = Pub.messageSize publishMessage
      queuedPublish =
        QueuePayloadBound
          actualSize
          (errorHandler . NatsPayloadTooLarge actualSize)
          (QueueItem publishMessage)
  validation <- validatePublish client publishMessage
  case validation of
    Left err -> pure (Left err)
    Right () -> do
      enqueueResult <- enqueuePublishOnConnected client actualSize queuedPublish
      publishEnqueueResult client actualSize enqueueResult

validatePublish :: ClientState -> Pub.Pub -> IO (Either NatsError ())
validatePublish client publishMessage =
  case validate publishMessage of
    Left reason -> do
      runClient client $
        logMessage Error ("rejecting invalid publish: " ++ BC.unpack reason)
      pure (Left (NatsValidationError reason))
    Right () -> do
      let actualSize = Pub.messageSize publishMessage
          clientMaximum = messageLimit (config client)
      if actualSize > clientMaximum
        then rejectPayloadTooLarge client actualSize clientMaximum
        else pure (Right ())

publishEnqueueResult
  :: ClientState
  -> Int
  -> PublishEnqueueResult
  -> IO (Either NatsError ())
publishEnqueueResult client actualSize enqueueResult =
  case enqueueResult of
    PublishEnqueued _ -> pure (Right ())
    PublishTooLarge maximumSize ->
      rejectPayloadTooLarge client actualSize maximumSize
    PublishConnectionClosed reason ->
      pure (Left (NatsConnectionClosed reason))

rejectPayloadTooLarge :: ClientState -> Int -> Int -> IO (Either NatsError a)
rejectPayloadTooLarge client actualSize maximumSize = do
  runClient client $
    logMessage Error
      ( "rejecting publish: message size "
          ++ show actualSize
          ++ " exceeds effective limit "
          ++ show maximumSize
      )
  pure (Left (NatsPayloadTooLarge actualSize maximumSize))

subscribeClient
  :: ClientState
  -> SubscriptionStore
  -> SubscriptionKind
  -> Msg.Subject
  -> SubscribeConfig
  -> (Message -> IO ())
  -> IO (Either NatsError Subscription)
subscribeClient client store subscriptionKind subject cfg callback =
  subscribeRawClient client store subscriptionKind subject cfg (maybe (pure ()) (callback . toMessage))

subscribeRawClient
  :: ClientState
  -> SubscriptionStore
  -> SubscriptionKind
  -> Msg.Subject
  -> SubscribeConfig
  -> (Maybe Msg.Msg -> IO ())
  -> IO (Either NatsError Subscription)
subscribeRawClient client store subscriptionKind subject cfg callback = do
  subscribeRawClientWithOverflow
    client
    store
    subscriptionKind
    subject
    cfg
    callback
    (pure ())

subscribeRawClientWithOverflow
  :: ClientState
  -> SubscriptionStore
  -> SubscriptionKind
  -> Msg.Subject
  -> SubscribeConfig
  -> (Maybe Msg.Msg -> IO ())
  -> IO ()
  -> IO (Either NatsError Subscription)
subscribeRawClientWithOverflow client store subscriptionKind subject cfg callback onDropped =
  mask $ \restore -> do
    runClient client $
      logMessage Debug ("subscribing to subject: " ++ show subject)
    let queueGroup = subscribeQueueGroup cfg
    sid <- nextSid client
    registeredGeneration <- newEmptyTMVarIO
    committedGeneration <- newEmptyTMVarIO
    let subscriptionMessage =
          Sub.Sub
            { Sub.subject = subject
            , Sub.queueGroup = queueGroup
            , Sub.sid = sid
            }
        meta = SubscriptionMeta subject queueGroup subscriptionKind
        subscription = Subscription sid
        cleanup =
          cleanupResumableSubscription
            client
            store
            registeredGeneration
            committedGeneration
            subscription
        deliver maybeMessage = do
          case maybeMessage of
            Nothing -> cleanup
            Just _  -> pure ()
          callback maybeMessage
        commands =
          QueueItem subscriptionMessage :
            [ QueueItem
                Unsub.Unsub
                  { Unsub.sid = sid
                  , Unsub.maxMsg = Just 1
                  }
            | isOneShotSubscription meta
            ]
        queuedCommands = QueueConnectionScoped (QueueBatch commands)
    case validate subscriptionMessage of
      Left reason -> do
        runClient client $
          logMessage Error ("rejecting invalid subscription: " ++ BC.unpack reason)
        pure (Left (NatsValidationError reason))
      Right () -> do
        let waitForServerRegistration = do
              statusResult <- runningResult client
              case statusResult of
                Left err -> cleanup >> pure (Left err)
                Right () -> pure (Right subscription)
            acquireGate = do
              statusResult <- runningResult client
              case statusResult of
                Left err -> pure (Left err)
                Right () -> do
                  gated <- withSubscriptionGate client $ do
                    status <- readStatus client
                    case status of
                      ConnectionConnected -> do
                        generation <- readConnectionGeneration client
                        atomically (void (tryPutTMVar registeredGeneration generation))
                        register store sid meta cfg deliver onDropped
                        enqueueResult <-
                          enqueueOnGenerationTracked
                            client
                            generation
                            committedGeneration
                            queuedCommands
                        case enqueueResult of
                          Right _ ->
                            pure (Just (Right SubscriptionQueued))
                          Left _ -> do
                            nextStatus <- readStatus client
                            case nextStatus of
                              ConnectionClosing reason ->
                                pure (Just (Left (NatsConnectionClosed reason)))
                              ConnectionClosed reason ->
                                pure (Just (Left (NatsConnectionClosed reason)))
                              _ ->
                                pure (Just (Right SubscriptionReconnect))
                      _ -> pure Nothing
                  maybe acquireGate pure gated
            subscribeOperation = do
              queueResult <- acquireGate
              case queueResult of
                Left err                    -> cleanup >> pure (Left err)
                Right SubscriptionQueued -> do
                  runClient client $
                    logMessage Debug "subscription commands queued"
                  pure (Right subscription)
                Right SubscriptionReconnect -> waitForServerRegistration
        restore subscribeOperation `onException` cleanup

requestClient
  :: ClientState
  -> SubscriptionStore
  -> Msg.Subject
  -> Msg.Payload
  -> RequestConfig
  -> IO (Either NatsError Message)
requestClient client store requestSubject requestPayload cfg = do
  timedResult <-
    timeout
      (durationMicros (requestTimeout cfg))
      (requestBeforeDeadline client store requestSubject requestPayload cfg)
  pure (fromMaybe (Left NatsRequestTimedOut) timedResult)

requestBeforeDeadline
  :: ClientState
  -> SubscriptionStore
  -> Msg.Subject
  -> Msg.Payload
  -> RequestConfig
  -> IO (Either NatsError Message)
requestBeforeDeadline client store requestSubject requestPayload cfg =
  mask $ \restore -> do
    response <- newEmptyTMVarIO
    accepted <- newEmptyTMVarIO
    committedGeneration <- newEmptyTMVarIO
    inbox <- nextInbox client
    let subscriptionConfig = SubscribeConfig Nothing Nothing
        deliver Nothing = atomically (void (tryPutTMVar response (Left NatsRequestTimedOut)))
        deliver (Just msg) =
          atomically . void . tryPutTMVar response $
            let message = toMessage msg
            in if isNoResponders message
                 then Left NatsNoResponders
                 else Right message
        rejectSlowConsumer =
          void (tryPutTMVar response (Left NatsSlowConsumer))
    sid <- nextSid client
    let subscription = Subscription sid
        subscriptionMessage =
          Sub.Sub
            { Sub.subject = inbox
            , Sub.queueGroup = Nothing
            , Sub.sid = sid
            }
        unsubscribeMessage =
          Unsub.Unsub
            { Unsub.sid = sid
            , Unsub.maxMsg = Just 1
            }
        publishMessage =
          Pub.Pub
            { Pub.subject = requestSubject
            , Pub.payload =
                if BS.null requestPayload then Nothing else Just requestPayload
            , Pub.replyTo = Just inbox
            , Pub.headers = requestHeaders cfg
            }
        commands = QueueConnectionScoped . QueueBatch $
          [ QueueItem subscriptionMessage
          , QueueItem unsubscribeMessage
          , QueueItem publishMessage
          ]
        meta = SubscriptionMeta inbox Nothing RequestReplySubscription
    case validate subscriptionMessage of
      Left reason -> pure (Left (NatsValidationError reason))
      Right () -> do
        publishValidation <- validatePublish client publishMessage
        case publishValidation of
          Left err -> pure (Left err)
          Right () -> do
            registerWithDispatchHooks
              store
              sid
              meta
              subscriptionConfig
              (void (tryPutTMVar accepted ()))
              rejectSlowConsumer
              deliver
              (pure ())
            let cleanupCommitted = do
                  committed <- atomically (tryReadTMVar committedGeneration)
                  case committed of
                    Nothing -> unregister store sid
                    Just generation ->
                      cleanupRequestSubscription client store generation subscription
            let publishSize = Pub.messageSize publishMessage
            queued <- restore
              ( enqueuePublishOnConnectedGenerationTracked
                  client
                  committedGeneration
                  publishSize
                  commands
              )
              `onException` cleanupCommitted
            case queued of
              PublishConnectionClosed reason -> do
                unregister store sid
                pure (Left (NatsConnectionClosed reason))
              PublishTooLarge maximumSize -> do
                unregister store sid
                rejectPayloadTooLarge client publishSize maximumSize
              PublishEnqueued generation -> do
                let cleanup =
                      cleanupRequestSubscription client store generation subscription
                result <- restore (awaitRequest client generation accepted response)
                  `onException` cleanup
                cleanup
                pure result

awaitRequest
  :: ClientState
  -> Int
  -> TMVar ()
  -> TMVar (Either NatsError Message)
  -> IO (Either NatsError Message)
awaitRequest client generation accepted response =
  atomically $
    readTMVar response
      `orElse`
        do
          acceptedReply <- tryReadTMVar accepted
          case acceptedReply of
            Just () -> retry
            Nothing ->
              Left . NatsConnectionClosed
                <$> waitForConnectionGenerationLoss client generation

durationMicros :: NominalDiffTime -> Int
durationMicros duration =
  fromInteger (min (toInteger (maxBound :: Int)) micros)
  where
    micros = max 0 (floor (toRational duration * 1000000))

isNoResponders :: Message -> Bool
isNoResponders message =
  case headers message of
    Nothing -> False
    Just messageHeaders ->
      any isNoRespondersHeader messageHeaders
  where
    isNoRespondersHeader (name, value) =
      BC.map toAsciiLower name == "status" && value == "503"
    toAsciiLower byte
      | isAsciiUpper byte = toEnum (fromEnum byte + 32)
      | otherwise = byte

unsubscribeClient
  :: ClientState
  -> SubscriptionStore
  -> Subscription
  -> IO (Either NatsError ())
unsubscribeClient client store (Subscription sid) =
  mask $ \restore -> do
    runClient client $
      logMessage Debug ("unsubscribing SID: " ++ show sid)
    let command = QueueConnectionScoped . QueueItem $
          Unsub.Unsub
            { Unsub.sid = sid
            , Unsub.maxMsg = Nothing
            }
        unregisterAndTry = do
          unregister store sid
          tryEnqueue client command >>= \case
            TryEnqueued    -> pure (Right UnsubscribeQueued)
            TryQueueClosed -> pure (Right UnsubscribeReconnect)
            TryQueueFull   -> pure (Right UnsubscribeReset)
    queueResult <- withSubscriptionGate client $ do
      status <- readStatus client
      case status of
        ConnectionConnected -> do
          unregister store sid
          enqueueResult <-
            restore (enqueue client command)
              `onException` interruptConnection connectionApi client
          case enqueueResult of
            Right () -> pure (Right UnsubscribeQueued)
            Left _ -> do
              nextStatus <- readStatus client
              case nextStatus of
                ConnectionClosing reason ->
                  pure (Left (NatsConnectionClosed reason))
                ConnectionClosed reason ->
                  pure (Left (NatsConnectionClosed reason))
                _ -> pure (Right UnsubscribeReconnect)
        ConnectionConnecting -> unregisterAndTry
        ConnectionReconnecting -> unregisterAndTry
        ConnectionClosing reason -> do
          unregister store sid
          pure (Left (NatsConnectionClosed reason))
        ConnectionClosed reason -> do
          unregister store sid
          pure (Left (NatsConnectionClosed reason))
    case queueResult of
      Left err                    -> pure (Left err)
      Right UnsubscribeQueued    -> pure (Right ())
      Right UnsubscribeReconnect -> pure (Right ())
      Right UnsubscribeReset     -> do
        interruptConnection connectionApi client
        pure (Right ())

cleanupResumableSubscription
  :: ClientState
  -> SubscriptionStore
  -> TMVar Int
  -> TMVar Int
  -> Subscription
  -> IO ()
cleanupResumableSubscription client store registeredGeneration committedGeneration (Subscription sid) =
  mask_ $ do
    resetRequired <- withSubscriptionGate client $ do
      unregister store sid
      status <- readStatus client
      case status of
        ConnectionConnected -> do
          generation <- readConnectionGeneration client
          registered <- atomically (tryReadTMVar registeredGeneration)
          committed <- atomically (tryReadTMVar committedGeneration)
          let serverMayKnow =
                isJust committed || maybe False (< generation) registered
          if serverMayKnow
            then do
              result <- tryEnqueueOnGeneration client generation unsubscribeCommand
              pure (result == TryQueueFull)
            else pure False
        _ -> pure False
    when resetRequired (void (interruptConnection connectionApi client))
  where
    unsubscribeCommand = QueueConnectionScoped . QueueItem $
      Unsub.Unsub { Unsub.sid = sid, Unsub.maxMsg = Nothing }

cleanupRequestSubscription :: ClientState -> SubscriptionStore -> Int -> Subscription -> IO ()
cleanupRequestSubscription client store generation (Subscription sid) = mask_ $ do
  unregister store sid
  result <- tryEnqueueOnGeneration client generation . QueueConnectionScoped . QueueItem $
    Unsub.Unsub { Unsub.sid = sid, Unsub.maxMsg = Nothing }
  case result of
    TryEnqueued    -> pure ()
    TryQueueClosed -> pure ()
    TryQueueFull   -> void (interruptConnection connectionApi client)

flushClient :: ConnectionAPI -> ClientState -> NominalDiffTime -> IO (Either NatsError ())
flushClient connectionApi' client timeoutSeconds =
  mask $ \restore -> do
    let reset = interruptConnection connectionApi' client
    timedResult <-
      restore (timeout (durationMicros timeoutSeconds) (awaitPong client))
        `onException` reset
    case timedResult of
      Nothing     -> reset >> pure (Left NatsRequestTimedOut)
      Just result -> pure result
  where
    awaitPong client' = do
      waiter <- newEmptyTMVarIO
      registered <- registerPingWaiterAndEnqueue
        client'
        waiter
        (QueueConnectionScoped (QueueItem Ping))
      case registered of
        Left reason -> pure (Left (NatsConnectionClosed reason))
        Right _ -> do
          result <- atomically $
            (Just <$> readTMVar waiter)
              `orElse` (Nothing <$ waitForNotRunning client')
          case result of
            Just PingReceived -> pure (Right ())
            Just PingConnectionLost ->
              Left . closedError <$> readStatus client'
            Nothing -> do
              status <- readStatus client'
              pure (Left (closedError status))

runningResult :: ClientState -> IO (Either NatsError ())
runningResult client = do
  result <- atomically (waitForConnected client)
  pure $
    case result of
      Left reason -> Left (NatsConnectionClosed reason)
      Right ()    -> Right ()

closedError :: ConnectionState -> NatsError
closedError status =
  case status of
    ConnectionClosing reason -> NatsConnectionClosed reason
    ConnectionClosed reason  -> NatsConnectionClosed reason
    _                        -> NatsConnectionClosed ExitResetRequested