Skip to content

Commit 1998a61

Browse files
committed
add: per request statement timeout using prefer header
Adds a feature to set `statement_timeout` using `Prefer: timeout` header. This also introduces a `PGRST129` error which is returned when the timeout preferred exceeds the per-role timeout, which prevents misuse of this feature. Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
1 parent 5a4c11c commit 1998a61

File tree

13 files changed

+399
-84
lines changed

13 files changed

+399
-84
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file. From versio
88

99
- Log error when `db-schemas` config contains schema `pg_catalog` or `information_schema` by @taimoorzaeem in #4359
1010
- Add a `HINT` when the LISTEN channel stops working due to a PostgreSQL bug by @laurenceisla in #4581
11+
- Add `Prefer: timeout` header for per-request `statement_timeout` by @taimoorzaeem in #4381
1112

1213
## [14.3] - 2026-01-03
1314

docs/references/api/preferences.rst

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ The following preferences are supported.
1515
- ``Prefer: missing``. See :ref:`prefer_missing`.
1616
- ``Prefer: max-affected``, See :ref:`prefer_max_affected`.
1717
- ``Prefer: tx``. See :ref:`prefer_tx`.
18+
- ``Prefer: timeout``. See :ref:`prefer_timeout`.
1819

1920
.. _prefer_handling:
2021

@@ -296,3 +297,62 @@ With :ref:`RPC <functions>`, the preference is honored completely on the basis o
296297
.. note::
297298
298299
It is important for functions to return ``SETOF`` or ``TABLE`` when called with ``max-affected`` preference. A violation of this would cause a :ref:`PGRST128 <pgrst128>` error.
300+
301+
.. _prefer_timeout:
302+
303+
Timeout
304+
=======
305+
306+
You can set `statement_timeout <https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-STATEMENT-TIMEOUT>`_ for the request using this preference. This works in combination with ``handling=strict`` preference in the same header.
307+
308+
The header only accepts integer value indicating the ``seconds`` that are set as timeout value. To demonstrate, see the following example:
309+
310+
.. code-block:: postgres
311+
312+
CREATE FUNCTION test.sleep(seconds)
313+
RETURNS VOID AS $$
314+
SELECT pg_sleep(seconds);
315+
$$ LANGUAGE SQL;
316+
317+
.. code-block:: bash
318+
319+
curl -i "http://localhost:3000/rpc/sleep?seconds=5" \
320+
-H "Prefer: handling=strict, timeout=2"
321+
322+
.. code-block:: http
323+
324+
HTTP/1.1 500 Internal Server Error
325+
326+
.. code-block:: json
327+
328+
{
329+
"code": "57014",
330+
"details": null,
331+
"hint": null,
332+
"message": "canceling statement due to statement timeout"
333+
}
334+
335+
It is important to note the timeout value cannot exceed the ``statement_timeout`` set :ref:`per-role <impersonated_settings>`. This restriction prevents misuse of this feature. PostgREST returns a :ref:`PGRST129 <pgrst129>` error in this case.
336+
337+
.. code-block:: postgres
338+
339+
ALTER ROLE postgrest_test_anonymous SET statement_timeout = '3s';
340+
341+
.. code-block:: bash
342+
343+
curl -i "http://localhost:3000/rpc/sleep?seconds=4" \
344+
-H "Prefer: handling=strict, timeout=5"
345+
346+
.. code-block:: http
347+
348+
HTTP/1.1 400 Bad Request
349+
Content-Type: application/json; charset=utf-8
350+
351+
.. code-block:: json
352+
353+
{
354+
"code": "PGRST129",
355+
"message": "Timeout preference value cannot exceed statement_timeout of role",
356+
"details": "Timeout preferred: 5s, statement_timeout of role 'postgrest_test_anonymous': 3s",
357+
"hint": null
358+
}

docs/references/errors.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,10 @@ Related to the HTTP request elements.
271271
| | | See :ref:`prefer_max_affected`. |
272272
| PGRST128 | | |
273273
+---------------+-------------+-------------------------------------------------------------+
274+
| .. _pgrst129: | 400 | ``timeout`` preference exceeds ``statement_timeout`` value |
275+
| | | of role. See :ref:`prefer_timeout`. |
276+
| PGRST129 | | |
277+
+---------------+-------------+-------------------------------------------------------------+
274278

275279

276280
.. _pgrst2**:

postgrest.cabal

Lines changed: 30 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -258,36 +258,37 @@ test-suite spec
258258
Feature.RollbackSpec
259259
Feature.RpcPreRequestGucsSpec
260260
SpecHelper
261-
build-depends: base >= 4.9 && < 4.20
262-
, aeson >= 2.0.3 && < 2.3
263-
, aeson-qq >= 0.8.1 && < 0.9
264-
, async >= 2.1.1 && < 2.3
265-
, base64-bytestring >= 1 && < 1.3
266-
, bytestring >= 0.10.8 && < 0.13
267-
, case-insensitive >= 1.2 && < 1.3
268-
, containers >= 0.5.7 && < 0.7
269-
, hasql-pool >= 1.0.1 && < 1.1
270-
, hasql-transaction >= 1.0.1 && < 1.2
271-
, heredoc >= 0.2 && < 0.3
272-
, hspec >= 2.3 && < 2.12
273-
, hspec-expectations >= 0.8.4 && < 0.9
274-
, hspec-wai >= 0.10 && < 0.12
275-
, hspec-wai-json >= 0.10 && < 0.12
276-
, http-types >= 0.12.3 && < 0.13
277-
, jose-jwt >= 0.9.6 && < 0.11
278-
, lens >= 4.14 && < 5.4
279-
, lens-aeson >= 1.0.1 && < 1.3
280-
, monad-control >= 1.0.1 && < 1.1
261+
build-depends: base >= 4.9 && < 4.20
262+
, aeson >= 2.0.3 && < 2.3
263+
, aeson-qq >= 0.8.1 && < 0.9
264+
, async >= 2.1.1 && < 2.3
265+
, base64-bytestring >= 1 && < 1.3
266+
, bytestring >= 0.10.8 && < 0.13
267+
, case-insensitive >= 1.2 && < 1.3
268+
, containers >= 0.5.7 && < 0.7
269+
, hasql-pool >= 1.0.1 && < 1.1
270+
, hasql-transaction >= 1.0.1 && < 1.2
271+
, heredoc >= 0.2 && < 0.3
272+
, hspec >= 2.3 && < 2.12
273+
, hspec-expectations >= 0.8.4 && < 0.9
274+
, hspec-wai >= 0.10 && < 0.12
275+
, hspec-wai-json >= 0.10 && < 0.12
276+
, http-types >= 0.12.3 && < 0.13
277+
, jose-jwt >= 0.9.6 && < 0.11
278+
, lens >= 4.14 && < 5.4
279+
, lens-aeson >= 1.0.1 && < 1.3
280+
, monad-control >= 1.0.1 && < 1.1
281281
, postgrest
282-
, process >= 1.4.2 && < 1.7
283-
, prometheus-client >= 1.1.1 && < 1.2.0
284-
, protolude >= 0.3.1 && < 0.4
285-
, regex-tdfa >= 1.2.2 && < 1.4
286-
, scientific >= 0.3.4 && < 0.4
287-
, text >= 1.2.2 && < 2.2
288-
, transformers-base >= 0.4.4 && < 0.5
289-
, wai >= 3.2.1 && < 3.3
290-
, wai-extra >= 3.0.19 && < 3.2
282+
, process >= 1.4.2 && < 1.7
283+
, prometheus-client >= 1.1.1 && < 1.2.0
284+
, protolude >= 0.3.1 && < 0.4
285+
, regex-tdfa >= 1.2.2 && < 1.4
286+
, scientific >= 0.3.4 && < 0.4
287+
, text >= 1.2.2 && < 2.2
288+
, transformers-base >= 0.4.4 && < 0.5
289+
, unordered-containers >= 0.2.8 && < 0.3
290+
, wai >= 3.2.1 && < 3.3
291+
, wai-extra >= 3.0.19 && < 3.2
291292
ghc-options: -threaded -O0 -Werror -Wall -fwarn-identities
292293
-fno-spec-constr -optP-Wno-nonportable-include-path
293294
-fno-warn-missing-signatures

src/PostgREST/ApiRequest/Preferences.hs

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
--
77
-- [1] https://datatracker.ietf.org/doc/html/rfc7240
88
--
9-
{-# LANGUAGE NamedFieldPuns #-}
9+
{-# LANGUAGE RecordWildCards #-}
1010
module PostgREST.ApiRequest.Preferences
1111
( Preferences(..)
1212
, PreferCount(..)
@@ -17,6 +17,7 @@ module PostgREST.ApiRequest.Preferences
1717
, PreferTransaction(..)
1818
, PreferTimezone(..)
1919
, PreferMaxAffected(..)
20+
, PreferTimeout(..)
2021
, fromHeaders
2122
, shouldCount
2223
, shouldExplainCount
@@ -43,6 +44,7 @@ import Protolude
4344
-- >>> deriving instance Show PreferHandling
4445
-- >>> deriving instance Show PreferTimezone
4546
-- >>> deriving instance Show PreferMaxAffected
47+
-- >>> deriving instance Show PreferTimeout
4648
-- >>> deriving instance Show Preferences
4749

4850
-- | Preferences recognized by the application.
@@ -56,6 +58,7 @@ data Preferences
5658
, preferHandling :: Maybe PreferHandling
5759
, preferTimezone :: Maybe PreferTimezone
5860
, preferMaxAffected :: Maybe PreferMaxAffected
61+
, preferTimeout :: Maybe PreferTimeout
5962
, invalidPrefs :: [ByteString]
6063
}
6164

@@ -77,12 +80,13 @@ data Preferences
7780
-- ( PreferTimezone "America/Los_Angeles" )
7881
-- , preferMaxAffected = Just
7982
-- ( PreferMaxAffected 100 )
83+
-- , preferTimeout = Nothing
8084
-- , invalidPrefs = []
8185
-- }
8286
--
8387
-- Multiple headers can also be used:
8488
--
85-
-- >>> pPrint $ fromHeaders True sc [("Prefer", "resolution=ignore-duplicates"), ("Prefer", "count=exact"), ("Prefer", "missing=null"), ("Prefer", "handling=lenient"), ("Prefer", "invalid"), ("Prefer", "max-affected=5999")]
89+
-- >>> pPrint $ fromHeaders True sc [("Prefer", "resolution=ignore-duplicates"), ("Prefer", "count=exact"), ("Prefer", "missing=null"), ("Prefer", "handling=lenient"), ("Prefer", "invalid"), ("Prefer", "max-affected=5999"), ("Prefer", "timeout=10")]
8690
-- Preferences
8791
-- { preferResolution = Just IgnoreDuplicates
8892
-- , preferRepresentation = Nothing
@@ -93,6 +97,8 @@ data Preferences
9397
-- , preferTimezone = Nothing
9498
-- , preferMaxAffected = Just
9599
-- ( PreferMaxAffected 5999 )
100+
-- , preferTimeout = Just
101+
-- ( PreferTimeout 10 )
96102
-- , invalidPrefs = [ "invalid" ]
97103
-- }
98104
--
@@ -124,6 +130,7 @@ data Preferences
124130
-- , preferHandling = Just Strict
125131
-- , preferTimezone = Nothing
126132
-- , preferMaxAffected = Nothing
133+
-- , preferTimeout = Nothing
127134
-- , invalidPrefs = [ "anything" ]
128135
-- }
129136
--
@@ -138,7 +145,8 @@ fromHeaders allowTxDbOverride acceptedTzNames headers =
138145
, preferHandling = parsePrefs [Strict, Lenient]
139146
, preferTimezone = if isTimezonePrefAccepted then PreferTimezone <$> timezonePref else Nothing
140147
, preferMaxAffected = PreferMaxAffected <$> maxAffectedPref
141-
, invalidPrefs = filter isUnacceptable prefs
148+
, preferTimeout = PreferTimeout <$> timeoutPref
149+
, invalidPrefs = filter (not . isPrefValid) prefs
142150
}
143151
where
144152
mapToHeadVal :: ToHeaderValue a => [a] -> [ByteString]
@@ -159,10 +167,13 @@ fromHeaders allowTxDbOverride acceptedTzNames headers =
159167
isTimezonePrefAccepted = ((S.member . decodeUtf8 <$> timezonePref) <*> pure acceptedTzNames) == Just True
160168

161169
maxAffectedPref = listStripPrefix "max-affected=" prefs >>= readMaybe . BS.unpack
170+
timeoutPref = listStripPrefix "timeout=" prefs >>= readMaybe . BS.unpack
162171

163-
isUnacceptable p = p `notElem` acceptedPrefs &&
164-
(isNothing (BS.stripPrefix "timezone=" p) || not isTimezonePrefAccepted) &&
165-
isNothing (BS.stripPrefix "max-affected=" p)
172+
isPrefValid p =
173+
p `elem` acceptedPrefs ||
174+
(isJust (BS.stripPrefix "timezone=" p) && isTimezonePrefAccepted) ||
175+
isJust (BS.stripPrefix "max-affected=" p) ||
176+
isJust (BS.stripPrefix "timeout=" p)
166177

167178
parsePrefs :: ToHeaderValue a => [a] -> Maybe a
168179
parsePrefs vals =
@@ -171,8 +182,9 @@ fromHeaders allowTxDbOverride acceptedTzNames headers =
171182
prefMap :: ToHeaderValue a => [a] -> Map.Map ByteString a
172183
prefMap = Map.fromList . fmap (\pref -> (toHeaderValue pref, pref))
173184

185+
174186
prefAppliedHeader :: Preferences -> Maybe HTTP.Header
175-
prefAppliedHeader Preferences {preferResolution, preferRepresentation, preferCount, preferTransaction, preferMissing, preferHandling, preferTimezone, preferMaxAffected } =
187+
prefAppliedHeader Preferences{..} =
176188
if null prefsVals
177189
then Nothing
178190
else Just (HTTP.hPreferenceApplied, combined)
@@ -187,6 +199,7 @@ prefAppliedHeader Preferences {preferResolution, preferRepresentation, preferCou
187199
, toHeaderValue <$> preferHandling
188200
, toHeaderValue <$> preferTimezone
189201
, if preferHandling == Just Strict then toHeaderValue <$> preferMaxAffected else Nothing
202+
, if preferHandling == Just Strict then toHeaderValue <$> preferTimeout else Nothing
190203
]
191204

192205
-- |
@@ -289,3 +302,10 @@ newtype PreferMaxAffected = PreferMaxAffected Int64
289302

290303
instance ToHeaderValue PreferMaxAffected where
291304
toHeaderValue (PreferMaxAffected n) = "max-affected=" <> show n
305+
306+
-- |
307+
-- Statement Timeout per request
308+
newtype PreferTimeout = PreferTimeout Int
309+
310+
instance ToHeaderValue PreferTimeout where
311+
toHeaderValue (PreferTimeout n) = "timeout=" <> show n

src/PostgREST/App.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ postgrestResponse appState conf@AppConfig{..} maybeSchemaCache authResult@AuthRe
147147
prefs = ApiRequest.userPreferences conf req timezones
148148

149149
(parseTime, apiReq@ApiRequest{..}) <- withTiming $ liftEither . mapLeft Error.ApiRequestError $ ApiRequest.userApiRequest conf prefs req body
150-
(planTime, plan) <- withTiming $ liftEither $ Plan.actionPlan iAction conf apiReq sCache
150+
(planTime, plan) <- withTiming $ liftEither $ Plan.actionPlan iAction conf apiReq authResult sCache
151151

152152
let mainQ = Query.mainQuery plan conf apiReq authResult configDbPreRequest
153153
tx = MainTx.mainTx mainQ conf authResult apiReq plan sCache

src/PostgREST/Error.hs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ data ApiRequestError
9999
| InvalidResourcePath
100100
| OpenAPIDisabled
101101
| MaxAffectedRpcViolation
102+
| TimeoutConstraintError Int ByteString ByteString
102103
deriving Show
103104

104105
data QPError = QPError Text Text
@@ -142,6 +143,7 @@ instance PgrstError ApiRequestError where
142143
status InvalidResourcePath = HTTP.status404
143144
status OpenAPIDisabled = HTTP.status404
144145
status MaxAffectedRpcViolation = HTTP.status400
146+
status TimeoutConstraintError{} = HTTP.status400
145147

146148
headers _ = mempty
147149

@@ -189,6 +191,7 @@ instance ErrorBody ApiRequestError where
189191
code OpenAPIDisabled = "PGRST126"
190192
code NotImplemented{} = "PGRST127"
191193
code MaxAffectedRpcViolation = "PGRST128"
194+
code TimeoutConstraintError{} = "PGRST129"
192195

193196
-- MESSAGE: Text
194197
message (QueryParamError (QPError msg _)) = msg
@@ -215,6 +218,7 @@ instance ErrorBody ApiRequestError where
215218
message OpenAPIDisabled = "Root endpoint metadata is disabled"
216219
message (NotImplemented _) = "Feature not implemented"
217220
message MaxAffectedRpcViolation = "Function must return SETOF or TABLE when max-affected preference is used with handling=strict"
221+
message TimeoutConstraintError{} = "Timeout preference value cannot exceed statement_timeout of role"
218222

219223
-- DETAILS: Maybe JSON.Value
220224
details (QueryParamError (QPError _ dets)) = Just $ JSON.String dets
@@ -230,6 +234,7 @@ instance ErrorBody ApiRequestError where
230234
details (InvalidPreferences prefs) = Just $ JSON.String $ T.decodeUtf8 ("Invalid preferences: " <> BS.intercalate ", " prefs)
231235
details (MaxAffectedViolationError n) = Just $ JSON.String $ T.unwords ["The query affects", show n, "rows"]
232236
details (NotImplemented details') = Just $ JSON.String details'
237+
details (TimeoutConstraintError t rt role) = Just $ JSON.String $ "Timeout preferred: " <> show t <> "s, statement_timeout of role '" <> T.decodeUtf8 role <> "': " <> T.decodeUtf8 rt
233238

234239
details _ = Nothing
235240

0 commit comments

Comments
 (0)