perf: no caching for prefer timezone

`Prefer: timezone` no longer requires the schema cache.
Previously this required caching `pg_timezone_names` which was slow in some systems.

Closes https://github.com/PostgREST/postgrest/issues/5100 and
https://github.com/PostgREST/postgrest/issues/4751.
This commit is contained in:
steve-chavez
2026-08-04 13:41:40 -05:00
committed by Steve Chavez
parent a41396c425
commit 932c4f6328
12 changed files with 74 additions and 1323 deletions
+4
View File
@@ -17,6 +17,9 @@ All notable changes to this project will be documented in this file. From versio
- Add config `server-reuseport` to allow starting multiple PostgREST instances using the same port on supported platforms by @mkleczek in #4703, #4694
- Make config `log-level` reloadable by @taimoorzaeem in #5113
- Optimize schema cache domain type resolution by using `pg_basetype` on PostgreSQL 17+ by @joelonsql in #4567
- `Prefer: timezone` is optimized so it no longer requires the schema cache by @steve-chavez in #5100
+ Previously this required caching `pg_timezone_names` which was slow in some systems
- `Prefer: timezone` now supports numeric offsets like `05:00` or `-4` by @steve-chavez in #5100
### Fixed
@@ -39,6 +42,7 @@ All notable changes to this project will be documented in this file. From versio
- Build a static executable for aarch64-linux by @wolfgangwalther in #4193
- Build the minimal docker image for aarch64-linux by @wolfgangwalther in #4193
- Config `jwt-role-claim-key` now uses RFC 9535 syntax for JSON Path by @taimoorzaeem in #4984
- `Prefer: timezone` no longer complies with `handling=lenient` and instead always fails by @steve-chavez in #5128
#### Changed Syntax for JWT Role Extraction
-1
View File
@@ -74,5 +74,4 @@ Provides the ``schema_cache`` endpoint that prints the runtime :ref:`schema_cach
"dbRepresentations": ["..."],
"dbRoutines": ["..."],
"dbTables": ["..."],
"dbTimezones": ["..."]
}
+5 -24
View File
@@ -62,8 +62,11 @@ The server ignores unrecognized or unfulfillable preferences by default. You can
Timezone
========
The ``timezone`` preference allows you to change the `PostgreSQL timezone <https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-TIMEZONE>`_. It accepts all time zones in `pg_timezone_names <https://www.postgresql.org/docs/current/view-pg-timezone-names.html>`_.
.. important::
``handling=lenient`` is ignored for ``timezone``. Invalid time zones always return an error.
The ``timezone`` preference allows you to change the `PostgreSQL timezone <https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-TIMEZONE>`_. It accepts all time zones in `pg_timezone_names <https://www.postgresql.org/docs/current/view-pg-timezone-names.html>`_.
.. code-block:: bash
@@ -84,35 +87,13 @@ The ``timezone`` preference allows you to change the `PostgreSQL timezone <https
{"t":"2023-10-18T09:37:59.611-07:00"}
]
For an invalid time zone, PostgREST returns values with the default time zone (configured on ``postgresql.conf`` or as a setting on the :ref:`authenticator <roles>`).
For an invalid time zone, PostgREST returns a database error.
.. code-block:: bash
curl -i "http://localhost:3000/timestamps" \
-H "Prefer: timezone=Jupiter/Red_Spot"
.. code-block:: http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
.. code-block:: json
[
{"t":"2023-10-18T12:37:59.611+00:00"},
{"t":"2023-10-18T14:37:59.611+00:00"},
{"t":"2023-10-18T16:37:59.611+00:00"}
]
Note that there's no ``Preference-Applied`` in the response.
However, with ``handling=strict``, an invalid time zone preference will throw an :ref:`error <pgrst122>`.
.. code-block:: bash
curl -i "http://localhost:3000/timestamps" \
-H "Prefer: handling=strict, timezone=Jupiter/Red_Spot"
.. code-block:: http
HTTP/1.1 400 Bad Request
+2 -3
View File
@@ -33,7 +33,6 @@ import PostgREST.ApiRequest.Types (Action (..), DbAction (..),
Payload (..), RequestBody,
Resource (..))
import PostgREST.Config (AppConfig (..), OpenAPIMode (..))
import PostgREST.Config.Database (TimezoneNames)
import PostgREST.Error (ApiRequestError (..), RangeError (..))
import PostgREST.MediaType (MediaType (..))
import PostgREST.RangeQuery (NonnegRange, allRange,
@@ -109,8 +108,8 @@ userApiRequest conf prefs req reqBody = do
actIsInvokeSafe x = case x of {ActDb (ActRoutine _ (InvRead _)) -> True; _ -> False}
-- | Parses the Prefer header
userPreferences :: AppConfig -> Request -> TimezoneNames -> Preferences.Preferences
userPreferences conf req timezones = Preferences.fromHeaders (configDbTxAllowOverride conf) timezones $ requestHeaders req
userPreferences :: AppConfig -> Request -> Preferences.Preferences
userPreferences conf req = Preferences.fromHeaders (configDbTxAllowOverride conf) $ requestHeaders req
-- | Obtains the Bearer Auth
userBearerAuth :: Request -> Maybe ByteString
@@ -26,11 +26,8 @@ module PostgREST.ApiRequest.Preferences
import qualified Data.ByteString.Char8 as BS
import qualified Data.Map as Map
import qualified Data.Set as S
import qualified Network.HTTP.Types.Header as HTTP
import PostgREST.Config.Database (TimezoneNames)
import Protolude
-- $setup
@@ -66,10 +63,8 @@ data Preferences
-- |
-- Parse HTTP headers based on RFC7240[1] to identify preferences.
--
-- >>> let sc = S.fromList ["America/Los_Angeles"]
--
-- One header with comma-separated values can be used to set multiple preferences:
-- >>> pPrint $ fromHeaders True sc [("Prefer", "resolution=ignore-duplicates, count=exact, timezone=America/Los_Angeles, max-affected=100")]
-- >>> pPrint $ fromHeaders True [("Prefer", "resolution=ignore-duplicates, count=exact, timezone=America/Los_Angeles, max-affected=100")]
-- Preferences
-- { preferResolution = Just IgnoreDuplicates
-- , preferRepresentation = Nothing
@@ -86,7 +81,7 @@ data Preferences
--
-- Multiple headers can also be used:
--
-- >>> pPrint $ fromHeaders True sc [("Prefer", "resolution=ignore-duplicates"), ("Prefer", "count=exact"), ("Prefer", "missing=null"), ("Prefer", "handling=lenient"), ("Prefer", "invalid"), ("Prefer", "max-affected=5999")]
-- >>> pPrint $ fromHeaders True [("Prefer", "resolution=ignore-duplicates"), ("Prefer", "count=exact"), ("Prefer", "missing=null"), ("Prefer", "handling=lenient"), ("Prefer", "invalid"), ("Prefer", "max-affected=5999")]
-- Preferences
-- { preferResolution = Just IgnoreDuplicates
-- , preferRepresentation = Nothing
@@ -102,13 +97,13 @@ data Preferences
--
-- If a preference is set more than once, only the first is used:
--
-- >>> preferTransaction $ fromHeaders True sc [("Prefer", "tx=commit, tx=rollback")]
-- >>> preferTransaction $ fromHeaders True [("Prefer", "tx=commit, tx=rollback")]
-- Just Commit
--
-- This is also the case across multiple headers:
--
-- >>> :{
-- preferResolution . fromHeaders True sc $
-- preferResolution . fromHeaders True $
-- [ ("Prefer", "resolution=ignore-duplicates")
-- , ("Prefer", "resolution=merge-duplicates")
-- ]
@@ -118,7 +113,7 @@ data Preferences
--
-- Preferences can be separated by arbitrary amounts of space, lower-case header is also recognized:
--
-- >>> pPrint $ fromHeaders True sc [("prefer", "count=exact, tx=commit ,return=representation , missing=default, handling=strict, anything")]
-- >>> pPrint $ fromHeaders True [("prefer", "count=exact, tx=commit ,return=representation , missing=default, handling=strict, anything")]
-- Preferences
-- { preferResolution = Nothing
-- , preferRepresentation = Just Full
@@ -131,8 +126,8 @@ data Preferences
-- , invalidPrefs = [ "anything" ]
-- }
--
fromHeaders :: Bool -> TimezoneNames -> [HTTP.Header] -> Preferences
fromHeaders allowTxDbOverride acceptedTzNames headers =
fromHeaders :: Bool -> [HTTP.Header] -> Preferences
fromHeaders allowTxDbOverride headers =
Preferences
{ preferResolution = parsePrefs [MergeDuplicates, IgnoreDuplicates]
, preferRepresentation = parsePrefs [Full, None, HeadersOnly]
@@ -140,7 +135,7 @@ fromHeaders allowTxDbOverride acceptedTzNames headers =
, preferTransaction = if allowTxDbOverride then parsePrefs [Commit, Rollback] else Nothing
, preferMissing = parsePrefs [ApplyDefaults, ApplyNulls]
, preferHandling = parsePrefs [Strict, Lenient]
, preferTimezone = if isTimezonePrefAccepted then PreferTimezone <$> timezonePref else Nothing
, preferTimezone = PreferTimezone <$> timezonePref
, preferMaxAffected = PreferMaxAffected <$> maxAffectedPref
, invalidPrefs = filter isUnacceptable prefs
}
@@ -160,12 +155,11 @@ fromHeaders allowTxDbOverride acceptedTzNames headers =
listStripPrefix prefix prefList = listToMaybe $ mapMaybe (BS.stripPrefix prefix) prefList
timezonePref = listStripPrefix "timezone=" prefs
isTimezonePrefAccepted = ((S.member . decodeUtf8 <$> timezonePref) <*> pure acceptedTzNames) == Just True
maxAffectedPref = listStripPrefix "max-affected=" prefs >>= readMaybe . BS.unpack
isUnacceptable p = p `notElem` acceptedPrefs &&
(isNothing (BS.stripPrefix "timezone=" p) || not isTimezonePrefAccepted) &&
isNothing (BS.stripPrefix "timezone=" p) &&
isNothing (BS.stripPrefix "max-affected=" p)
parsePrefs :: ToHeaderValue a => [a] -> Maybe a
+1 -1
View File
@@ -204,7 +204,7 @@ postgrestResponse appState conf@AppConfig{..} maybeSchemaCache jwtTime authResul
liftIO $ observer SchemaCacheEmptyObs
throwError Error.NoSchemaCacheError
let prefs = ApiRequest.userPreferences conf req (dbTimezones sCache)
let prefs = ApiRequest.userPreferences conf req
body <- liftIO $ Wai.strictRequestBody req
-2
View File
@@ -7,7 +7,6 @@ module PostgREST.Config.Database
, queryRoleSettings
, RoleSettings
, RoleIsolationLvl
, TimezoneNames
, toIsolationLevel
) where
@@ -31,7 +30,6 @@ import Protolude
type RoleSettings = (HM.HashMap ByteString (HM.HashMap ByteString ByteString))
type RoleIsolationLvl = HM.HashMap ByteString SQL.IsolationLevel
type TimezoneNames = Set Text -- cache timezone names for prefer timezone=
toIsolationLevel :: Text -> SQL.IsolationLevel
toIsolationLevel a = case T.toLower a of
+9 -31
View File
@@ -45,7 +45,7 @@ import Data.Functor.Contravariant ((>$<))
import NeatInterpolation (trimming)
import PostgREST.Config (AppConfig (..), LogLevel (..))
import PostgREST.Config.Database (TimezoneNames, toIsolationLevel)
import PostgREST.Config.Database (toIsolationLevel)
import PostgREST.Config.PgVersion (PgVersion, pgVersion170)
import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier (..),
@@ -78,7 +78,6 @@ data SchemaCache = SchemaCache
, dbRoutines :: RoutineMap
, dbRepresentations :: RepresentationsMap
, dbMediaHandlers :: MediaHandlerMap
, dbTimezones :: TimezoneNames
-- Memoized fuzzy index of table names per schema to support approximate matching
-- Since index construction can be expensive, we build it once and store in the SchemaCache
-- Haskell lazy evaluation ensures it's only built on first use and memoized afterwards
@@ -86,24 +85,22 @@ data SchemaCache = SchemaCache
} deriving (Show)
instance JSON.ToJSON SchemaCache where
toJSON (SchemaCache tabs rels routs reps hdlers tzs _) = JSON.object [
toJSON (SchemaCache tabs rels routs reps hdlers _) = JSON.object [
"dbTables" .= JSON.toJSON tabs
, "dbRelationships" .= JSON.toJSON rels
, "dbRoutines" .= JSON.toJSON routs
, "dbRepresentations" .= JSON.toJSON reps
, "dbMediaHandlers" .= JSON.toJSON hdlers
, "dbTimezones" .= JSON.toJSON tzs
]
showSummary :: SchemaCache -> Text
showSummary (SchemaCache tbls rels routs reps mediaHdlrs tzs _) =
showSummary (SchemaCache tbls rels routs reps mediaHdlrs _) =
T.intercalate ", "
[ show (HM.size tbls) <> " Relations"
, show (HM.size rels) <> " Relationships"
, show (HM.size routs) <> " RPCs"
, show (HM.size reps) <> " Domain Representations"
, show (HM.size mediaHdlrs) <> " Media Type Handlers"
, show (S.size tzs) <> " Timezones"
]
-- | A view foreign key or primary key dependency detected on its source table
@@ -160,13 +157,12 @@ querySchemaCache pgVer conf@AppConfig{..} = do
cRels <- sqlTimedStmt gucCRels mempty allComputedRels
reps <- sqlTimedStmt gucDReps conf dataRepresentations
mHdlers <- sqlTimedStmt gucMHdrs conf mediaHandlers
tzones <- sqlTimedStmt gucTzones mempty timezones
for_ configInternalSCQuerySleepSnd (`SQL.statement` sleepCall) -- only used for testing
qsTime <-
if isLogDebug
then Just <$> SQL.statement mempty (extractTimings True)
then Just <$> SQL.statement mempty extractTimings
else pure Nothing
let tabsWViewsPks = addViewPrimaryKeys tabs keyDeps
@@ -178,7 +174,6 @@ querySchemaCache pgVer conf@AppConfig{..} = do
, dbRoutines = funcs
, dbRepresentations = reps
, dbMediaHandlers = HM.union mHdlers initialMediaHandlers -- the custom handlers will override the initial ones
, dbTimezones = tzones
, dbTablesFuzzyIndex =
-- Only build fuzzy index for schemas with a reasonable number of tables
@@ -221,7 +216,6 @@ removeInternal schemas dbStruct =
, dbRoutines = dbRoutines dbStruct -- procs are only obtained from the exposed schemas, no need to filter them.
, dbRepresentations = dbRepresentations dbStruct -- no need to filter, not directly exposed through the API
, dbMediaHandlers = dbMediaHandlers dbStruct
, dbTimezones = dbTimezones dbStruct
, dbTablesFuzzyIndex = dbTablesFuzzyIndex dbStruct
}
where
@@ -1108,19 +1102,6 @@ decodeMediaHandlers =
<*> (MediaType.decodeMediaType . encodeUtf8 <$> column HD.text)
<*> (MediaType.decodeMediaType . encodeUtf8 <$> column HD.text)
timezones :: SQL.Statement () TimezoneNames
timezones = SQL.Statement sql HE.noParams decodeTimezones True
where
sql = encodeUtf8 $ unlines
-- This CTE wrapper is only added for clarifying the query under pg_stat_statements
["WITH pgrst_timezones AS ("
, " SELECT name FROM pg_timezone_names"
, ")"
, "SELECT * FROM pgrst_timezones"
]
decodeTimezones :: HD.Result TimezoneNames
decodeTimezones = S.fromList <$> HD.rowList (column HD.text)
param :: HE.Value a -> HE.Params a
param = HE.param . HE.nonNullable
@@ -1168,21 +1149,21 @@ sqlTimedStatement isLogDebug guc params stmt =
eFrag = "select set_config('pgrst." <> guc <> "', (clock_timestamp() - current_setting('pgrst." <> guc <> "', false)::timestamptz)::text, true)"
-- Extract all the generated timings (see sqlTimedStatement) converting the value to milliseconds.
extractTimings :: Bool -> SQL.Statement () QueryTimings
extractTimings hasTimezones = SQL.Statement sql HE.noParams decodeThem True
extractTimings :: SQL.Statement () QueryTimings
extractTimings = SQL.Statement sql HE.noParams decodeThem True
where
qFrag setting = "extract('milliseconds' from current_setting('pgrst." <> setting <> "', false)::interval)::text"
sql = "SELECT " <> BS.intercalate ","
[ qFrag gucTbls, qFrag gucKDeps, qFrag gucRels
, qFrag gucFuncs, qFrag gucCRels, qFrag gucDReps
, qFrag gucMHdrs, if hasTimezones then qFrag gucTzones else "'0.0'"
, qFrag gucMHdrs
]
decodeThem :: HD.Result QueryTimings
decodeThem = HD.singleRow $
QueryTimings
<$> column HD.text <*> column HD.text <*> column HD.text
<*> column HD.text <*> column HD.text <*> column HD.text
<*> column HD.text <*> column HD.text
<*> column HD.text
data QueryTimings = QueryTimings
{ qtTables :: Text
@@ -1192,7 +1173,6 @@ data QueryTimings = QueryTimings
, qtCRels :: Text
, qtDReps :: Text
, qtMHdrs :: Text
, qtTzones :: Text
} deriving (Show)
queryTimingsWLabels :: QueryTimings -> [(ByteString, Text)]
@@ -1204,10 +1184,9 @@ queryTimingsWLabels qt =
, (gucCRels, qtCRels qt)
, (gucDReps, qtDReps qt)
, (gucMHdrs, qtMHdrs qt)
, (gucTzones, qtTzones qt)
]
gucTbls, gucKDeps, gucRels, gucFuncs, gucCRels, gucDReps, gucMHdrs, gucTzones :: ByteString
gucTbls, gucKDeps, gucRels, gucFuncs, gucCRels, gucDReps, gucMHdrs :: ByteString
gucTbls = "tables"
gucKDeps = "keydeps"
gucRels = "rels"
@@ -1215,4 +1194,3 @@ gucFuncs = "funcs"
gucCRels = "comprels"
gucDReps = "dreps"
gucMHdrs = "mhandlers"
gucTzones = "tzones"
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -237,7 +237,6 @@ def test_invalid_openapi_mode(invalidopenapimodes, defaultenv):
"dbRepresentations",
"dbRoutines",
"dbTables",
"dbTimezones",
],
)
def test_schema_cache_snapshot(baseenv, key, snapshot_yaml):
@@ -248,7 +247,7 @@ def test_schema_cache_snapshot(baseenv, key, snapshot_yaml):
schema_cache[key],
encoding="utf8",
allow_unicode=True,
Dumper=yaml.SafeDumper if key == "dbTimezones" else ExtraNewLinesDumper,
Dumper=ExtraNewLinesDumper,
)
assert formatted == snapshot_yaml
+1 -3
View File
@@ -1002,9 +1002,8 @@ def test_schema_cache_query_timings_log(level, defaultenv):
**defaultenv,
"PGRST_LOG_LEVEL": level,
}
# here we also capture the tzones: <value> ms
log_pattern = re.compile(
r".+: tables: [\d.]+ ms, keydeps: [\d.]+ ms, rels: [\d.]+ ms, funcs: [\d.]+ ms, comprels: [\d.]+ ms, dreps: [\d.]+ ms, mhandlers: [\d.]+ ms, tzones: ([\d.]+) ms"
r".+: tables: [\d.]+ ms, keydeps: [\d.]+ ms, rels: [\d.]+ ms, funcs: [\d.]+ ms, comprels: [\d.]+ ms, dreps: [\d.]+ ms, mhandlers: [\d.]+ ms"
)
with run(env=env, no_startup_stdout=False) as postgrest:
@@ -1015,7 +1014,6 @@ def test_schema_cache_query_timings_log(level, defaultenv):
if level == "debug":
assert len(timing_matches) == 1
assert float(timing_matches[0].group(1)) > 0
else:
assert not timing_matches
@@ -9,52 +9,49 @@ import Protolude hiding (get)
import SpecHelper
spec :: SpecWithConfig
spec withConfig = withConfig baseCfg $
describe "test Prefer: timezone with db-timezone-enabled is true" $ do
context "test Prefer: timezone=America/Los_Angeles" $ do
it "should change timezone with handling=strict" $
request methodGet "/timestamps"
[("Prefer", "handling=strict, timezone=America/Los_Angeles")]
""
`shouldRespondWith`
[json|[{"t":"2023-10-18T05:37:59.611-07:00"}, {"t":"2023-10-18T07:37:59.611-07:00"}, {"t":"2023-10-18T09:37:59.611-07:00"}]|]
{ matchStatus = 200
, matchHeaders = [matchContentTypeJson
, "Preference-Applied" <:> "handling=strict, timezone=America/Los_Angeles"]}
spec withConfig = withConfig baseCfg $ do
context "test Prefer: timezone=America/Los_Angeles" $ do
it "should change timezone with handling=strict" $
request methodGet "/timestamps"
[("Prefer", "handling=strict, timezone=America/Los_Angeles")]
""
`shouldRespondWith`
[json|[{"t":"2023-10-18T05:37:59.611-07:00"}, {"t":"2023-10-18T07:37:59.611-07:00"}, {"t":"2023-10-18T09:37:59.611-07:00"}]|]
{ matchStatus = 200
, matchHeaders = [matchContentTypeJson
, "Preference-Applied" <:> "handling=strict, timezone=America/Los_Angeles"]}
it "should change timezone without handling=strict" $
request methodGet "/timestamps"
[("Prefer", "timezone=America/Los_Angeles")]
""
`shouldRespondWith`
[json|[{"t":"2023-10-18T05:37:59.611-07:00"}, {"t":"2023-10-18T07:37:59.611-07:00"}, {"t":"2023-10-18T09:37:59.611-07:00"}]|]
{ matchStatus = 200
, matchHeaders = [matchContentTypeJson
, "Preference-Applied" <:> "timezone=America/Los_Angeles"] }
it "should change timezone without handling=strict" $
request methodGet "/timestamps"
[("Prefer", "timezone=America/Los_Angeles")]
""
`shouldRespondWith`
[json|[{"t":"2023-10-18T05:37:59.611-07:00"}, {"t":"2023-10-18T07:37:59.611-07:00"}, {"t":"2023-10-18T09:37:59.611-07:00"}]|]
{ matchStatus = 200
, matchHeaders = [matchContentTypeJson
, "Preference-Applied" <:> "timezone=America/Los_Angeles"] }
context "test Prefer: timezone=Invalid/Timezone" $ do
it "should throw error with handling=strict" $
request methodGet "/timestamps"
[("Prefer", "handling=strict, timezone=Invalid/Timezone")]
""
`shouldRespondWith`
[json|{"code":"PGRST122","details":"Invalid preferences: timezone=Invalid/Timezone","hint":null,"message":"Invalid preferences given with handling=strict"}|]
{ matchStatus = 400 }
context "test Prefer: timezone=Invalid/Timezone" $ do
it "should throw error without handling" $
request methodGet "/timestamps"
[("Prefer", "timezone=Invalid/Timezone")]
""
`shouldRespondWith`
[json|{"code":"22023","details":null,"hint":null,"message":"invalid value for parameter \"TimeZone\": \"Invalid/Timezone\""}|]
{ matchStatus = 400 }
it "should return with default timezone without handling or with handling=lenient" $ do
request methodGet "/timestamps"
[("Prefer", "timezone=Invalid/Timezone")]
""
`shouldRespondWith`
[json|[{"t":"2023-10-18T12:37:59.611+00:00"}, {"t":"2023-10-18T14:37:59.611+00:00"}, {"t":"2023-10-18T16:37:59.611+00:00"}]|]
{ matchStatus = 200
, matchHeaders = [matchContentTypeJson]}
it "should throw error with handling=strict" $
request methodGet "/timestamps"
[("Prefer", "handling=strict, timezone=Invalid/Timezone")]
""
`shouldRespondWith`
[json|{"code":"22023","details":null,"hint":null,"message":"invalid value for parameter \"TimeZone\": \"Invalid/Timezone\""}|]
{ matchStatus = 400 }
request methodGet "/timestamps"
[("Prefer", "handling=lenient, timezone=Invalid/Timezone")]
""
`shouldRespondWith`
[json|[{"t":"2023-10-18T12:37:59.611+00:00"}, {"t":"2023-10-18T14:37:59.611+00:00"}, {"t":"2023-10-18T16:37:59.611+00:00"}]|]
{ matchStatus = 200
, matchHeaders = [matchContentTypeJson
, "Preference-Applied" <:> "handling=lenient"]}
it "should throw error with handling=lenient" $
request methodGet "/timestamps"
[("Prefer", "handling=lenient, timezone=Invalid/Timezone")]
""
`shouldRespondWith`
[json|{"code":"22023","details":null,"hint":null,"message":"invalid value for parameter \"TimeZone\": \"Invalid/Timezone\""}|]
{ matchStatus = 400 }