packages feed

ephemeral-pg-0.2.0.0: src/EphemeralPg/Error.hs

-- | Error types for ephemeral-pg.
--
-- This module provides a rich error hierarchy for diagnosing failures
-- when starting or stopping temporary PostgreSQL databases.
module EphemeralPg.Error
  ( -- * Top-level errors
    StartError (..),
    StopError (..),

    -- * Specific error types
    InitDbError (..),
    PostgresError (..),
    CreateDbError (..),
    ConfigError (..),
    ResourceError (..),
    TimeoutError (..),

    -- * Error rendering
    renderStartError,
    renderStopError,
  )
where

import Control.Exception (Exception)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Word (Word16)
import System.Exit (ExitCode (..))

-- | Top-level error when starting a database.
data StartError
  = -- | initdb failed to initialize the data directory
    InitDbError InitDbError
  | -- | PostgreSQL server failed to start
    PostgresStartError PostgresError
  | -- | createdb failed to create the database
    CreateDbError CreateDbError
  | -- | Configuration is invalid
    ConfigError ConfigError
  | -- | System resource error (permissions, disk space, etc.)
    ResourceError ResourceError
  | -- | Timeout waiting for database to be ready
    TimeoutError TimeoutError
  deriving stock (Eq, Show)
  deriving anyclass (Exception)

-- | Errors when stopping a database.
data StopError
  = -- | Failed to send shutdown signal
    ShutdownSignalFailed Int Text
  | -- | Graceful shutdown timed out, had to force kill
    ShutdownTimedOut Int
  | -- | Failed to clean up directories
    CleanupFailed FilePath Text
  deriving stock (Eq, Show)
  deriving anyclass (Exception)

-- | Errors from initdb execution.
data InitDbError
  = -- | initdb executable not found in PATH
    InitDbNotFound
  | -- | PostgreSQL version too old
    InitDbVersionMismatch
      { expected :: Text,
        actual :: Text
      }
  | -- | initdb process failed
    InitDbFailed
      { exitCode :: ExitCode,
        stdout :: Text,
        stderr :: Text,
        command :: Text
      }
  | -- | Cannot write to data directory
    InitDbPermissionDenied FilePath
  deriving stock (Eq, Show)

-- | Errors from PostgreSQL server operations.
data PostgresError
  = -- | postgres executable not found in PATH
    PostgresNotFound
  | -- | Server process exited unexpectedly
    PostgresStartFailed
      { exitCode :: ExitCode,
        stdout :: Text,
        stderr :: Text,
        command :: Text
      }
  | -- | Could not connect to verify server is running
    PostgresConnectionFailed Text
  | -- | Server version doesn't match requirements
    PostgresVersionMismatch
      { expected :: Text,
        actual :: Text
      }
  deriving stock (Eq, Show)

-- | Errors from createdb execution.
data CreateDbError
  = -- | createdb executable not found in PATH
    CreateDbNotFound
  | -- | createdb process failed
    CreateDbFailed
      { exitCode :: ExitCode,
        stdout :: Text,
        stderr :: Text,
        command :: Text,
        name :: Text
      }
  | -- | Database with this name already exists
    DatabaseAlreadyExists Text
  deriving stock (Eq, Show)

-- | Configuration validation errors.
data ConfigError
  = -- | Port number is invalid or unavailable
    InvalidPort Word16 Text
  | -- | Database name is invalid
    InvalidDatabaseName Text Text
  | -- | Username is invalid
    InvalidUser Text Text
  | -- | Data directory path is invalid
    InvalidDataDirectory FilePath Text
  | -- | Socket directory path is invalid
    InvalidSocketDirectory FilePath Text
  | -- | Socket path exceeds Unix domain socket limit
    SocketPathTooLong
      { path :: FilePath,
        pathLength :: Int,
        pathMaxLength :: Int
      }
  | -- | Configuration options conflict with each other
    ConflictingConfig Text
  deriving stock (Eq, Show)

-- | System resource errors.
data ResourceError
  = -- | Cannot access a required path
    PermissionDenied FilePath Text
  | -- | Cannot create a directory
    DirectoryCreationFailed FilePath Text
  | -- | Directory should be empty but isn't
    DirectoryNotEmpty FilePath
  | -- | Not enough disk space
    DiskSpaceExhausted FilePath
  | -- | Too many open files
    FileDescriptorLimit
  | -- | Requested port is already in use
    PortInUse Word16
  | -- | Could not find a free port
    PortAllocationFailed Text
  deriving stock (Eq, Show)

-- | Timeout errors.
data TimeoutError
  = -- | Timed out waiting for PostgreSQL to accept connections
    ConnectionTimeout
      { durationSeconds :: Int,
        host :: Text,
        port :: Word16
      }
  | -- | Timed out waiting for graceful shutdown
    ShutdownTimeout
      { timeoutSeconds :: Int,
        pid :: Int
      }
  deriving stock (Eq, Show)

-- | Render a 'StartError' for display to users.
renderStartError :: StartError -> Text
renderStartError = \case
  InitDbError InitDbNotFound ->
    "initdb not found. Please ensure PostgreSQL is installed and initdb is in your PATH.\n"
      <> "On Debian/Ubuntu, you may need to add /usr/lib/postgresql/<version>/bin to PATH."
  InitDbError (InitDbVersionMismatch expected actual) ->
    "PostgreSQL version mismatch: expected " <> expected <> " but found " <> actual
  InitDbError (InitDbFailed _code _stdout stderr cmd) ->
    "initdb failed:\n  Command: " <> cmd <> "\n  Error: " <> stderr
  InitDbError (InitDbPermissionDenied path) ->
    "Permission denied: cannot write to " <> T.pack path
  PostgresStartError PostgresNotFound ->
    "postgres not found. Please ensure PostgreSQL is installed and postgres is in your PATH."
  PostgresStartError (PostgresStartFailed _code _stdout stderr cmd) ->
    "PostgreSQL server failed to start:\n  Command: " <> cmd <> "\n  Error: " <> stderr
  PostgresStartError (PostgresConnectionFailed msg) ->
    "Could not connect to PostgreSQL: " <> msg
  PostgresStartError (PostgresVersionMismatch expected actual) ->
    "PostgreSQL version mismatch: expected " <> expected <> " but found " <> actual
  CreateDbError CreateDbNotFound ->
    "createdb not found. Please ensure PostgreSQL is installed and createdb is in your PATH."
  CreateDbError (CreateDbFailed _code _stdout stderr cmd name) ->
    "createdb failed for database '" <> name <> "':\n  Command: " <> cmd <> "\n  Error: " <> stderr
  CreateDbError (DatabaseAlreadyExists name) ->
    "Database '" <> name <> "' already exists"
  ConfigError (InvalidPort port msg) ->
    "Invalid port " <> T.pack (show port) <> ": " <> msg
  ConfigError (InvalidDatabaseName name msg) ->
    "Invalid database name '" <> name <> "': " <> msg
  ConfigError (InvalidUser name msg) ->
    "Invalid username '" <> name <> "': " <> msg
  ConfigError (InvalidDataDirectory path msg) ->
    "Invalid data directory '" <> T.pack path <> "': " <> msg
  ConfigError (InvalidSocketDirectory path msg) ->
    "Invalid socket directory '" <> T.pack path <> "': " <> msg
  ConfigError (SocketPathTooLong path len maxLen) ->
    "Socket path too long ("
      <> T.pack (show len)
      <> " > "
      <> T.pack (show maxLen)
      <> " bytes): "
      <> T.pack path
      <> "\nTry using a shorter temporary directory path."
  ConfigError (ConflictingConfig msg) ->
    "Conflicting configuration: " <> msg
  ResourceError (PermissionDenied path msg) ->
    "Permission denied for '" <> T.pack path <> "': " <> msg
  ResourceError (DirectoryCreationFailed path msg) ->
    "Failed to create directory '" <> T.pack path <> "': " <> msg
  ResourceError (DirectoryNotEmpty path) ->
    "Directory not empty: " <> T.pack path
  ResourceError (DiskSpaceExhausted path) ->
    "Disk space exhausted at " <> T.pack path
  ResourceError FileDescriptorLimit ->
    "Too many open files. Try increasing your system's file descriptor limit."
  ResourceError (PortInUse port) ->
    "Port " <> T.pack (show port) <> " is already in use"
  ResourceError (PortAllocationFailed msg) ->
    "Could not allocate a free port: " <> msg
  TimeoutError (ConnectionTimeout secs _host port) ->
    "Timed out after " <> T.pack (show secs) <> "s waiting for PostgreSQL on port " <> T.pack (show port)
  TimeoutError (ShutdownTimeout secs pid) ->
    "Timed out after " <> T.pack (show secs) <> "s waiting for process " <> T.pack (show pid) <> " to shut down"

-- | Render a 'StopError' for display to users.
renderStopError :: StopError -> Text
renderStopError = \case
  ShutdownSignalFailed pid msg ->
    "Failed to send shutdown signal to process " <> T.pack (show pid) <> ": " <> msg
  ShutdownTimedOut secs ->
    "Graceful shutdown timed out after " <> T.pack (show secs) <> "s, process was killed"
  CleanupFailed path msg ->
    "Failed to clean up '" <> T.pack path <> "': " <> msg