add: config client-error-verbosity to set error verbosity

Set error verbosity using this config. The verbosity can
be set to `verbose` or `minimal` for client error responses.

This only affects client side HTTP responses, server side logs
are not affected by this config.

Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
This commit is contained in:
Taimoor Zaeem
2026-02-25 15:24:27 -05:00
committed by Steve Chavez
parent 2edc44c352
commit 83dc082acf
29 changed files with 179 additions and 38 deletions
+1
View File
@@ -12,6 +12,7 @@ All notable changes to this project will be documented in this file. From versio
- Log host, port and pg version of listener database connection by @mkleczek in #4617 #4618
- Optimize requests with `Prefer: count=exact` that do not use ranges or `db-max-rows` by @laurenceisla in #3957
+ Removed unnecessary double count when building the `Content-Range`.
- Add config `client_error_verbosity` to customize error verbosity by @taimoorzaeem in #4088
### Changed
+47
View File
@@ -195,6 +195,53 @@ app.settings.*
The :code:`current_setting` function has `an optional boolean second <https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-ADMIN-SET>`_ argument to avoid it from raising an error if the value was not defined. Default values to :code:`app.settings` can then be given by combining this argument with :code:`coalesce` and :code:`nullif` : :code:`coalesce(nullif(current_setting('app.settings.my_custom_variable', true), ''), 'default value')`. The use of :code:`nullif` is necessary because if set in a transaction, the setting is sometimes not "rolled back" to :code:`null`. See also :ref:`this section <guc_req_headers_cookies_claims>` for more information on this behaviour.
.. _client-error-verbosity:
client-error-verbosity
----------------------
=============== =======================
**Type** String
**Default** verbose
**Reloadable** Y
**Environment** PGRST_CLIENT_ERROR_VERBOSITY
**In-Database** pgrst.client_error_verbosity
=============== =======================
Specifies the verbosity of PostgREST errors.
With ``verbose``, ``code``, ``message``, ``details`` and ``hint`` are returned.
.. code:: bash
curl "localhost:3000/itemsxx"
.. code-block:: json
{
"code": "PGRST205",
"message": "Could not find the table 'public.itemsxx' in the schema cache",
"details": "Perhaps you meant the table 'public.items'",
"hint": null
}
With ``minimal``, just ``code`` and ``message`` are returned.
.. code:: bash
curl "localhost:3000/itemsxx"
.. code-block:: json
{
"code": "PGRST205",
"message": "Could not find the table 'public.itemsxx' in the schema cache"
}
.. note::
This setting only affects client side error messages. Server side logs are not affected by this setting.
.. _db-aggregates-enabled:
db-aggregates-enabled
+23 -22
View File
@@ -119,30 +119,31 @@ postgrest logLevel appState connWorker =
Logger.middleware logLevel Auth.getRole $
-- fromJust can be used, because the auth middleware will **always** add
-- some AuthResult to the vault.
\req respond -> case fromJust $ Auth.getResult req of
Left err -> respond $ Error.errorResponseFor err
Right authResult -> do
appConf <- AppState.getConfig appState -- the config must be read again because it can reload
maybeSchemaCache <- AppState.getSchemaCache appState
\req respond -> do
appConf@AppConfig{..} <- AppState.getConfig appState -- the config must be read again because it can reload
case fromJust $ Auth.getResult req of
Left err -> respond $ Error.errorResponseFor configClientErrorVerbosity err
Right authResult -> do
maybeSchemaCache <- AppState.getSchemaCache appState
let
eitherResponse :: IO (Either Error Wai.Response)
eitherResponse =
runExceptT $ postgrestResponse appState appConf maybeSchemaCache authResult req
let
eitherResponse :: IO (Either Error Wai.Response)
eitherResponse =
runExceptT $ postgrestResponse appState appConf maybeSchemaCache authResult req
response <- either Error.errorResponseFor identity <$> eitherResponse
-- Launch the connWorker when the connection is down. The postgrest
-- function can respond successfully (with a stale schema cache) before
-- the connWorker is done. However, when there's an empty schema cache
-- postgrest responds with the error `PGRST002`; this means that the schema
-- cache is still loading, so we don't launch the connWorker here because
-- it would duplicate the loading process, e.g. https://github.com/PostgREST/postgrest/issues/3704
-- TODO: this process may be unnecessary when the Listener is enabled. Revisit once https://github.com/PostgREST/postgrest/issues/1766 is done
when (isServiceUnavailable response && isJust maybeSchemaCache) connWorker
resp <- do
delay <- AppState.getNextDelay appState
return $ addRetryHint delay response
respond resp
response <- either (Error.errorResponseFor configClientErrorVerbosity) identity <$> eitherResponse
-- Launch the connWorker when the connection is down. The postgrest
-- function can respond successfully (with a stale schema cache) before
-- the connWorker is done. However, when there's an empty schema cache
-- postgrest responds with the error `PGRST002`; this means that the schema
-- cache is still loading, so we don't launch the connWorker here because
-- it would duplicate the loading process, e.g. https://github.com/PostgREST/postgrest/issues/3704
-- TODO: this process may be unnecessary when the Listener is enabled. Revisit once https://github.com/PostgREST/postgrest/issues/1766 is done
when (isServiceUnavailable response && isJust maybeSchemaCache) connWorker
resp <- do
delay <- AppState.getNextDelay appState
return $ addRetryHint delay response
respond resp
postgrestResponse
:: AppState.AppState
+25 -1
View File
@@ -29,6 +29,7 @@ module PostgREST.Config
, addTargetSessionAttrs
, exampleConfigFile
, audMatchesCfg
, Verbosity (..)
) where
import qualified Data.Aeson as JSON
@@ -73,6 +74,7 @@ audMatchesCfg = maybe (const True) (==) . configJwtAudience
data AppConfig = AppConfig
{ configAppSettings :: [(Text, Text)]
, configClientErrorVerbosity :: Verbosity
, configDbAggregates :: Bool
, configDbAnonRole :: Maybe BS.ByteString
, configDbChannel :: Text
@@ -134,6 +136,15 @@ dumpLogLevel = \case
LogInfo -> "info"
LogDebug -> "debug"
data Verbosity
= Minimal
| Verbose
dumpClientErrorVerbosity :: Verbosity -> Text
dumpClientErrorVerbosity = \case
Minimal -> "minimal"
Verbose -> "verbose"
data OpenAPIMode = OAFollowPriv | OAIgnorePriv | OADisabled
deriving Eq
@@ -150,7 +161,8 @@ toText conf =
where
-- apply conf to all pgrst settings
pgrstSettings = (\(k, v) -> (k, v conf)) <$>
[("db-aggregates-enabled", T.toLower . show . configDbAggregates)
[("client-error-verbosity", q . dumpClientErrorVerbosity . configClientErrorVerbosity)
,("db-aggregates-enabled", T.toLower . show . configDbAggregates)
,("db-anon-role", q . T.decodeUtf8 . fromMaybe "" . configDbAnonRole)
,("db-channel", q . configDbChannel)
,("db-channel-enabled", T.toLower . show . configDbChannelEnabled)
@@ -254,6 +266,7 @@ parser :: Maybe FilePath -> Environment -> [(Text, Text)] -> RoleSettings -> Rol
parser optPath env dbSettings roleSettings roleIsolationLvl =
AppConfig
<$> parseAppSettings "app.settings"
<*> parseErrorVerbosity "client-error-verbosity"
<*> (fromMaybe False <$> optBool "db-aggregates-enabled")
<*> (fmap encodeUtf8 <$> optString "db-anon-role")
<*> (fromMaybe "pgrst" <$> optString "db-channel")
@@ -310,6 +323,14 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
<*> optInt "internal-schema-cache-load-sleep"
<*> optInt "internal-schema-cache-relationship-load-sleep"
where
parseErrorVerbosity :: C.Key -> C.Parser C.Config Verbosity
parseErrorVerbosity k =
optString k >>= \case
Nothing -> pure Verbose -- default
Just "minimal" -> pure Minimal
Just "verbose" -> pure Verbose
Just _ -> fail "Invalid client-error-verbosity. Check your configuration."
parseAppSettings :: C.Key -> C.Parser C.Config [(Text, Text)]
parseAppSettings key = addFromEnv . fmap (fmap coerceText) <$> C.subassocs key C.value
where
@@ -642,6 +663,9 @@ exampleConfigFile = S.unlines
[ "## Admin server used for checks. It's disabled by default unless a port is specified."
, "# admin-server-port = 3001"
, ""
, "# PostgREST error json verbosity config"
, "# client-error-verbosity = \"verbose\""
, ""
, "## The database role to use when no client authentication is provided"
, "# db-anon-role = \"anon\""
, ""
+1
View File
@@ -46,6 +46,7 @@ dbSettingsNames :: [Text]
dbSettingsNames =
(prefix <>) <$>
["db_aggregates_enabled"
,"client_error_verbosity"
,"db_anon_role"
,"db_pre_config"
,"db_extra_search_path"
+12 -7
View File
@@ -42,6 +42,7 @@ import Network.HTTP.Types.Header (Header)
import PostgREST.MediaType (MediaType (..))
import qualified PostgREST.MediaType as MediaType
import PostgREST.Config (Verbosity (..))
import PostgREST.SchemaCache (SchemaCache (SchemaCache, dbTablesFuzzyIndex))
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..),
Schema)
@@ -57,26 +58,30 @@ import PostgREST.Error.Types
import Protolude
-- | Encode Error to ByteString
errorPayload :: (ErrorBody a, ErrorHeaders a) => a -> LByteString
errorPayload = JSON.encode . toJsonPgrstError
errorPayload :: (ErrorBody a, ErrorHeaders a) => Verbosity -> a -> LByteString
errorPayload verb = JSON.encode . toJsonPgrstError verb
where
toJsonPgrstError :: (ErrorBody a, ErrorHeaders a) => a -> JSON.Value
toJsonPgrstError err = JSON.object [
toJsonPgrstError :: (ErrorBody a, ErrorHeaders a) => Verbosity -> a -> JSON.Value
toJsonPgrstError Verbose err = JSON.object [
"code" .= code err
, "message" .= message err
, "details" .= details err
, "hint" .= hint err
]
toJsonPgrstError Minimal err = JSON.object [
"code" .= code err
, "message" .= message err
]
-- | Create HTTP response from Error
errorResponseFor :: (ErrorBody a, ErrorHeaders a) => a -> Response
errorResponseFor err =
errorResponseFor :: (ErrorBody a, ErrorHeaders a) => Verbosity -> a -> Response
errorResponseFor verb err =
let
baseHeader = MediaType.toContentType MTApplicationJSON
cLHeader body = (,) "Content-Length" (show $ LBS.length body) :: Header
pSHeader code' = ("Proxy-Status", "PostgREST; error=" <> T.encodeUtf8 code')
in
responseLBS (status err) (baseHeader : cLHeader (errorPayload err) : pSHeader (code err) : headers err) $ errorPayload err
responseLBS (status err) (baseHeader : cLHeader (errorPayload verb err) : pSHeader (code err) : headers err) $ errorPayload verb err
class ErrorHeaders a where
status :: a -> HTTP.Status
-1
View File
@@ -25,7 +25,6 @@ import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..))
import PostgREST.SchemaCache.Relationship (Relationship (..),
RelationshipsMap)
import PostgREST.SchemaCache.Routine (Routine (..))
import Protolude
data Error
+3 -2
View File
@@ -24,6 +24,7 @@ import qualified Hasql.Pool as SQL
import qualified Hasql.Pool.Observation as SQL
import Network.HTTP.Types.Status (Status)
import Numeric (showFFloat)
import PostgREST.Config (Verbosity (..))
import PostgREST.Config.PgVersion
import qualified PostgREST.Error as Error
import PostgREST.Query (MainQuery)
@@ -94,7 +95,7 @@ observationMessage = \case
ExitDBFatalError ServerError08P01 usageErr ->
"Connection poolers in statement mode are not supported." <> jsonMessage usageErr
SchemaCacheEmptyObs ->
T.decodeUtf8 . LBS.toStrict . Error.errorPayload $ Error.NoSchemaCacheError
T.decodeUtf8 . LBS.toStrict . Error.errorPayload Verbose $ Error.NoSchemaCacheError
SchemaCacheErrorObs dbSchemas extraPaths usageErr ->
"Failed to load the schema cache using "
<> "db-schemas=" <> T.intercalate "," (toList dbSchemas)
@@ -167,7 +168,7 @@ observationMessage = \case
showMillis :: Double -> Text
showMillis x = toS $ showFFloat (Just 1) x ""
jsonMessage err = T.decodeUtf8 . LBS.toStrict . Error.errorPayload $ Error.PgError False err
jsonMessage err = T.decodeUtf8 . LBS.toStrict . Error.errorPayload Verbose $ Error.PgError False err
showListenerConnError :: SQL.ConnectionError -> Text
+4 -4
View File
@@ -62,7 +62,7 @@ data PgrstResponse = PgrstResponse {
actionResponse :: DbResult -> ApiRequest -> (Text, Text) -> AppConfig -> SchemaCache -> Schema -> Bool -> Either Error.Error PgrstResponse
actionResponse (DbCrudResult plan@WrappedReadPlan{pMedia, wrHdrsOnly=headersOnly, crudQi=identifier} RSStandard{..}) ctxApiRequest@ApiRequest{..} _ _ _ _ _ = do
actionResponse (DbCrudResult plan@WrappedReadPlan{pMedia, wrHdrsOnly=headersOnly, crudQi=identifier} RSStandard{..}) ctxApiRequest@ApiRequest{..} _ AppConfig{..} _ _ _ = do
let
(status, contentRange) = RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal rsTableTotal
cLHeader = if headersOnly then mempty else [ contentLengthHeader bod ]
@@ -79,7 +79,7 @@ actionResponse (DbCrudResult plan@WrappedReadPlan{pMedia, wrHdrsOnly=headersOnly
++ cLHeader
++ contentTypeHeaders pMedia ctxApiRequest
++ prefHeader
bod | status == HTTP.status416 = Error.errorPayload $ Error.ApiRequestErr $ Error.InvalidRange $
bod | status == HTTP.status416 = Error.errorPayload configClientErrorVerbosity $ Error.ApiRequestErr $ Error.InvalidRange $
Error.OutOfBounds (show $ RangeQuery.rangeOffset iTopLevelRange) (maybe "0" show rsTableTotal)
| headersOnly = mempty
| otherwise = LBS.fromStrict rsBody
@@ -178,12 +178,12 @@ actionResponse (DbCrudResult plan@MutateReadPlan{mrMutation=MutationDelete, pMed
Right $ PgrstResponse ovStatus ovHeaders body
actionResponse (DbCrudResult plan@CallReadPlan{pMedia, crInvMthd=invMethod, crProc=proc} RSStandard {..}) ctxApiRequest@ApiRequest{..} _ _ _ _ _ = do
actionResponse (DbCrudResult plan@CallReadPlan{pMedia, crInvMthd=invMethod, crProc=proc} RSStandard {..}) ctxApiRequest@ApiRequest{..} _ AppConfig{..} _ _ _ = do
let
(status, contentRange) =
RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal rsTableTotal
rsOrErrBody = if status == HTTP.status416
then Error.errorPayload $ Error.ApiRequestErr $ Error.InvalidRange
then Error.errorPayload configClientErrorVerbosity $ Error.ApiRequestErr $ Error.InvalidRange
$ Error.OutOfBounds (show $ RangeQuery.rangeOffset iTopLevelRange) (maybe "0" show rsTableTotal)
else LBS.fromStrict rsBody
isHeadMethod = invMethod == InvRead True
+1
View File
@@ -1,3 +1,4 @@
client-error-verbosity = "verbose"
db-aggregates-enabled = false
db-anon-role = ""
db-channel = "pgrst"
@@ -1,3 +1,4 @@
client-error-verbosity = "verbose"
db-aggregates-enabled = false
db-anon-role = ""
db-channel = "pgrst"
@@ -1,3 +1,4 @@
client-error-verbosity = "verbose"
db-aggregates-enabled = false
db-anon-role = ""
db-channel = "pgrst"
+1
View File
@@ -1,3 +1,4 @@
client-error-verbosity = "verbose"
db-aggregates-enabled = false
db-anon-role = ""
db-channel = "pgrst"
@@ -1,3 +1,4 @@
client-error-verbosity = "verbose"
db-aggregates-enabled = false
db-anon-role = ""
db-channel = "pgrst"
@@ -1,3 +1,4 @@
client-error-verbosity = "verbose"
db-aggregates-enabled = false
db-anon-role = ""
db-channel = "pgrst"
@@ -1,3 +1,4 @@
client-error-verbosity = "verbose"
db-aggregates-enabled = false
db-anon-role = ""
db-channel = "pgrst"
@@ -1,3 +1,4 @@
client-error-verbosity = "verbose"
db-aggregates-enabled = false
db-anon-role = ""
db-channel = "pgrst"
@@ -1,3 +1,4 @@
client-error-verbosity = "verbose"
db-aggregates-enabled = false
db-anon-role = ""
db-channel = "pgrst"
@@ -1,3 +1,4 @@
client-error-verbosity = "minimal"
db-aggregates-enabled = false
db-anon-role = "pre_config_role"
db-channel = "postgrest"
@@ -1,3 +1,4 @@
client-error-verbosity = "minimal"
db-aggregates-enabled = false
db-anon-role = "anonymous"
db-channel = "postgrest"
@@ -1,3 +1,4 @@
client-error-verbosity = "minimal"
db-aggregates-enabled = true
db-anon-role = "root"
db-channel = "postgrest"
+1
View File
@@ -1,3 +1,4 @@
client-error-verbosity = "verbose"
db-aggregates-enabled = false
db-anon-role = ""
db-channel = "pgrst"
+1
View File
@@ -1,3 +1,4 @@
client-error-verbosity = "verbose"
db-aggregates-enabled = false
db-anon-role = ""
db-channel = "pgrst"
+1
View File
@@ -1,5 +1,6 @@
PGRST_APP_SETTINGS_test2: test
PGRST_APP_SETTINGS_test: test
PGRST_CLIENT_ERROR_VERBOSITY: minimal
PGRST_DB_AGGREGATES_ENABLED: true
PGRST_DB_ANON_ROLE: root
PGRST_DB_CHANNEL: postgrest
+1
View File
@@ -1,3 +1,4 @@
client-error-verbosity = "minimal"
db-aggregates-enabled = true
db-anon-role = "root"
db-channel = "postgrest"
+2
View File
@@ -2,6 +2,7 @@ CREATE ROLE db_config_authenticator LOGIN NOINHERIT;
-- reloadable config options
-- these settings will override the values in configs/no-defaults.config, so they must be different
ALTER ROLE db_config_authenticator SET pgrst.client_error_verbosity = 'minimal';
ALTER ROLE db_config_authenticator SET pgrst.db_aggregates_enabled = 'false';
ALTER ROLE db_config_authenticator SET pgrst.db_anon_role = 'anonymous';
ALTER ROLE db_config_authenticator SET pgrst.db_extra_search_path = 'public, extensions';
@@ -56,6 +57,7 @@ ALTER ROLE db_config_authenticator SET pgrst.server_unix_socket_mode = 'ignored'
-- other authenticator reloadable config options
-- these settings will override the values in configs/no-defaults.config, so they must be different
CREATE ROLE other_authenticator LOGIN NOINHERIT;
ALTER ROLE other_authenticator SET pgrst.client_error_verbosity = 'minimal';
ALTER ROLE other_authenticator SET pgrst.db_aggregates_enabled = 'false';
ALTER ROLE other_authenticator SET pgrst.db_extra_search_path = 'public, extensions, other';
ALTER ROLE other_authenticator SET pgrst.db_max_rows = '100';
+11
View File
@@ -286,6 +286,17 @@ def test_jwt_secret_min_length(defaultenv):
assert "The JWT secret must be at least 32 characters long." in error
def test_invalid_client_error_verbosity(defaultenv):
"Given an invalid value for client-error-verbosity, Postgrest should exit with a non-zero exit code."
env = {
**defaultenv,
"PGRST_CLIENT_ERROR_VERBOSITY": "invalid",
}
error = cli(["--dump-config"], env=env, expect_error=True)
assert "Invalid client-error-verbosity. Check your configuration." in error
@pytest.mark.parametrize("restricted_schema", FIXTURES["restrictedschemas"])
def test_restricted_db_schemas(restricted_schema, defaultenv):
"Should print error when db-schemas config contain pg_catalog or information_schema"
+32
View File
@@ -1757,3 +1757,35 @@ def test_server_timing_transaction_duration(defaultenv, metapostgrest):
]
assert 2000 <= response_dur < 3000
def test_client_error_verbosity_config(defaultenv):
"Test PostgREST errors with different error verbosity settings"
env = {
**defaultenv,
"PGRST_CLIENT_ERROR_VERBOSITY": "minimal", # hide details and hint
}
with run(env=env) as postgrest:
response = postgrest.session.get("/itemsxx")
assert response.status_code == 404
assert response.json() == {
"code": "PGRST205",
"message": "Could not find the table 'public.itemsxx' in the schema cache",
}
env = {
**defaultenv,
"PGRST_CLIENT_ERROR_VERBOSITY": "verbose",
}
with run(env=env) as postgrest:
response = postgrest.session.get("/itemsxx")
assert response.status_code == 404
assert response.json() == {
"code": "PGRST205",
"message": "Could not find the table 'public.itemsxx' in the schema cache",
"details": None,
"hint": "Perhaps you meant the table 'public.items'",
}
+2 -1
View File
@@ -40,7 +40,7 @@ import PostgREST.Config (AppConfig (..),
JSPathExp (..),
LogLevel (..),
OpenAPIMode (..),
parseSecret)
Verbosity (..), parseSecret)
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..))
import Prometheus (Counter, getCounter)
import Protolude hiding (get, toS)
@@ -121,6 +121,7 @@ baseCfg :: AppConfig
baseCfg = let secret = encodeUtf8 "reallyreallyreallyreallyverysafe" in
AppConfig {
configAppSettings = [ ("app.settings.app_host", "localhost") , ("app.settings.external_api_secret", "0123456789abcdef") ]
, configClientErrorVerbosity = Verbose
, configDbAggregates = False
, configDbAnonRole = Just "postgrest_test_anonymous"
, configDbChannel = mempty