Help needed narrowing down an IO exception

I am using obelisk with servant and servant-snap. I implemented a custom authentication scheme and testing it I encountered a weird error:

A web handler threw an exception. Details: no value

This means: Accessing a backend route (handled by snap), there was an HTTP 500 status code and the error message is quite generic. As far as I understand, snap tries to tell me that there was an unhandled IO exception in the handler. (which I can confirm, because I can catch this IO exception)

Specifically “no value” implies that I am running evalSnap on a snap handler (in my case: authHandler :: Snap UserInfo) that at some point calls finishWith :: Response -> Snap a, which is non-sensical. Only that I never explicitly call finishWith. So this can’t be it.

I then basically sprinkled my code with putStrLn $ "Hi, there!" <> -- ... to figure out what line causes the problem. And I could narrow down the offender to this piece of code:

verifyCompactJWT
    :: forall m. (MonadIO m, MonadError JWTError m, MonadBaseControl IO m) => JWK -> CompactJWT -> UTCTime -> m Text
verifyCompactJWT jwk (CompactJWT str) now = do
    liftIO $ putStrLn "In verifyCompactJWT"
    jwt <- decodeCompact $ BL.fromStrict $ Text.encodeUtf8 str
    liftIO $ putStrLn $ "In verifyCompactJWT: jwt: " <> Text.take 16 (Text.pack (show jwt))
    let config = defaultJWTValidationSettings (== audience)
    claims <- fromJust <$> handle handler (Just <$> verifyClaimsAt config jwk now jwt)
    liftIO $ putStrLn $ "In verifyCompactJWT: claims: " <> Text.pack (show claims)
    case claims ^. claimSub ^? _Just . string of
        Nothing -> do
          throwError $ JWTClaimsSetDecodeError "no subject in claims"
        Just s  -> do
          liftIO $ putStrLn $ "verifyCompactJWT: sub: " <> s
          pure s
  where
    handler :: SomeException -> m (Maybe ClaimsSet)
    handler e = do
      liftIO $ putStrLn $ "Caught exception in verifyCompactJWT: " <> Text.pack (show e)
      pure Nothing

verifyCompactJWT is a function called by my handler and this is the output I get:

In verifyCompactJWT
In verifyCompactJWT: jwt: JWS Base64Octets
[23/Jun/2022:15:13:23 -0500] During processing of request from 127.0.0.1:56048
request:
"GET /api/user/app/get HTTP/1.1\naccept-language: en-US,en;q=0.9\nreferer: http://localhost:8000/\nsec-fetch-dest: empty\nsec-fetch-mode: cors\nsec-fetch-site: same-origin\naccept: */*\nsec-ch-ua-platform: \"Linux\"\nx-alias: rubenmoor\nuser-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36\nsec-ch-ua-mobile: ?0\nauthorization: Bearer eyJhbGciOiJFUzM4NCJ9.eyJzdWIiOiJydWJlbm1vb3IiLCJleHAiOjEuNjU1OTQzNjM4NTY0Mzg1Mjc5ZTksImlhdCI6MS42NTU5NDM2MDg1NjQzODUyNzllOSwiYXVkIjoiaHR0cHM6Ly9wYWxhbnR5cGUuY29tIn0.td1jCKoE5QSciU2gUde3FqFrCljSCQzJUwTS09EvfA9K0YY6YZrlfxZakcwCTPLo7c8jI3c5xI32maQok5lQUp50WUcxXz_k1NNeeDmuQfPq0P34HmEJBmcH7S-Dog8G\nsec-ch-ua: \" Not A;Brand\";v=\"99\", \"Chromium\";v=\"102\", \"Google Chrome\";v=\"102\"\nconnection: keep-alive\nhost: localhost:8000\nx-real-ip: 127.0.0.1\naccept-encoding: gzip\n\nsn=\"localhost:8000\" c=127.0.0.1:56048 s=127.0.0.1:58115 ctx=/ clen=n/a"
A web handler threw an exception. Details:
no value
127.0.0.1 - - [23/Jun/2022:15:13:23 -0500] "GET /api/user/app/get HTTP/1.1" 500 51 "http://localhost:8000/" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36"

Right where the proper code execution stops, I tried to catch an error. But in vain. The error isn’t caught, the lines thereafter are never reached and the handler fails.

My code behaves as if verifyClaimsAt called finishWith, only that verifyClaimsAt isn’t aware of Snap or MonadSnap and thus can’t do that.

How do I go about nailing down this error?

I found the cause of my demise:

verifyClaimsAt indeed fails to verify, because I set the expiry to 30 seconds instead of 30 days. So far so good, otherwise I would have noticed the problem in 30 days.

Jumping out of the function verifyCompactJWT, it gets evaluated via runExceptT to Left JWTExpired. This I never see because I then move on to call

throwError :: MonadSnap m => ServantError -> m a

… which apparently cannot be used safely with evalSnap (it seems to work fine in the normal case, which is runSnap). In my case throwError $ err500 { errBody = "JWTExpired" just results in Snap’s malfunction throwIO $ ErrorCall "no value". Bad luck I guess.

Ironically, I recall how making mistakes in error handling code in JavaScript is hellish. The same is true for Haskell: getting IO exception while gullibly implementing pure error handling is quite confusing.

I am currently stuck with snap-core-1.0.4.1 and snap-server-1.1.1.2. I wonder whether this particular behavior (throwError causing IO exceptions in evalSnap) is tolerated or an actual bug. I will post an issue there.

1 Like