servant icon indicating copy to clipboard operation
servant copied to clipboard

`servant-client` ignores `Header'`'s mods, causing response headers to always be treated as `Optional` and `Lenient`

Open intolerable opened this issue 6 months ago • 1 comments

HasClient 's instance for Verb m s c (Headers h a) ignores Header's modifiers, treating all response headers as Optional and Lenient. This causes several problems (imo):

  • running the client against a server that doesn't respond with a Required header succeeds, but it should fail
  • running the client against a server that responds with a Strict header that can't be parsed succeeds, but it should fail
  • running the client against a server that correctly responds with a Strict header results in a ResponseHeader type, and the user still has to pattern match on MissingHeader, even though this branch should be unreachable in this case
  • running the client against a server that correctly responds with a Lenient header results in a ResponseHeader type, and the user still has to pattern match on UndecodableHeader, even though this branch should be unreachable in this case

(opinionated part follows:)

imo this is mostly fallout from hlistLookupHeader in HasResponseHeader having the wrong type. I think it should be returning one of headerType, Maybe headerType, Either headerType), or Maybe (Either headerType) (or an equivalent GADT), depending on the Header''s mods , but my brief attempt at changing that resulted in me painting myself into a corner with type family injectivity. if you think more explanation / code from this exploration would be useful, let me know. I also have a repository with a completely undocumented prototype that integrates response headers into Verb which might be interesting (but might not)

Full example below:

#!/usr/bin/env cabal
{- cabal:
  default-language: GHC2021
  default-extensions:
    DataKinds
    GADTs
    LambdaCase
  build-depends:
    base,
    aeson,
    servant >=0.20 && < 0.21,
    servant-client >=0.20 && < 0.21,
    servant-client-core >=0.20 && < 0.21,
  ghc-options: -Wall -Wextra -Wcompat -Werror
-}

module Main where

import Data.Proxy
import Servant.API
import Servant.Client

type BadHeaderAPI =
  "test" :> Get '[PlainText] (Headers '[Header' '[Required, Strict] "X-Test-Header" Int] String)
  

badHeaderClient :: Client ClientM BadHeaderAPI
badHeaderClient = client (Proxy :: Proxy BadHeaderAPI)

getTestHeader :: ClientM Int
getTestHeader = do
  badHeaderClient >>= \case
    Headers _r hs -> case hs of
      xTestHeader `HCons` HNil ->
        case xTestHeader of
          Header x -> pure x
          MissingHeader ->
            error "huh? this should be required, and `badHeaderClient` should immediately fail!"
          UndecodableHeader _ ->
            error "huh? parsing this header should be strict, and `badHeaderClient` should immediately fail!"

main :: IO ()
main = pure ()

intolerable avatar Jun 11 '25 21:06 intolerable

@intolerable Hi, thanks for reporting this. I'd be really grateful if you could submit a PR to fix this, if you have the time. :)

tchoutri avatar Jun 23 '25 09:06 tchoutri