diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Revision history for http-exchange
+
+## 0.1.0.0 -- YYYY-mm-dd
+
+* First version. Released on an unsuspecting world.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2023, Andrew Martin
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+
+    * Neither the name of Andrew Martin nor the names of other
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/http-exchange.cabal b/http-exchange.cabal
new file mode 100644
--- /dev/null
+++ b/http-exchange.cabal
@@ -0,0 +1,65 @@
+cabal-version: 3.0
+name: http-exchange
+version: 0.1.1.0
+synopsis: Perform HTTP Requests
+description: Perform HTTP requests. This uses backpack and is agnostic to the backend.
+license: BSD-3-Clause
+license-file: LICENSE
+author: Andrew Martin
+maintainer: andrew.thaddeus@gmail.com
+copyright: 2023 Andrew Martin
+category: Data
+build-type: Simple
+extra-doc-files: CHANGELOG.md
+
+library types
+  ghc-options: -Wall
+  exposed-modules: Http.Exchange.Types
+  build-depends: base >=4.16.3.0 && <5
+  hs-source-dirs: src-types
+  default-language: GHC2021
+
+library testdep
+  ghc-options: -Wall
+  exposed-modules: OkChannel
+  build-depends:
+    , base >=4.16.3.0 && <5
+    , byteslice >=0.2.11
+    , types
+  hs-source-dirs: src-testdep
+  default-language: GHC2021
+
+library
+  signatures: Channel
+  ghc-options: -Wall
+  exposed-modules: Exchange
+  build-depends:
+    , base >=4.16.3.0 && <5
+    , http-interchange >=0.3.1
+    , text >= 2.0
+    , types
+    , primitive >=0.8
+    , byteslice >=0.2.11
+    , bytesmith >=0.3.9
+  hs-source-dirs: src
+  default-language: GHC2021
+
+test-suite test
+  ghc-options: -Wall
+  default-language: GHC2021
+  type: exitcode-stdio-1.0
+  hs-source-dirs: test
+  main-is: Main.hs
+  build-depends:
+    , base >=4.16.3.0 && <5
+    , http-interchange >=0.3.1
+    , http-exchange
+    , testdep
+    , tasty >=1.4.3
+    , tasty-hunit >=0.10.0.3
+    , byteslice
+    , bytestring >=0.11
+    , primitive >=0.8
+  mixins:
+    http-exchange (Exchange as OkExchange)
+      requires (Channel as OkChannel)
diff --git a/src-testdep/OkChannel.hs b/src-testdep/OkChannel.hs
new file mode 100644
--- /dev/null
+++ b/src-testdep/OkChannel.hs
@@ -0,0 +1,76 @@
+{-# language DeriveFunctor #-}
+{-# language DerivingStrategies #-}
+{-# language KindSignatures #-}
+
+module OkChannel
+  ( M(..)
+  , ReceiveException(..)
+  , SendException
+  , showsPrecReceiveException
+  , showsPrecSendException
+  , Resource
+  , send
+  , receive
+  ) where
+
+import Data.Bytes (Bytes)
+import Data.Bytes.Chunks (Chunks(ChunksNil,ChunksCons))
+import Data.Void (Void,absurd)
+
+import qualified Data.Bytes as Bytes
+import qualified Data.Bytes.Chunks as Chunks
+
+type Resource = ()
+
+data ReceiveException = ExpectedMoreInput
+  deriving (Show)
+
+showsPrecReceiveException :: Int -> ReceiveException -> String -> String
+showsPrecReceiveException = showsPrec
+
+type SendException = Void
+
+showsPrecSendException :: Int -> SendException -> String -> String
+showsPrecSendException _ x _ = absurd x
+
+-- First arg is input, second arg is output
+-- The input is peeled off one byte sequence at a time by receive
+-- We use this feature to feed input byte-by-byte to test streaming
+-- features.
+data M a = M (Chunks -> Bytes -> (Chunks,Bytes,a))
+  deriving stock (Functor)
+
+bindM :: M a -> (a -> M b) -> M b
+bindM (M f) g = M $ \inbound0 outbound0 ->
+  case f inbound0 outbound0 of
+    (inbound1,outbound1,a) ->
+      case g a of
+        M h -> h inbound1 outbound1
+
+pureM :: a -> M a
+pureM a = M $ \x y -> (x,y,a)
+
+instance Applicative M where
+  pure = pureM
+  f <*> a = f `bindM` \f' -> a `bindM` \a' -> pureM (f' a')
+
+instance Monad M where
+  (>>=) = bindM
+
+send ::
+     ()
+  -> Chunks
+  -> M (Either SendException ())
+send _ b = M $ \inbound outbound ->
+  (inbound,outbound <> Chunks.concat b,Right ())
+
+receive ::
+     ()
+  -> M (Either ReceiveException Bytes)
+receive _ = M $ \inbound0 outbound ->
+  let go inbound = case inbound of
+        ChunksNil -> (inbound,outbound,Left ExpectedMoreInput)
+        ChunksCons b ch -> case Bytes.null b of
+          True -> go ch
+          False -> (ch,outbound,Right b)
+   in go inbound0
diff --git a/src-types/Http/Exchange/Types.hs b/src-types/Http/Exchange/Types.hs
new file mode 100644
--- /dev/null
+++ b/src-types/Http/Exchange/Types.hs
@@ -0,0 +1,35 @@
+{-# language DeriveAnyClass #-}
+{-# language DerivingStrategies #-}
+
+module Http.Exchange.Types
+  ( HttpException(..)
+  ) where
+
+import qualified Control.Exception as E
+
+-- | Exceptions that occur when decoding an HTTP response.
+-- If this happens, the only way to proceed is to
+-- shut down the connection. Either the server does not
+-- speak HTTP correct, or there is a mistake in this libary.
+data HttpException
+  = ChunkTooLarge
+  | ChunkedBodyEndOfInput
+  | NonchunkedBodyEndOfInput
+  | ContentLengthDuplicated
+  | ContentLengthMalformed
+  | ContentLengthTooLarge
+  | ExpectedCrlfAfterChunk
+  | ExpectedCrlfAfterChunkLength
+  | ExpectedCrlfBeforeChunkLength
+  | HeadersMalformed
+  | HeadersEndOfInput
+  | HeadersTooLarge
+  | ImplementationMistake
+    -- ^ If this one happens, there is a mistake in this
+    -- library.
+  | NonNumericChunkLength
+  | PipelinedResponses
+  | TransferEncodingUnrecognized
+  | TransferEncodingDuplicated
+  deriving stock (Show)
+  deriving anyclass (E.Exception)
diff --git a/src/Channel.hsig b/src/Channel.hsig
new file mode 100644
--- /dev/null
+++ b/src/Channel.hsig
@@ -0,0 +1,49 @@
+{-# language KindSignatures #-}
+
+signature Channel where
+
+import Data.Kind (Type)
+import Data.Bytes.Chunks (Chunks)
+import Data.Bytes (Bytes)
+
+-- | A resource that, in the monadic context @M@, can be used to send
+-- and receive data. Typically instantiated to a network socket type.
+data Resource :: Type
+
+-- | Exceptions that can occur while sending data.
+data SendException :: Type
+
+showsPrecSendException ::
+  Int -> SendException -> String -> String
+
+-- | Exceptions that can occur while receiving data.
+data ReceiveException :: Type
+
+showsPrecReceiveException ::
+  Int -> ReceiveException -> String -> String
+
+-- | The monadic context in which an HTTP exchange takes place. This
+-- is always instantiated with 'IO' when building a real HTTP client,
+-- but the test suite uses a different non-IO context that is fully
+-- deterministic.
+data M :: Type -> Type
+instance Functor M
+instance Applicative M
+instance Monad M
+
+-- | Send bytes.
+send ::
+     Resource
+  -> Chunks
+  -> M (Either SendException ())
+
+-- | Receive a chunk of input. The caller does not specify a
+-- size hint. This helps make the test suite easier to write, but
+-- we should probably figure out a way to support size hints as well.
+--
+-- Postcondition: If the returned chunked is empty, the source has
+-- shut down. The indefinite module @Exchange@ interprets a zero-length
+-- chunk to mean that the source has reached the end of its input.
+receive ::
+     Resource
+  -> M (Either ReceiveException Bytes)
diff --git a/src/Exchange.hs b/src/Exchange.hs
new file mode 100644
--- /dev/null
+++ b/src/Exchange.hs
@@ -0,0 +1,253 @@
+{-# language DeriveAnyClass #-}
+{-# language DerivingStrategies #-}
+{-# language DuplicateRecordFields #-}
+{-# language LambdaCase #-}
+{-# language OverloadedStrings #-}
+
+module Exchange
+  ( Exception(..)
+  , HttpException(..)
+  , exchange
+  ) where
+
+import Channel (M,Resource,SendException,ReceiveException,send,receive)
+import Control.Monad (when)
+import Data.Char (ord)
+import Data.Bytes (Bytes)
+import Data.Bytes.Chunks (Chunks(ChunksCons,ChunksNil))
+import Data.Word (Word64)
+import Http.Bodied (Bodied(Bodied))
+import Http.Exchange.Types (HttpException)
+import Http.Header (Header(Header))
+import Http.Types (Request,Response,Headers,LookupException(Duplicate,Missing))
+import Text.Read (readMaybe)
+import Data.Bytes.Parser (Parser)
+
+import qualified Data.Bytes.Parser as Parser
+import qualified Data.Bytes.Parser.Latin as Latin
+import qualified Http.Exchange.Types as E
+import qualified Data.Bytes as Bytes
+import qualified Data.Bytes.Chunks as Chunks
+import qualified Data.Text as T
+import qualified Http.Header
+import qualified Http.Headers as Headers
+import qualified Http.Request as Request
+import qualified Http.Response as Response
+import qualified Http.Bodied
+import qualified Control.Exception
+import qualified Channel
+
+data Continuation = Continuation
+  !Instruction
+  !Chunks -- these chunks are in reverse order
+
+-- Not exported
+data Instruction
+  = More -- we are in the middle of a chunk
+      !Int -- how much input was requested (zero is special)
+      !Int -- how much more input do we need to consume
+  | MorePostCr
+      !Int -- how much input was requested for the last chunk
+  | ChunkLength
+      -- We are in the middle (or at the beginning) of chunk length,
+      -- the leading CRLF has already been consumed
+      !Word64 -- chunk length accumulator
+  | PostCr -- We already got the CR after the chunk length
+      !Int -- how much input we need to consume, but we need to consume the LF first
+  | Done
+    -- ^ We got all the chunks, and we got the zero-length chunk
+    -- at the end, and we got the trailing CRLF. We are done.
+
+data TransferEncoding
+  = Nonchunked
+  | Chunked
+
+-- | An exception that occurs during an HTTP exchange.
+data Exception
+  = Http -- ^ The response was not a valid HTTP response
+      !HttpException
+  | Send
+      -- ^ Transport exception while sending. When backed by stream sockets,
+      -- exceptions like @ECONNRESET@ show up here.
+      !SendException
+  | Receive
+      -- ^ Transport exception while receiving. Depending on the backend,
+      -- this may or may not include an end-of-input exception. For stream
+      -- sockets, end-of-input is not presented as an exception. It is
+      -- presented as a zero-length result.
+      !ReceiveException
+  deriving anyclass (Control.Exception.Exception)
+
+instance Show Exception where
+  showsPrec d (Http e) = showParen (d > 10)
+    (showString "Http " . showsPrec 11 e)
+  showsPrec d (Send e) = showParen (d > 10)
+    (showString "Send " . Channel.showsPrecSendException 11 e)
+  showsPrec d (Receive e) = showParen (d > 10)
+    (showString "Receive " . Channel.showsPrecReceiveException 11 e)
+
+-- | Send an HTTP request and await a response. This function takes
+-- responsibility for encoding the request and decoding the response.
+-- It deals with the @Transfer-Encoding@ of the response and supports
+-- both chunked and nonchunked responses.
+exchange ::
+     Resource
+  -> Bodied Request -- http request line and headers
+  -> M (Either Exception (Bodied Response))
+exchange ctx req = do
+  let enc = Request.bodiedToChunks req
+  send ctx enc >>= \case
+    Left err -> pure (Left (Send err))
+    Right () -> receiveResponse ctx
+
+receiveResponse ::
+     Resource
+  -> M (Either Exception (Bodied Response))
+receiveResponse !ctx = do
+  let go !oldOutput = receive ctx >>= \case
+        Left err -> pure (Left (Receive err))
+        Right newOutput -> case Bytes.length newOutput of
+          0 -> pure (Left (Http E.HeadersEndOfInput))
+          _ -> do
+            let output = oldOutput <> newOutput
+            case splitEndOfHeaders output of
+              Nothing -> if Bytes.length output > 16000
+                then pure (Left (Http E.HeadersTooLarge))
+                else go output
+              Just (pre,post) -> case Response.decode 128 pre of
+                Nothing -> pure (Left (Http E.HeadersMalformed))
+                Just resp@Response.Response{headers} -> case lookupTransferEncoding headers of
+                  Left err -> pure (Left (Http err))
+                  Right enc -> case enc of
+                    Nonchunked -> handleNonchunkedBody ctx resp post headers
+                    Chunked -> handleChunkedBody ctx resp post
+  go mempty
+
+handleChunkedBody ::
+     Resource
+  -> Response
+  -> Bytes
+  -> M (Either Exception (Bodied Response))
+handleChunkedBody !ctx resp !input0 = do
+  let go contA !inputA = case Parser.parseBytes (parserChunked contA) inputA of
+        Parser.Failure e -> pure (Left (Http e))
+        Parser.Success (Parser.Slice _ leftoverLen contB) -> case leftoverLen of
+          -- We expect that parserChunked consumes all input, so we check
+          -- here to be certain that it actually does.
+          0 -> case contB of
+            Continuation Done revChunks -> pure $ Right $ Bodied
+              { metadata = resp
+              , body = Chunks.reverse revChunks
+              }
+            _ -> receive ctx >>= \case
+              Right inputB -> case Bytes.length inputB of
+                0 -> pure (Left (Http E.ChunkedBodyEndOfInput))
+                _ -> go contB inputB
+              Left err -> pure (Left (Receive err))
+          _ -> pure (Left (Http E.ImplementationMistake))
+  let cont0 = Continuation (ChunkLength 0) ChunksNil
+  go cont0 input0
+
+parserChunked :: Continuation -> Parser HttpException s Continuation
+parserChunked (Continuation instr chunks0) = case instr of
+  Done -> Parser.fail E.ImplementationMistake
+  More total n -> parserChunkedMore total n chunks0
+  MorePostCr total -> parserChunkedMorePostCr total chunks0
+  ChunkLength acc -> parserChunkedChunkLength acc chunks0
+  PostCr n -> parserChunkedChunkLengthPostCr n chunks0
+
+parserChunkedMore :: Int -> Int -> Chunks -> Parser HttpException s Continuation
+parserChunkedMore !total !n !chunks0 = case n of
+  -- If there are no more bytes left in the chunk, we start
+  -- on the next decimal-encoded chunk length.
+  0 -> parserChunkedMorePost total chunks0
+  _ -> do
+    b <- Parser.takeUpTo n
+    case Bytes.length b of
+      -- If there was no input left, we return to request more input.
+      -- If we didn't check for this, we would go into a loop.
+      0 -> pure (Continuation (More total n) chunks0)
+      m -> do
+        let chunks1 = ChunksCons b chunks0
+        parserChunkedMore total (n - m) chunks1
+
+parserChunkedMorePost :: Int -> Chunks -> Parser HttpException s Continuation
+parserChunkedMorePost !total !chunks0 = Latin.opt >>= \case
+  Just '\r' -> parserChunkedMorePostCr total chunks0
+  Just _ -> Parser.fail E.ExpectedCrlfAfterChunk
+  Nothing -> pure (Continuation (More total 0) chunks0)
+
+parserChunkedMorePostCr :: Int -> Chunks -> Parser HttpException s Continuation
+parserChunkedMorePostCr !total !chunks0 = Latin.opt >>= \case
+  Just '\n' -> case total of
+    0 -> pure (Continuation Done chunks0)
+    _ -> parserChunkedChunkLength 0 chunks0
+  Just _ -> Parser.fail E.ExpectedCrlfAfterChunk
+  Nothing -> pure (Continuation (MorePostCr total) chunks0)
+
+parserChunkedChunkLength :: Word64 -> Chunks -> Parser HttpException s Continuation
+parserChunkedChunkLength !acc !chunks0 = if acc > 100_000_000
+  then Parser.fail E.ChunkTooLarge
+  else Latin.opt >>= \case
+    Nothing -> pure (Continuation (ChunkLength acc) chunks0)
+    Just c -> case c of
+      '\r' -> Latin.opt >>= \case
+        Just d -> case d of
+          '\n' -> do
+            let !acc' = fromIntegral acc :: Int
+            parserChunkedMore acc' acc' chunks0
+          _ -> Parser.fail E.ExpectedCrlfAfterChunkLength
+        Nothing -> pure (Continuation (PostCr (fromIntegral acc)) chunks0)
+      _ | c >= '0', c <= '9' -> parserChunkedChunkLength (acc * 16 + fromIntegral (ord c - 0x30)) chunks0
+      _ | c >= 'a', c <= 'f' -> parserChunkedChunkLength (acc * 16 + fromIntegral (ord c - (0x61 - 10))) chunks0
+      _ | c >= 'A', c <= 'F' -> parserChunkedChunkLength (acc * 16 + fromIntegral (ord c - (0x41 - 10))) chunks0
+      _ -> Parser.fail E.NonNumericChunkLength
+
+parserChunkedChunkLengthPostCr :: Int -> Chunks -> Parser HttpException s Continuation
+parserChunkedChunkLengthPostCr !n !chunks0 = Latin.opt >>= \case
+  Just d -> case d of
+    '\n' -> parserChunkedMore n n chunks0
+    _ -> Parser.fail E.ExpectedCrlfAfterChunkLength
+  Nothing -> pure (Continuation (PostCr n) chunks0)
+
+handleNonchunkedBody :: Resource -> Response -> Bytes -> Headers -> M (Either Exception (Bodied Response))
+handleNonchunkedBody ctx resp !post !headers = case lookupContentLength headers of
+  Left err -> pure (Left (Http err))
+  Right len -> do
+    let finish reversedChunks n = case compare n 0 of
+          LT -> pure (Left (Http E.PipelinedResponses))
+          EQ -> pure $ Right $ Bodied
+            { metadata = resp
+            , body = Chunks.reverse reversedChunks
+            }
+          GT -> receive ctx >>= \case
+            Right chunk -> case Bytes.length chunk of
+              0 -> pure (Left (Http E.NonchunkedBodyEndOfInput))
+              _ -> finish (ChunksCons chunk reversedChunks) (n - Bytes.length chunk)
+            Left err -> pure (Left (Receive err))
+    finish (ChunksCons post ChunksNil) (len - Bytes.length post)
+
+splitEndOfHeaders :: Bytes -> Maybe (Bytes, Bytes)
+splitEndOfHeaders !b = case Bytes.findTetragramIndex 0x0D 0x0A 0x0D 0x0A b of
+  Nothing -> Nothing
+  Just n -> Just (Bytes.unsafeTake (n + 4) b, Bytes.unsafeDrop (n + 4) b)
+
+lookupTransferEncoding :: Headers -> Either HttpException TransferEncoding
+lookupTransferEncoding !hdrs =
+  case Headers.lookupTransferEncoding hdrs of
+    Right Header{value} -> case value of
+      "chunked" -> Right Chunked
+      _ -> Left E.TransferEncodingUnrecognized
+    Left Missing -> Right Nonchunked
+    Left Duplicate -> Left E.TransferEncodingDuplicated
+
+lookupContentLength :: Headers -> Either HttpException Int
+lookupContentLength !hdrs =
+  case Headers.lookupContentLength hdrs of
+    Left Missing -> Right 0
+    Left Duplicate -> Left E.ContentLengthDuplicated
+    Right Header{value} -> case readMaybe (T.unpack value) of
+      Nothing -> Left E.ContentLengthMalformed
+      Just i -> do
+        when (i > 8_000_000_000) (Left E.ContentLengthTooLarge)
+        Right i
diff --git a/test/Main.hs b/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/Main.hs
@@ -0,0 +1,131 @@
+{-# language DuplicateRecordFields #-}
+{-# language OverloadedStrings #-}
+
+import Test.Tasty (TestTree,testGroup,defaultMain)
+import Test.Tasty.HUnit (testCase,(@=?))
+import Data.Bytes (Bytes)
+import Http.Request (Request(Request),RequestLine(RequestLine))
+import Http.Types (Response, Bodied(Bodied), Header(Header))
+import OkChannel (M(M))
+import Data.Bytes.Chunks (Chunks(ChunksCons,ChunksNil))
+
+import qualified Data.Bytes as Bytes
+import qualified Data.Bytes.Chunks as Chunks
+import qualified GHC.Exts as Exts
+import qualified OkExchange as E
+import qualified Http.Header
+import qualified Http.Headers as Headers
+import qualified Http.Bodied
+import qualified Http.Request as Request
+import qualified Data.Bytes.Text.Ascii as Ascii
+
+main :: IO ()
+main = defaultMain tests
+
+tests :: TestTree
+tests = testGroup "tests"
+  [ testCase "get-a" $ do
+      (input,output,Bodied{body}) <- evaluateM (E.exchange () getReqA) (Chunks.fromBytes getRespA)
+      body @=? ChunksNil
+      input @=? mempty
+      output @=? Chunks.concat (Request.bodiedToChunks getReqA)
+  , testCase "get-chunked-a" $ do
+      (input,output,Bodied{body}) <- evaluateM (E.exchange () getReqA) (Chunks.fromBytes getRespChunkedA)
+      body @=? ChunksNil
+      input @=? mempty
+      output @=? Chunks.concat (Request.bodiedToChunks getReqA)
+  , testCase "get-chunked-byte-by-byte-a" $ do
+      (input,output,Bodied{body}) <- evaluateM (E.exchange () getReqA) (bytesToSingleByteChunks getRespChunkedA)
+      body @=? ChunksNil
+      input @=? mempty
+      output @=? Chunks.concat (Request.bodiedToChunks getReqA)
+  , testCase "get-body-a" $ do
+      (input,output,Bodied{body}) <- evaluateM (E.exchange () getReqA) (Chunks.fromBytes getRespBodyA)
+      input @=? mempty
+      body @=? ChunksCons (Ascii.fromString "helloworld") ChunksNil
+      output @=? Chunks.concat (Request.bodiedToChunks getReqA)
+  , testCase "get-chunked-b" $ do
+      (input,output,Bodied{body}) <- evaluateM (E.exchange () getReqA) (Chunks.fromBytes getRespChunkedB)
+      Ascii.fromString "hello to my friends." @=? Chunks.concat body
+      mempty @=? input
+      Chunks.concat (Request.bodiedToChunks getReqA) @=? output
+  , testCase "get-chunked-byte-by-byte-b" $ do
+      (input,output,Bodied{body}) <- evaluateM (E.exchange () getReqA) (bytesToSingleByteChunks getRespChunkedB)
+      Ascii.fromString "hello to my friends." @=? Chunks.concat body
+      mempty @=? input
+      Chunks.concat (Request.bodiedToChunks getReqA) @=? output
+  , testCase "get-chunked-two-by-two-b" $ do
+      (input,output,Bodied{body}) <- evaluateM (E.exchange () getReqA) (bytesToDoubletonByteChunks getRespChunkedB)
+      Ascii.fromString "hello to my friends." @=? Chunks.concat body
+      mempty @=? input
+      Chunks.concat (Request.bodiedToChunks getReqA) @=? output
+  ]
+
+bytesToSingleByteChunks :: Bytes -> Chunks
+bytesToSingleByteChunks = Bytes.foldr'
+  (\w acc -> ChunksCons (Bytes.singleton w) acc
+  ) ChunksNil
+
+bytesToDoubletonByteChunks :: Bytes -> Chunks
+bytesToDoubletonByteChunks b0 = go (Exts.toList b0)
+  where
+  go (x : y : zs) = ChunksCons (Exts.fromList [x,y]) (go zs)
+  go [x] = ChunksCons (Bytes.singleton x) ChunksNil
+  go [] = ChunksNil
+
+evaluateM ::
+     M (Either E.Exception (Bodied Response))
+  -> Chunks -- prebuilt response
+  -> IO (Chunks,Bytes,Bodied Response)
+evaluateM (M f) resp = case f resp mempty of
+  (input,output,r) -> case r of
+    Left e -> case e of
+      E.Http err -> fail ("exchange http protocol failure: " ++ show err)
+      E.Receive err -> fail ("exchange http transport receive failure: " ++ show err)
+    Right b -> pure (input,output,b)
+
+getReqA :: Bodied Request
+getReqA = Bodied
+  { metadata = Request
+    { requestLine=RequestLine
+      { method = "GET"
+      , path = "/health"
+      }
+    , headers = Headers.fromArray $ Exts.fromList
+      [ Header{name="Host",value="example.com"}
+      ]
+    }
+  , body = mempty
+  }
+
+getRespA :: Bytes
+getRespA = Ascii.fromString
+  "HTTP/1.1 200 OK\r\n\
+  \Server: testsuite/1.2.3\r\n\
+  \Content-Type: text/plain\r\n\r\n"
+
+getRespBodyA :: Bytes
+getRespBodyA = Ascii.fromString
+  "HTTP/1.1 200 OK\r\n\
+  \Server: testsuite/1.2.3\r\n\
+  \Content-Type: text/plain\r\n\
+  \Content-Length: 10\r\n\r\n\
+  \helloworld"
+
+getRespChunkedA :: Bytes
+getRespChunkedA = Ascii.fromString
+  "HTTP/1.1 200 OK\r\n\
+  \Server: testsuite/1.2.3\r\n\
+  \Transfer-Encoding: chunked\r\n\
+  \Content-Type: text/plain\r\n\r\n\
+  \0\r\n\r\n"
+
+getRespChunkedB :: Bytes
+getRespChunkedB = Ascii.fromString
+  "HTTP/1.1 200 OK\r\n\
+  \Server: testsuite/1.2.3\r\n\
+  \Transfer-Encoding: chunked\r\n\
+  \Content-Type: text/plain\r\n\r\n\
+  \5\r\nhello\r\n\
+  \f\r\n to my friends.\r\n\
+  \0\r\n\r\n"
