Skip to content

Commit 4e3163d

Browse files
Catherine Gasniermeta-codesync[bot]
authored andcommitted
Classify undeclared exceptions for ServiceRouter
Summary: `glean.glass`'s ServiceRouter server-fatal SLI is ~99.9% undeclared `ApplicationException`, and ~94% of those are downstream `glean.query.prod` failures leaking out of the Glass handler untyped. Two consequences: 1. The SLI measures Glean's throttling, not Glass's availability. The `glean.glass fatals` detector fires on "a Glass client exceeded its Glean per-client QPS share" — something Glass oncall cannot act on. 2. **ServiceRouter marks down healthy Glass hosts.** Undeclared exceptions get `kUndeclaredAppErrorConfig`, which is `logFatal = true` *and* `markdown = true`, so a Glean-side throttle shrinks the routable Glass pool exactly when load is highest. For a Thrift error, the following headers are appended to the response: * 'uex': a short string for the exception type, e.g. ApplicationException * 'ex': the error code for that exception type * 'uexw': the complete error message. hsthrift cannot distinguish undeclared exceptions on its own. The generated `respWriter'` replaces the escaping exception with a fresh `Thrift.ApplicationException` before `handlerWrapper` takes `show (typeOf ex)`, so `uex` is uniformly `ApplicationException` and all triage has to string-match `uexw`. This diff adds function `Glean.Glass.ErrorClassification.srErrorName`, which maps an escaping exception to the `uex` name ServiceRouter should classify it under. D116930502 in configerator then defines the behaviour for each new `uex` value. The mapping of escapting exceptions is as follows: | escaping exception | new `uex` name | |---|---| | downstream `THROTTLING_CLIENT_ID_REQUEST` | `GleanClientThrottled` | | downstream `THROTTLING_ERROR` | `GleanServiceThrottled` | | downstream `APP_QUEUE_TIMEOUT` | `GleanQueueTimeout` | | downstream `HOST_OVERLOAD` / `APP_OVERLOAD` | `GleanOverload` | | GHC allocation limit | `GlassAllocationLimit` | | Glass's own 30s `withTimeout` | `GlassTimeout` | `assignHeaders` emits the new `uex` header. The downstream reason is recovered by matching the `(REASON)` token that `TServiceRouterException::getExceptionMsg` puts at the front of its message. String matching is unavoidable today — `HsChannel.h` stringifies the whole `folly::exception_wrapper` into a `ChannelException Text` and drops the response headers — but only the enum name is matched, never the prose after it, and an unrecognised reason returns `Nothing`. This diff is inert on its own: ServiceRouter falls back to `kUndeclaredAppErrorConfig` for any name with no `appErrorsMap` entry, so behaviour is unchanged until the companion `configerator` change adds the per-name policy. `ex` and `uexw` are deliberately left alone, so the wire-level record still says "undeclared application exception" and no client sees a different response. Reviewed By: bochko Differential Revision: D116930531 fbshipit-source-id: 9f92d799e9d135d8f73379735244c11ca5e15421
1 parent 2359a1a commit 4e3163d

4 files changed

Lines changed: 232 additions & 5 deletions

File tree

glean/glass/ARCHITECTURE.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,41 @@ client instead of seeing all Glass traffic as a single `glean.glass` client.
117117
it in from the process's first SMC tier — see
118118
`fbcode/tupperware/config/glean/glass/glass.tw`.)
119119

120+
### Error classification for ServiceRouter (`uex`)
121+
122+
`assignHeaders` is the `postProcess` hook passed to `runFacebookService'`. For a
123+
failed request it asks
124+
[`srErrorName`](Glean/Glass/ErrorClassification.hs) for a name and, when there
125+
is one, emits it as the `uex` response header. ServiceRouter looks `uex` up in
126+
`appErrorsMap` in glean.glass's routing config
127+
(`configerator/raw_configs/fbrpcclient/configs/glean.glass/glean.glass`) and
128+
takes the fatal/markdown/retry policy from there.
129+
130+
This exists because hsthrift cannot distinguish undeclared exceptions on its
131+
own: `handlerWrapper` sets `uex` to `show (typeOf ex)` *after* the generated
132+
`respWriter'` has replaced the original exception with a fresh
133+
`Thrift.ApplicationException`, so every undeclared failure reaches
134+
ServiceRouter as the single name `ApplicationException`. ServiceRouter then
135+
applies `kUndeclaredAppErrorConfig``logFatal = true` **and**
136+
`markdown = true`. In practice most of those failures are downstream Glean
137+
throttles and queue timeouts, so before this change a Glean-side throttle both
138+
polluted Glass's server-fatal SLI and marked down healthy Glass hosts.
139+
140+
The override works because `postProcess` headers are appended last in
141+
`handlerWrapper` and `HaskellProcessor.cpp` applies them with `setHeader`,
142+
which overwrites. `ex` and `uexw` are deliberately left alone, so the
143+
wire-level record still says "undeclared application exception"; only
144+
ServiceRouter's classification policy changes, and no client sees a different
145+
response.
146+
147+
`srErrorName` recovers the downstream cause by string-matching the
148+
`(REASON)` token that `TServiceRouterException::getExceptionMsg` puts at the
149+
front of its message. That is unavoidable today: hsthrift's cpp-channel
150+
stringifies the whole `folly::exception_wrapper` into a `ChannelException Text`
151+
and drops the response headers, so the error code never reaches Haskell. Only
152+
the enum name is matched, never the prose after it. An unrecognised reason
153+
returns `Nothing`, which keeps ServiceRouter's default behaviour.
154+
120155
## 4. Glean DB Selection (“best/closest DB”)
121156

122157
This is one of Glass’s key logic layers.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
{-
2+
Copyright (c) Meta Platforms, Inc. and affiliates.
3+
All rights reserved.
4+
5+
This source code is licensed under the BSD-style license found in the
6+
LICENSE file in the root directory of this source tree.
7+
-}
8+
9+
{-# LANGUAGE OverloadedStrings #-}
10+
11+
module Glean.Glass.ErrorClassification
12+
( srErrorName
13+
) where
14+
15+
import Control.Exception
16+
( AllocationLimitExceeded(..), SomeException, fromException )
17+
import Data.ByteString (ByteString)
18+
import Data.Text (Text)
19+
import qualified Data.Text as Text
20+
21+
import Thrift.Channel (ChannelException(..))
22+
import Thrift.Protocol.ApplicationException.Types
23+
( ApplicationException(..), ApplicationExceptionType(..) )
24+
25+
-- | The @uex@ name ServiceRouter should classify this failure under, or
26+
-- 'Nothing' to leave its default undeclared-exception handling (fatal plus
27+
-- host markdown) in place.
28+
--
29+
-- A name only has an effect if glean.glass's routing config gives it an
30+
-- @appErrorsMap@ entry; without one ServiceRouter falls back to that same
31+
-- default, so adding a name here is never worse than not classifying at all.
32+
srErrorName :: SomeException -> Maybe ByteString
33+
srErrorName e
34+
| Just (ChannelException msg) <- fromException e =
35+
flip lookup downstreamErrorNames =<< serviceRouterErrorReason msg
36+
| Just AllocationLimitExceeded <- fromException e =
37+
Just "GlassAllocationLimit"
38+
| Just ApplicationException
39+
{ applicationException_type = ApplicationExceptionType_Timeout }
40+
<- fromException e =
41+
Just "GlassTimeout"
42+
| otherwise = Nothing
43+
44+
-- | The @ErrorReason@ enum name that ServiceRouter reported a downstream failure
45+
-- under, e.g. APP_QUEUE_TIMEOUT, THROTTLING_CLIENT_ID_REQUEST, etc.
46+
-- hsthrift's cpp-channel flattens the whole @TServiceRouterException@
47+
-- to its @what()@ string, so this is the only way to recover it. Match the
48+
-- enum name only: @TServiceRouterException::getExceptionMsg@ formats it as
49+
-- @(REASON) @, while the message that follows is prose, not contract.
50+
serviceRouterErrorReason :: Text -> Maybe Text
51+
serviceRouterErrorReason msg
52+
| Text.null afterReason = Nothing
53+
| otherwise = Just reason
54+
where
55+
(_, atMarker) = Text.breakOn marker msg
56+
(reason, afterReason) =
57+
Text.breakOn ")" (Text.drop (Text.length marker) atMarker)
58+
marker = "TServiceRouterException: ("
59+
60+
-- | ServiceRouter @ErrorReason@ names (@servicerouter/common/error.thrift@)
61+
-- that Glass reports under a dedicated @uex@ name. Anything absent keeps
62+
-- ServiceRouter's undeclared-exception default.
63+
downstreamErrorNames :: [(Text, ByteString)]
64+
downstreamErrorNames =
65+
[ ("THROTTLING_CLIENT_ID_REQUEST", "GleanClientThrottled")
66+
, ("THROTTLING_ERROR", "GleanErrorRateShed")
67+
, ("APP_QUEUE_TIMEOUT", "GleanQueueTimeout")
68+
, ("HOST_OVERLOAD", "GleanOverload")
69+
, ("APP_OVERLOAD", "GleanOverload")
70+
]

glean/glass/Glean/Glass/Main.hs

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import Util.Log.Text ( logInfo )
4141
import Logger.IO (withLogger)
4242

4343
import Control.Exception (SomeException, fromException, throwIO)
44+
import Data.ByteString (ByteString)
4445
import Data.Text (Text)
4546
import qualified Data.Text as Text
4647
import Data.Text.Encoding (encodeUtf8)
@@ -56,6 +57,7 @@ import Glean.Util.ConfigProvider
5657
import Glean.Util.Some ( Some(Some) )
5758

5859
import Glean.Glass.RepoMapping -- site-specific
60+
import Glean.Glass.ErrorClassification (srErrorName)
5961
import qualified Glean.Glass.Env as Glass
6062
import Glean.Glass.Repos (withLatestRepos)
6163
import Glean.Glass.SourceControl (setCallerInfo)
@@ -84,6 +86,12 @@ import JustKnobs (evalKnob)
8486
kThriftCacheNoCache :: Text
8587
kThriftCacheNoCache = "nocache"
8688

89+
-- | hsthrift's undeclared-exception name header. ServiceRouter looks the value
90+
-- up in @appErrorsMap@ to decide fatal/markdown, so overriding it here is how
91+
-- Glass classifies its own failures.
92+
kUex :: ByteString
93+
kUex = "uex"
94+
8795
-- | Ok, go.
8896
mainWith
8997
:: Parser (Glass.Config GlassTraceWithId -> Glass.Config GlassTraceWithId)
@@ -164,19 +172,23 @@ runGlass res@Glass.Env{fb303, evp} conf@Glass.Config{..} = do
164172
runFacebookService' fb303 (glassHandler res) assignHeaders options
165173
#endif
166174

175+
-- | @postProcess@ hook for 'runFacebookService''. These headers are appended
176+
-- after the ones hsthrift derives from the escaping exception, and
177+
-- @HaskellProcessor@ sets rather than adds them, so 'kUex' here wins.
167178
assignHeaders :: GlassServiceCommand r -> Either SomeException r -> Header
168-
assignHeaders _ (Left e) | isRevisionNotAvailableException e =
169-
[ (encodeUtf8 kThriftCacheNoCache, "1")]
179+
assignHeaders _ (Right _) = []
180+
assignHeaders _ (Left e) =
181+
[ (encodeUtf8 kThriftCacheNoCache, "1") | isRevisionNotAvailableException ] ++
182+
[ (kUex, name) | Just name <- [srErrorName e] ]
170183
where
171-
isRevisionNotAvailableException e = case fromException e of
184+
isRevisionNotAvailableException = case fromException e of
172185
Just GlassException{glassException_reasons} ->
173186
all isRevisionNotAvailable glassException_reasons
174187
_ -> False
175-
isRevisionNotAvailable e = case e of
188+
isRevisionNotAvailable r = case r of
176189
GlassExceptionReason_exactRevisionNotAvailable{} -> True
177190
GlassExceptionReason_matchingRevisionNotAvailable{} -> True
178191
_ -> False
179-
assignHeaders _ _ = []
180192

181193
#if GLEAN_FACEBOOK
182194
-- | ModifyFunction (C++ FFI) that installs the shared CAT ServiceInterceptor
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
{-
2+
Copyright (c) Meta Platforms, Inc. and affiliates.
3+
All rights reserved.
4+
5+
This source code is licensed under the BSD-style license found in the
6+
LICENSE file in the root directory of this source tree.
7+
-}
8+
9+
{-# LANGUAGE OverloadedStrings #-}
10+
11+
module Glean.Glass.Test.ErrorClassification (main) where
12+
13+
import Control.Exception
14+
( AllocationLimitExceeded(..), SomeException, toException )
15+
import Data.ByteString (ByteString)
16+
import Data.Text (Text)
17+
import Test.HUnit (Test(..), assertEqual)
18+
19+
import Glean.Init (withUnitTest)
20+
import TestRunner (testRunner)
21+
import Thrift.Channel (ChannelException(..))
22+
import Thrift.Protocol.ApplicationException.Types
23+
( ApplicationException(..), ApplicationExceptionType(..) )
24+
25+
import Glean.Glass.ErrorClassification (srErrorName)
26+
import Glean.Glass.Types
27+
( GlassException(..), GlassExceptionReason(..), Revision(..) )
28+
29+
main :: IO ()
30+
main = withUnitTest $ testRunner $ TestList unitTests
31+
32+
unitTests :: [Test]
33+
unitTests =
34+
[ TestLabel label $ TestCase $ assertEqual label expected (srErrorName e)
35+
| (label, e, expected) <- classifications
36+
]
37+
38+
-- | The downstream messages are shaped after real @error_reason_what@ values
39+
-- from the @service_router@ dataset for @service = 'glean.glass'@.
40+
classifications :: [(String, SomeException, Maybe ByteString)]
41+
classifications =
42+
[ ( "per-client Glean throttle"
43+
, downstream
44+
"(THROTTLING_CLIENT_ID_REQUEST) Throttling request to tier \
45+
\'glean.query.prod' with client_id \
46+
\'glean.glass~useGleanGlassNavigationForDiffTool:phabricator' \
47+
\because global request rate is higher than max configured 100"
48+
, Just "GleanClientThrottled"
49+
)
50+
, ( "Glean error-rate throttle"
51+
, downstream
52+
"(THROTTLING_ERROR) This request to service 'glean.query.prod' was \
53+
\throttled because the error rate exceeded the configured threshold"
54+
, Just "GleanErrorRateShed"
55+
)
56+
, ( "Glean queue timeout, with the trailing help text"
57+
, downstream
58+
"(APP_QUEUE_TIMEOUT) Server Queue Timeout -- For help debugging \
59+
\visit https://fburl.com/wiki/sr_app_queue_timeout"
60+
, Just "GleanQueueTimeout"
61+
)
62+
, ( "Glean host overload"
63+
, downstream
64+
"(HOST_OVERLOAD) Host is overloaded -- For help debugging visit \
65+
\https://fburl.com/wiki/sr_host_overload"
66+
, Just "GleanOverload"
67+
)
68+
, ( "Glean app overload shares the overload name"
69+
, downstream
70+
"(APP_OVERLOAD) Application is overloaded -- For help debugging \
71+
\visit https://fburl.com/wiki/sr_app_overload"
72+
, Just "GleanOverload"
73+
)
74+
, ( "unclassified reason falls through to ServiceRouter's default"
75+
, downstream
76+
"(RECV_TIMEOUT) Receive timeout after 30000ms -- For help debugging \
77+
\visit https://fburl.com/wiki/sr_recv_timeout"
78+
, Nothing
79+
)
80+
, ( "channel failure that is not a ServiceRouter exception"
81+
, toException (ChannelException "recvCob: connection closed")
82+
, Nothing
83+
)
84+
, ( "GHC allocation limit"
85+
, toException AllocationLimitExceeded
86+
, Just "GlassAllocationLimit"
87+
)
88+
, ( "Glass's own request timeout"
89+
, toException $ ApplicationException
90+
"glass server timeout" ApplicationExceptionType_Timeout
91+
, Just "GlassTimeout"
92+
)
93+
, ( "other ApplicationExceptions are not Glass timeouts"
94+
, toException $ ApplicationException
95+
"something else" ApplicationExceptionType_InternalError
96+
, Nothing
97+
)
98+
, ( "declared exceptions keep the classification hsthrift gives them"
99+
, toException $ GlassException
100+
{ glassException_reasons =
101+
[GlassExceptionReason_exactRevisionNotAvailable "deadbeef"]
102+
, glassException_revisions = [Revision "deadbeef"]
103+
}
104+
, Nothing
105+
)
106+
]
107+
108+
downstream :: Text -> SomeException
109+
downstream what = toException $ ChannelException $
110+
"recvCob: facebook::servicerouter::TServiceRouterException: " <> what

0 commit comments

Comments
 (0)