Skip to content

Commit 6963e9a

Browse files
committed
add: per request statement timeout using prefer header
Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
1 parent b8ca1bb commit 6963e9a

6 files changed

Lines changed: 133 additions & 11 deletions

File tree

CHANGELOG.md

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

99
- Log error when `db-schemas` config contains schema `pg_catalog` or `information_schema` by @taimoorzaeem in #4359
10+
- Add `Prefer: timeout` header for per request statement timeout by @taimoorzaeem in #4381
1011

1112
## [14.2] - 2025-12-18
1213

docs/references/api/preferences.rst

Lines changed: 36 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,38 @@ 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+
302+
.. _prefer_timeout:
303+
304+
Timeout
305+
=======
306+
307+
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.
308+
309+
Currently, the header only accepts integer value indicating the ``seconds`` that are set as timeout value. To demonstrate, see the following example:
310+
311+
.. code-block:: postgres
312+
313+
CREATE FUNCTION test.sleep(seconds)
314+
RETURNS VOID AS $$
315+
SELECT pg_sleep(seconds);
316+
$$ LANGUAGE SQL;
317+
318+
.. code-block:: bash
319+
320+
curl -i "http://localhost:3000/rpc/sleep?seconds=5" \
321+
-H "Prefer: handling=strict, timeout=2"
322+
323+
.. code-block:: http
324+
325+
HTTP/1.1 500 Internal Server Error
326+
327+
.. code-block:: json
328+
329+
{
330+
"code": "57014",
331+
"details": null,
332+
"hint": null,
333+
"message": "canceling statement due to statement timeout"
334+
}

src/PostgREST/ApiRequest/Preferences.hs

Lines changed: 25 additions & 6 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,6 +80,7 @@ data Preferences
7780
-- ( PreferTimezone "America/Los_Angeles" )
7881
-- , preferMaxAffected = Just
7982
-- ( PreferMaxAffected 100 )
83+
-- , preferTimeout = Nothing
8084
-- , invalidPrefs = []
8185
-- }
8286
--
@@ -93,6 +97,7 @@ data Preferences
9397
-- , preferTimezone = Nothing
9498
-- , preferMaxAffected = Just
9599
-- ( PreferMaxAffected 5999 )
100+
-- , preferTimeout = Nothing
96101
-- , invalidPrefs = [ "invalid" ]
97102
-- }
98103
--
@@ -124,6 +129,7 @@ data Preferences
124129
-- , preferHandling = Just Strict
125130
-- , preferTimezone = Nothing
126131
-- , preferMaxAffected = Nothing
132+
-- , preferTimeout = Nothing
127133
-- , invalidPrefs = [ "anything" ]
128134
-- }
129135
--
@@ -138,7 +144,8 @@ fromHeaders allowTxDbOverride acceptedTzNames headers =
138144
, preferHandling = parsePrefs [Strict, Lenient]
139145
, preferTimezone = if isTimezonePrefAccepted then PreferTimezone <$> timezonePref else Nothing
140146
, preferMaxAffected = PreferMaxAffected <$> maxAffectedPref
141-
, invalidPrefs = filter isUnacceptable prefs
147+
, preferTimeout = PreferTimeout <$> timeoutPref
148+
, invalidPrefs = filter (not . isPrefValid) prefs
142149
}
143150
where
144151
mapToHeadVal :: ToHeaderValue a => [a] -> [ByteString]
@@ -159,10 +166,13 @@ fromHeaders allowTxDbOverride acceptedTzNames headers =
159166
isTimezonePrefAccepted = ((S.member . decodeUtf8 <$> timezonePref) <*> pure acceptedTzNames) == Just True
160167

161168
maxAffectedPref = listStripPrefix "max-affected=" prefs >>= readMaybe . BS.unpack
169+
timeoutPref = listStripPrefix "timeout=" prefs >>= readMaybe . BS.unpack
162170

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

167177
parsePrefs :: ToHeaderValue a => [a] -> Maybe a
168178
parsePrefs vals =
@@ -171,8 +181,9 @@ fromHeaders allowTxDbOverride acceptedTzNames headers =
171181
prefMap :: ToHeaderValue a => [a] -> Map.Map ByteString a
172182
prefMap = Map.fromList . fmap (\pref -> (toHeaderValue pref, pref))
173183

184+
174185
prefAppliedHeader :: Preferences -> Maybe HTTP.Header
175-
prefAppliedHeader Preferences {preferResolution, preferRepresentation, preferCount, preferTransaction, preferMissing, preferHandling, preferTimezone, preferMaxAffected } =
186+
prefAppliedHeader Preferences{..} =
176187
if null prefsVals
177188
then Nothing
178189
else Just (HTTP.hPreferenceApplied, combined)
@@ -187,6 +198,7 @@ prefAppliedHeader Preferences {preferResolution, preferRepresentation, preferCou
187198
, toHeaderValue <$> preferHandling
188199
, toHeaderValue <$> preferTimezone
189200
, if preferHandling == Just Strict then toHeaderValue <$> preferMaxAffected else Nothing
201+
, if preferHandling == Just Strict then toHeaderValue <$> preferTimeout else Nothing
190202
]
191203

192204
-- |
@@ -289,3 +301,10 @@ newtype PreferMaxAffected = PreferMaxAffected Int64
289301

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

src/PostgREST/Query/PreQuery.hs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ import qualified Hasql.DynamicStatements.Snippet as SQL hiding (sql)
1717

1818

1919
import PostgREST.ApiRequest (ApiRequest (..))
20-
import PostgREST.ApiRequest.Preferences (PreferTimezone (..),
20+
import PostgREST.ApiRequest.Preferences (PreferHandling (..),
21+
PreferTimeout (..),
22+
PreferTimezone (..),
2123
Preferences (..))
2224
import PostgREST.Auth.Types (AuthResult (..))
2325
import PostgREST.Config (AppConfig (..))
@@ -35,11 +37,11 @@ import Protolude hiding (Handler)
3537

3638
-- sets transaction variables
3739
txVarQuery :: DbActionPlan -> AppConfig -> AuthResult -> ApiRequest -> SQL.Snippet
38-
txVarQuery dbActPlan AppConfig{..} AuthResult{..} ApiRequest{..} =
40+
txVarQuery dbActPlan AppConfig{..} AuthResult{..} ApiRequest{iPreferences=Preferences{..}, ..} =
3941
-- To ensure `GRANT SET ON PARAMETER <superuser_setting> TO authenticator` works, the role settings must be set before the impersonated role.
4042
-- Otherwise the GRANT SET would have to be applied to the impersonated role. See https://github.com/PostgREST/postgrest/issues/3045
4143
"select " <> intercalateSnippet ", " (
42-
searchPathSql : roleSettingsSql ++ roleSql ++ claimsSql ++ [methodSql, pathSql] ++ headersSql ++ cookiesSql ++ timezoneSql ++ funcSettingsSql ++ appSettingsSql
44+
searchPathSql : roleSettingsSql ++ roleSql ++ claimsSql ++ [methodSql, pathSql] ++ headersSql ++ cookiesSql ++ timezoneSql ++ timeoutSql ++ funcSettingsSql ++ appSettingsSql
4345
)
4446
where
4547
methodSql = setConfigWithConstantName ("request.method", iMethod)
@@ -50,7 +52,11 @@ txVarQuery dbActPlan AppConfig{..} AuthResult{..} ApiRequest{..} =
5052
roleSql = [setConfigWithConstantName ("role", authRole)]
5153
roleSettingsSql = setConfigWithDynamicName <$> HM.toList (fromMaybe mempty $ HM.lookup authRole configRoleSettings)
5254
appSettingsSql = setConfigWithDynamicName . join bimap toUtf8 <$> configAppSettings
53-
timezoneSql = maybe mempty (\(PreferTimezone tz) -> [setConfigWithConstantName ("timezone", tz)]) $ preferTimezone iPreferences
55+
timezoneSql = maybe mempty (\(PreferTimezone tz) -> [setConfigWithConstantName ("timezone", tz)]) preferTimezone
56+
timeoutSql =
57+
case (preferTimeout, preferHandling) of -- only applied on when handling = strict
58+
(Just (PreferTimeout t), Just Strict) -> [setConfigWithConstantName ("statement_timeout", show t <> "s")]
59+
_ -> mempty
5460
funcSettingsSql = setConfigWithDynamicName . join bimap toUtf8 <$> funcSettings
5561
searchPathSql =
5662
let schemas = escapeIdentList (iSchema : configDbExtraSearchPath) in

src/PostgREST/Response.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,4 +299,4 @@ responsePreferences plan ApiRequest{iPreferences=Preferences{..}, iQueryParams=Q
299299
CallReadPlan{} -> preferMaxAffected
300300
_ -> Nothing
301301

302-
in Preferences preferResolution' preferRepresentation' preferCount preferTransaction preferMissing' preferHandling preferTimezone preferMaxAffected' []
302+
in Preferences preferResolution' preferRepresentation' preferCount preferTransaction preferMissing' preferHandling preferTimezone preferMaxAffected' preferTimeout []

test/spec/Feature/Query/PreferencesSpec.hs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,3 +236,63 @@ spec =
236236
`shouldRespondWith`
237237
[json| {"code":"PGRST128","details":null,"hint":null,"message":"Function must return SETOF or TABLE when max-affected preference is used with handling=strict"} |]
238238
{ matchStatus = 400 }
239+
240+
context "Prefer: timeout and handling=strict" $ do
241+
it "should fail when timeout is less than the query time" $
242+
request methodGet "/rpc/sleep?seconds=4"
243+
[("Prefer", "handling=strict, timeout=3")]
244+
""
245+
`shouldRespondWith`
246+
[json| {"code":"57014","details":null,"hint":null,"message":"canceling statement due to statement timeout"} |]
247+
{ matchStatus = 500
248+
, matchHeaders = [ matchContentTypeJson ]
249+
}
250+
251+
it "should fail when timeout is equal to the query time" $
252+
request methodGet "/rpc/sleep?seconds=3"
253+
[("Prefer", "handling=strict, timeout=3")]
254+
""
255+
`shouldRespondWith`
256+
[json| {"code":"57014","details":null,"hint":null,"message":"canceling statement due to statement timeout"} |]
257+
{ matchStatus = 500
258+
, matchHeaders = [ matchContentTypeJson ]
259+
}
260+
261+
it "should succeed when timeout is more than the query time" $
262+
request methodGet "/rpc/sleep?seconds=3"
263+
[("Prefer", "handling=strict, timeout=4")]
264+
""
265+
`shouldRespondWith`
266+
""
267+
{ matchStatus = 204
268+
, matchHeaders = ["Preference-Applied" <:> "handling=strict, timeout=4"]
269+
}
270+
271+
context "Prefer: timeout and handling=lenient" $
272+
it "statement timeout is not applied when handling=lenient, so it should succeed in all cases" $ do
273+
request methodGet "/rpc/sleep?seconds=4"
274+
[("Prefer", "handling=lenient, timeout=3")]
275+
""
276+
`shouldRespondWith`
277+
""
278+
{ matchStatus = 204
279+
, matchHeaders = ["Preference-Applied" <:> "handling=lenient"]
280+
}
281+
282+
request methodGet "/rpc/sleep?seconds=3"
283+
[("Prefer", "handling=lenient, timeout=3")]
284+
""
285+
`shouldRespondWith`
286+
""
287+
{ matchStatus = 204
288+
, matchHeaders = ["Preference-Applied" <:> "handling=lenient"]
289+
}
290+
291+
request methodGet "/rpc/sleep?seconds=3"
292+
[("Prefer", "handling=lenient, timeout=4")]
293+
""
294+
`shouldRespondWith`
295+
""
296+
{ matchStatus = 204
297+
, matchHeaders = ["Preference-Applied" <:> "handling=lenient"]
298+
}

0 commit comments

Comments
 (0)