diff --git a/Network/Wreq.hs b/Network/Wreq.hs
--- a/Network/Wreq.hs
+++ b/Network/Wreq.hs
@@ -56,6 +56,9 @@
     -- ** DELETE
     , delete
     , deleteWith
+    -- ** Custom Method
+    , customMethod
+    , customMethodWith
     -- * Incremental consumption of responses
     -- ** GET
     , foldGet
@@ -72,13 +75,17 @@
     , Lens.params
     , Lens.cookie
     , Lens.cookies
+    , Lens.checkStatus
+
     -- ** Authentication
     -- $auth
     , Auth
+    , AWSAuthVersion(..)
     , Lens.auth
     , basicAuth
     , oauth2Bearer
     , oauth2Token
+    , awsAuth
     -- ** Proxy settings
     , Proxy(Proxy)
     , Lens.proxy
@@ -116,6 +123,7 @@
     , Lens.responseStatus
     , Lens.Status
     , Lens.statusCode
+    , Lens.statusMessage
     -- ** Link headers
     , Lens.Link
     , Lens.linkURL
@@ -136,13 +144,13 @@
 
     -- * Parsing responses
     , Lens.atto
+    , Lens.atto_
     ) where
 
 import Control.Lens ((.~), (&))
 import Control.Monad (unless)
 import Control.Monad.Catch (MonadThrow(throwM))
 import Data.Aeson (FromJSON)
-import Data.ByteString.Char8 ()
 import Data.Maybe (fromMaybe)
 import Data.Text (Text)
 import Data.Text.Encoding (encodeUtf8)
@@ -157,10 +165,9 @@
 import qualified Data.Text as T
 import qualified Network.HTTP.Client as HTTP
 import qualified Network.HTTP.Client.MultipartFormData as Form
-import qualified Network.HTTP.Types as HTTP
-import qualified Network.Wreq.Internal.Lens as Int
 import qualified Network.Wreq.Lens as Lens
 import qualified Network.Wreq.Types as Wreq
+import qualified Data.ByteString.Char8 as BC8
 
 -- | Issue a GET request.
 --
@@ -194,7 +201,7 @@
 -- >>> r ^? responseBody . key "url"
 -- Just (String "http://httpbin.org/get?foo=bar")
 getWith :: Options -> String -> IO (Response L.ByteString)
-getWith opts url = request id opts url readResponse
+getWith opts url = runRead =<< prepareGet opts url
 
 -- | Issue a POST request.
 --
@@ -224,9 +231,7 @@
 -- >>> r ^? responseBody . key "url"
 -- Just (String "http://httpbin.org/post?foo=bar")
 postWith :: Postable a => Options -> String -> a -> IO (Response L.ByteString)
-postWith opts url payload =
-  requestIO (postPayload payload . (Int.method .~ HTTP.methodPost)) opts url
-    readResponse
+postWith opts url payload = runRead =<< preparePost opts url payload
 
 -- | Issue a HEAD request.
 --
@@ -256,7 +261,7 @@
 -- >>> r ^? responseHeader "Connection"
 -- Just "keep-alive"
 headWith :: Options -> String -> IO (Response ())
-headWith = emptyMethodWith HTTP.methodHead
+headWith opts url = runIgnore =<< prepareHead opts url
 
 -- | Issue a PUT request.
 put :: Putable a => String -> a -> IO (Response L.ByteString)
@@ -264,9 +269,7 @@
 
 -- | Issue a PUT request, using the supplied 'Options'.
 putWith :: Putable a => Options -> String -> a -> IO (Response L.ByteString)
-putWith opts url payload =
-  requestIO (putPayload payload . (Int.method .~ HTTP.methodPut)) opts url
-    readResponse
+putWith opts url payload = runRead =<< preparePut opts url payload
 
 -- | Issue an OPTIONS request.
 --
@@ -289,7 +292,7 @@
 --'optionsWith' opts \"http:\/\/httpbin.org\/get\"
 -- @
 optionsWith :: Options -> String -> IO (Response ())
-optionsWith = emptyMethodWith HTTP.methodOptions
+optionsWith opts url = runIgnore =<< prepareOptions opts url
 
 -- | Issue a DELETE request.
 --
@@ -302,7 +305,7 @@
 -- >>> r <- delete "http://httpbin.org/delete"
 -- >>> r ^. responseStatus . statusCode
 -- 200
-delete :: String -> IO (Response ())
+delete :: String -> IO (Response L.ByteString)
 delete = deleteWith defaults
 
 -- | Issue a DELETE request, using the supplied 'Options'.
@@ -318,14 +321,45 @@
 -- >>> r <- deleteWith opts "http://httpbin.org/delete"
 -- >>> r ^. responseStatus . statusCode
 -- 200
-deleteWith :: Options -> String -> IO (Response ())
-deleteWith = emptyMethodWith HTTP.methodDelete
+deleteWith :: Options -> String -> IO (Response L.ByteString)
+deleteWith opts url = runRead =<< prepareDelete opts url
 
+-- | Issue a custom-method request
+--
+-- Example:
+-- @
+-- 'customMethod' \"PATCH\" \"http:\/\/httpbin.org\/patch\"
+-- @
+--
+-- >>> r <- customMethod "PATCH" "http://httpbin.org/patch"
+-- >>> r ^. responseStatus . statusCode
+-- 200
+customMethod :: String -> String -> IO (Response L.ByteString)
+customMethod method url = customMethodWith method defaults url
+
+-- | Issue a custom request method request, using the supplied 'Options'.
+--
+-- Example:
+--
+-- @
+--let opts = 'defaults' '&' 'Lens.redirects' '.~' 0
+--'customMethodWith' \"PATCH\" opts \"http:\/\/httpbin.org\/patch\"
+-- @
+--
+-- >>> let opts = defaults & redirects .~ 0
+-- >>> r <- customMethodWith "PATCH" opts "http://httpbin.org/patch"
+-- >>> r ^. responseStatus . statusCode
+-- 200
+customMethodWith :: String -> Options -> String -> IO (Response L.ByteString)
+customMethodWith method opts url = runRead =<< prepareMethod methodBS opts url
+  where
+    methodBS = BC8.pack method
+
 foldGet :: (a -> S.ByteString -> IO a) -> a -> String -> IO a
 foldGet f z url = foldGetWith defaults f z url
 
 foldGetWith :: Options -> (a -> S.ByteString -> IO a) -> a -> String -> IO a
-foldGetWith opts f z0 url = request id opts url (foldResponseBody f z0)
+foldGetWith opts f z0 url = request return opts url (foldResponseBody f z0)
 
 -- | Convert the body of an HTTP response from JSON to a suitable
 -- Haskell type.
@@ -405,18 +439,22 @@
 -- Example (note the use of TLS):
 --
 -- @
---let opts = 'defaults' '&' 'Lens.auth' '.~' 'basicAuth' \"user\" \"pass\"
+--let opts = 'defaults' '&' 'Lens.auth' '?~' 'basicAuth' \"user\" \"pass\"
 --'getWith' opts \"https:\/\/httpbin.org\/basic-auth\/user\/pass\"
 -- @
 --
--- >>> let opts = defaults & auth .~ basicAuth "user" "pass"
+-- Note here the use of the 'Control.Lens.?~' setter to turn an 'Auth'
+-- into a 'Maybe' 'Auth', to make the type of the RHS compatible with
+-- the 'Lens.auth' lens.
+--
+-- >>> let opts = defaults & auth ?~ basicAuth "user" "pass"
 -- >>> r <- getWith opts "https://httpbin.org/basic-auth/user/pass"
 -- >>> r ^? responseBody . key "authenticated"
 -- Just (Bool True)
 basicAuth :: S.ByteString       -- ^ Username.
           -> S.ByteString       -- ^ Password.
-          -> Maybe Auth
-basicAuth user pass = Just (BasicAuth user pass)
+          -> Auth
+basicAuth = BasicAuth
 
 -- | An OAuth2 bearer token. This is treated by many services as the
 -- equivalent of a username and password.
@@ -424,11 +462,11 @@
 -- Example (note the use of TLS):
 --
 -- @
---let opts = 'defaults' '&' 'Lens.auth' '.~' 'oauth2Bearer' \"1234abcd\"
+--let opts = 'defaults' '&' 'Lens.auth' '?~' 'oauth2Bearer' \"1234abcd\"
 --'getWith' opts \"https:\/\/public-api.wordpress.com\/rest\/v1\/me\/\"
 -- @
-oauth2Bearer :: S.ByteString -> Maybe Auth
-oauth2Bearer token = Just (OAuth2Bearer token)
+oauth2Bearer :: S.ByteString -> Auth
+oauth2Bearer = OAuth2Bearer
 
 -- | A not-quite-standard OAuth2 bearer token (that seems to be used
 -- only by GitHub). This will be treated by whatever services accept
@@ -437,26 +475,37 @@
 -- Example (note the use of TLS):
 --
 -- @
---let opts = 'defaults' '&' 'Lens.auth' '.~' 'oauth2Token' \"abcd1234\"
+--let opts = 'defaults' '&' 'Lens.auth' '?~' 'oauth2Token' \"abcd1234\"
 --'getWith' opts \"https:\/\/api.github.com\/user\"
 -- @
-oauth2Token :: S.ByteString -> Maybe Auth
-oauth2Token token = Just (OAuth2Token token)
+oauth2Token :: S.ByteString -> Auth
+oauth2Token = OAuth2Token
 
+-- | AWS v4 request signature.
+--
+-- Example (note the use of TLS):
+--
+-- @
+--let opts = 'defaults' '&' 'Lens.auth' '?~' 'awsAuth AWSv4' \"key\" \"secret\"
+--'getWith' opts \"https:\/\/dynamodb.us-west-2.amazonaws.com\"
+-- @
+awsAuth :: AWSAuthVersion -> S.ByteString -> S.ByteString -> Auth
+awsAuth = AWSAuth
+
 -- | Proxy configuration.
 --
 -- Example:
 --
 -- @
---let opts = 'defaults' '&' 'Lens.proxy' '.~' 'httpProxy' \"localhost\" 8000
+--let opts = 'defaults' '&' 'Lens.proxy' '?~' 'httpProxy' \"localhost\" 8000
 --'getWith' opts \"http:\/\/httpbin.org\/get\"
 -- @
 --
--- (You may wonder why this function returns a 'Maybe Proxy'. This
--- allows it to be easily used on the right hand side of an operation,
--- as above, without its result needing to be wrapped in 'Just'.)
-httpProxy :: S.ByteString -> Int -> Maybe Proxy
-httpProxy host port = Just (Proxy host port)
+-- Note here the use of the 'Control.Lens.?~' setter to turn a 'Proxy'
+-- into a 'Maybe' 'Proxy', to make the type of the RHS compatible with
+-- the 'Lens.proxy' lens.
+httpProxy :: S.ByteString -> Int -> Proxy
+httpProxy = Proxy
 
 -- | Make a 'Part' whose content is a strict 'T.Text', encoded as
 -- UTF-8.
diff --git a/Network/Wreq/Cache.hs b/Network/Wreq/Cache.hs
new file mode 100644
--- /dev/null
+++ b/Network/Wreq/Cache.hs
@@ -0,0 +1,153 @@
+{-# LANGUAGE CPP, DeriveDataTypeable, DeriveFunctor, DeriveGeneric,
+    OverloadedStrings, RecordWildCards #-}
+
+module Network.Wreq.Cache
+    (
+      shouldCache
+    , validateEntry
+    , cacheStore
+    ) where
+
+import Control.Applicative
+import Control.Lens ((^?), (^.), (^..), folded, non, pre, to)
+import Control.Monad (guard)
+import Data.Attoparsec.ByteString.Char8 as A
+import Data.CaseInsensitive (mk)
+import Data.Foldable (forM_)
+import Data.HashSet (HashSet)
+import Data.Hashable (Hashable)
+import Data.IntSet (IntSet)
+import Data.IORef (newIORef)
+import Data.List (sort)
+import Data.Maybe (listToMaybe)
+import Data.Monoid (First(..), mconcat)
+import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime)
+import Data.Time.Format (parseTime)
+import Data.Typeable (Typeable)
+import GHC.Generics (Generic)
+import Network.HTTP.Types (HeaderName, Method)
+import Network.Wreq.Internal.Lens
+import Network.Wreq.Internal.Types
+import Network.Wreq.Lens
+import System.Locale (defaultTimeLocale)
+import qualified Data.ByteString.Char8 as B
+import qualified Data.HashSet as HashSet
+import qualified Data.IntSet as IntSet
+import qualified Network.Wreq.Cache.Store as Store
+
+#if MIN_VERSION_base(4,6,0)
+import Data.IORef (atomicModifyIORef')
+#else
+import Data.IORef (IORef, atomicModifyIORef)
+
+atomicModifyIORef' :: IORef a -> (a -> (a, b)) -> IO b
+atomicModifyIORef' = atomicModifyIORef
+#endif
+
+cacheStore :: Int -> IO (Run body -> Run body)
+cacheStore capacity = do
+  cache <- newIORef (Store.empty capacity)
+  return $ \run req -> do
+    let url = reqURL req
+    before <- getCurrentTime
+    mresp <- atomicModifyIORef' cache $ \s ->
+      case Store.lookup url s of
+        Nothing       -> (s, Nothing)
+        Just (ce, s') ->
+          case validateEntry before ce of
+            n@Nothing -> (Store.delete url s, n)
+            resp      -> (s', resp)
+    case mresp of
+      Just resp -> return resp
+      Nothing -> do
+        resp <- run req
+        after <- getCurrentTime
+        forM_ (shouldCache after req resp) $ \ce ->
+          atomicModifyIORef' cache $ \s -> (Store.insert url ce s, ())
+        return resp
+
+cacheableStatuses :: IntSet
+cacheableStatuses = IntSet.fromList [200, 203, 300, 301, 410]
+
+cacheableMethods :: HashSet Method
+cacheableMethods = HashSet.fromList ["GET", "HEAD", "OPTIONS"]
+
+possiblyCacheable :: Request -> Response body -> Bool
+possiblyCacheable req resp =
+    (req ^. method) `HashSet.member` cacheableMethods &&
+    (resp ^. responseStatus . statusCode) `IntSet.member` cacheableStatuses
+
+computeExpiration :: UTCTime -> [CacheResponse Seconds] -> Maybe UTCTime
+computeExpiration now crs = do
+  guard $ and [NoCache [] `notElem` crs, NoStore `notElem` crs]
+  age <- listToMaybe $ sort [age | MaxAge age <- crs]
+  return $! fromIntegral age `addUTCTime` now
+
+validateEntry :: UTCTime -> CacheEntry body -> Maybe (Response body)
+validateEntry now CacheEntry{..} =
+  case entryExpires of
+    Nothing          -> Just entryResponse
+    Just e | e > now -> Just entryResponse
+    _                -> Nothing
+
+shouldCache :: UTCTime -> Req -> Response body -> Maybe (CacheEntry body)
+shouldCache now (Req _ req) resp = do
+  guard (possiblyCacheable req resp)
+  let crs = resp ^.. responseHeader "Cache-Control" . atto_ parseCacheResponse .
+                     folded . to simplifyCacheResponse
+      dateHeader name = responseHeader name . to parseDate . folded
+      mexpires = case crs of
+                   [] -> resp ^? dateHeader "Expires"
+                   _  -> computeExpiration now crs
+      created = resp ^. pre (dateHeader "Date") . non now
+  case mexpires of
+    Just expires | expires <= created                -> empty
+    Nothing      | req ^. method == "GET" &&
+                   not (B.null (req ^. queryString)) -> empty
+    _ -> return $ CacheEntry created mexpires resp
+
+type Seconds = Int
+
+data CacheResponse age = Public
+                       | Private [HeaderName]
+                       | NoCache [HeaderName]
+                       | NoStore
+                       | NoTransform
+                       | MustRevalidate
+                       | ProxyRevalidate
+                       | MaxAge age
+                       | SMaxAge age
+                       | Extension
+                       deriving (Eq, Show, Functor, Typeable, Generic)
+
+instance Hashable age => Hashable (CacheResponse age)
+
+simplifyCacheResponse :: CacheResponse age -> CacheResponse age
+simplifyCacheResponse (Private _) = Private []
+simplifyCacheResponse (NoCache _) = NoCache []
+simplifyCacheResponse cr          = cr
+
+parseCacheResponse :: A.Parser [CacheResponse Seconds]
+parseCacheResponse = commaSep1 body
+  where
+    body = "public" *> pure Public
+       <|> "private" *> (Private <$> (eq headerNames <|> pure []))
+       <|> "no-cache" *> (NoCache <$> (eq headerNames <|> pure []))
+       <|> "no-store" *> pure NoStore
+       <|> "no-transform" *> pure NoTransform
+       <|> "must-revalidate" *> pure MustRevalidate
+       <|> "proxy-revalidate" *> pure ProxyRevalidate
+       <|> "max-age" *> eq (MaxAge <$> decimal)
+       <|> "s-maxage" *> eq (SMaxAge <$> decimal)
+    headerNames = A.char '"' *> commaSep1 hdr <* A.char '"'
+    hdr = mk <$> A.takeWhile1 (inClass "a-zA-Z0-9_-")
+    commaSep1 p = (p <* skipSpace) `sepBy1` (A.char ',' *> skipSpace)
+    eq p = skipSpace *> A.char '=' *> skipSpace *> p
+
+parseDate :: B.ByteString -> Maybe UTCTime
+parseDate s = getFirst . mconcat . map tryout $ [
+    "%a, %d %b %Y %H:%M:%S %Z"
+  , "%A, %d-%b-%y %H:%M:%S %Z"
+  , "%a %b %e %H:%M:%S %Y"
+  ]
+  where tryout fmt = First $ parseTime defaultTimeLocale fmt (B.unpack s)
diff --git a/Network/Wreq/Cache/Store.hs b/Network/Wreq/Cache/Store.hs
new file mode 100644
--- /dev/null
+++ b/Network/Wreq/Cache/Store.hs
@@ -0,0 +1,83 @@
+{-# LANGUAGE BangPatterns, DeriveFunctor, RecordWildCards #-}
+
+module Network.Wreq.Cache.Store
+    (
+      Store
+    , empty
+    , insert
+    , delete
+    , lookup
+    , fromList
+    , toList
+    ) where
+
+import Data.Hashable (Hashable)
+import Data.Int (Int64)
+import Data.List (foldl')
+import Prelude hiding (lookup, map)
+import qualified Data.HashMap.Lazy as HM
+import qualified Data.PSQueue as PSQ
+
+type Epoch = Int64
+
+data Store k v = Store {
+    capacity :: {-# UNPACK #-} !Int
+  , size     :: {-# UNPACK #-} !Int
+  , epoch    :: {-# UNPACK #-} !Epoch
+  , lru      :: !(PSQ.PSQ k Epoch)
+  , map      :: !(HM.HashMap k v)
+  }
+
+instance (Show k, Show v, Ord k, Hashable k) => Show (Store k v) where
+    show st = "fromList " ++ show (toList st)
+
+empty :: Ord k => Int -> Store k v
+empty cap
+  | cap <= 0  = error "empty: invalid capacity"
+  | otherwise = Store cap 0 0 PSQ.empty HM.empty
+{-# INLINABLE empty #-}
+
+insert :: (Ord k, Hashable k) => k -> v -> Store k v -> Store k v
+insert k v st@Store{..}
+  | size < capacity || present =
+    st { size  = if present then size else size + 1
+       , epoch = epoch + 1
+       , lru   = PSQ.insert k epoch lru
+       , map   = HM.insert k v map
+       }
+  | otherwise =
+      let Just (mink PSQ.:-> _, lru0) = PSQ.minView lru
+      in st { epoch = epoch + 1
+            , lru   = PSQ.insert k epoch lru0
+            , map   = HM.insert k v $ if mink == k
+                                      then map
+                                      else HM.delete mink map
+            }
+  where present = k `HM.member` map
+{-# INLINABLE insert #-}
+
+lookup :: (Ord k, Hashable k) => k -> Store k v -> Maybe (v, Store k v)
+lookup k st@Store{..} = do
+  v <- HM.lookup k map
+  let !st' = st { epoch = epoch + 1, lru = PSQ.insert k epoch lru }
+  return (v, st')
+{-# INLINABLE lookup #-}
+
+delete :: (Ord k, Hashable k) => k -> Store k v -> Store k v
+delete k st@Store{..}
+  | k `HM.member` map =
+    st { size = size - 1
+       , lru  = PSQ.delete k lru
+       , map  = HM.delete k map
+       }
+  | otherwise = st
+{-# INLINABLE delete #-}
+
+fromList :: (Ord k, Hashable k) => Int -> [(k, v)] -> Store k v
+fromList = foldl' (flip (uncurry insert)) . empty
+{-# INLINABLE fromList #-}
+
+toList :: (Ord k, Hashable k) => Store k v -> [(k, v)]
+toList Store{..} = [(k,v) | (k PSQ.:-> _) <- PSQ.toList lru,
+                            let v = map HM.! k]
+{-# INLINABLE toList #-}
diff --git a/Network/Wreq/Internal.hs b/Network/Wreq/Internal.hs
--- a/Network/Wreq/Internal.hs
+++ b/Network/Wreq/Internal.hs
@@ -9,7 +9,15 @@
     , ignoreResponse
     , readResponse
     , request
-    , requestIO
+    , prepareGet
+    , preparePost
+    , runRead
+    , prepareHead
+    , runIgnore
+    , prepareOptions
+    , preparePut
+    , prepareDelete
+    , prepareMethod
     ) where
 
 import Control.Applicative ((<$>))
@@ -22,7 +30,8 @@
 import Network.HTTP.Client.Internal (Proxy(..), Request, Response(..), addProxy)
 import Network.HTTP.Client.TLS (tlsManagerSettings)
 import Network.Wreq.Internal.Lens (setHeader)
-import Network.Wreq.Types (Auth(..), Options(..))
+import Network.Wreq.Internal.Types (Mgr, Req(..), Run)
+import Network.Wreq.Types (Auth(..), Options(..), Postable(..), Putable(..))
 import Prelude hiding (head)
 import qualified Data.ByteString as S
 import qualified Data.ByteString.Char8 as Char8
@@ -30,7 +39,8 @@
 import qualified Network.HTTP.Client as HTTP
 import qualified Network.HTTP.Types as HTTP
 import qualified Network.Wreq.Internal.Lens as Lens
-import qualified Network.Wreq.Lens as Lens
+import qualified Network.Wreq.Internal.AWS as AWS (signRequest)
+import qualified Network.Wreq.Lens as Lens hiding (checkStatus)
 
 -- This mess allows this module to continue to load during interactive
 -- development in ghci :-(
@@ -38,6 +48,7 @@
 import Paths_wreq (version)
 #else
 import Data.Version (Version(..))
+version :: Version
 version = Version [0] ["wip"]
 #endif
 
@@ -46,13 +57,14 @@
 
 defaults :: Options
 defaults = Options {
-    manager   = Left defaultManagerSettings
-  , proxy     = Nothing
-  , auth      = Nothing
-  , headers   = [("User-Agent", userAgent)]
-  , params    = []
-  , redirects = 10
-  , cookies   = HTTP.createCookieJar []
+    manager     = Left defaultManagerSettings
+  , proxy       = Nothing
+  , auth        = Nothing
+  , headers     = [("User-Agent", userAgent)]
+  , params      = []
+  , redirects   = 10
+  , cookies     = HTTP.createCookieJar []
+  , checkStatus = Nothing
   }
   where userAgent = "haskell wreq-" <> Char8.pack (showVersion version)
 
@@ -63,7 +75,7 @@
 
 emptyMethodWith :: HTTP.Method -> Options -> String -> IO (Response ())
 emptyMethodWith method opts url =
-  request (Lens.method .~ method) opts url ignoreResponse
+  request (return . (Lens.method .~ method)) opts url ignoreResponse
 
 ignoreResponse :: Response BodyReader -> IO (Response ())
 ignoreResponse resp = (Lens.responseBody .~ ()) <$> readResponse resp
@@ -82,25 +94,30 @@
             then return z
             else f z bs >>= go
 
-requestIO :: (Request -> IO Request) -> Options -> String
-          -> (Response BodyReader -> IO a) -> IO a
-requestIO modify opts url body =
-    either (flip HTTP.withManager go) go (manager opts)
+request :: (Request -> IO Request) -> Options -> String
+        -> (Response BodyReader -> IO a) -> IO a
+request modify opts url act = run (manager opts) act =<< prepare modify opts url
+
+run :: Mgr -> (Response BodyReader -> IO a) -> Request -> IO a
+run emgr act req = either (flip HTTP.withManager go) go emgr
+  where go mgr = HTTP.withResponse req mgr act
+
+prepare :: (Request -> IO Request) -> Options -> String -> IO Request
+prepare modify opts url = do
+  signRequest =<< modify =<< frob <$> HTTP.parseUrl url
   where
-    go mgr = do
-      let frob req = req
-                   & Lens.requestHeaders %~ (headers opts ++)
+    frob req = req & Lens.requestHeaders %~ (headers opts ++)
                    & setQuery opts
                    & setAuth opts
                    & setProxy opts
+                   & setCheckStatus opts
                    & setRedirects opts
                    & Lens.cookieJar .~ Just (cookies opts)
-      req <- modify =<< (frob <$> HTTP.parseUrl url)
-      HTTP.withResponse req mgr body
-
-request :: (Request -> Request) -> Options -> String
-        -> (Response BodyReader -> IO a) -> IO a
-request f = requestIO (return . f)
+    signRequest :: Request -> IO Request
+    signRequest = maybe return f $ auth opts
+      where
+        f (AWSAuth versn key secret) = AWS.signRequest versn key secret
+        f _ = return
 
 setQuery :: Options -> Request -> Request
 setQuery opts =
@@ -117,7 +134,43 @@
     f (BasicAuth user pass) = HTTP.applyBasicAuth user pass
     f (OAuth2Bearer token)  = setHeader "Authorization" ("Bearer " <> token)
     f (OAuth2Token token)   = setHeader "Authorization" ("token " <> token)
+    -- for AWS request signature, see Internal/AWS
+    f (AWSAuth _ _ _)       = id
 
 setProxy :: Options -> Request -> Request
 setProxy = maybe id f . proxy
   where f (Proxy host port) = addProxy host port
+
+setCheckStatus :: Options -> Request -> Request
+setCheckStatus = maybe id f . checkStatus
+  where f cs = ( & Lens.checkStatus .~ cs)
+
+prepareGet :: Options -> String -> IO Req
+prepareGet opts url = Req (manager opts) <$> prepare return opts url
+
+runRead :: Run L.ByteString
+runRead (Req mgr req) = run mgr readResponse req
+
+preparePost :: Postable a => Options -> String -> a -> IO Req
+preparePost opts url payload = Req (manager opts) <$>
+  prepare (postPayload payload . (Lens.method .~ HTTP.methodPost)) opts url
+
+prepareMethod :: HTTP.Method -> Options -> String -> IO Req
+prepareMethod method opts url = Req (manager opts) <$>
+  prepare (return . (Lens.method .~ method)) opts url
+
+prepareHead :: Options -> String -> IO Req
+prepareHead = prepareMethod HTTP.methodHead
+
+runIgnore :: Run ()
+runIgnore (Req mgr req) = run mgr ignoreResponse req
+
+prepareOptions :: Options -> String -> IO Req
+prepareOptions = prepareMethod HTTP.methodOptions
+
+preparePut :: Putable a => Options -> String -> a -> IO Req
+preparePut opts url payload = Req (manager opts) <$>
+  prepare (putPayload payload . (Lens.method .~ HTTP.methodPut)) opts url
+
+prepareDelete :: Options -> String -> IO Req
+prepareDelete = prepareMethod HTTP.methodDelete
diff --git a/Network/Wreq/Internal/AWS.hs b/Network/Wreq/Internal/AWS.hs
new file mode 100644
--- /dev/null
+++ b/Network/Wreq/Internal/AWS.hs
@@ -0,0 +1,189 @@
+{-# LANGUAGE OverloadedStrings, BangPatterns #-}
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+
+module Network.Wreq.Internal.AWS
+    (
+      signRequest
+    , addTmpPayloadHashHeader
+    ) where
+
+import Control.Applicative ((<$>))
+import Control.Lens ((%~), (^.), (&), to)
+import Crypto.MAC (hmac, hmacGetDigest)
+import Data.ByteString.Base16 as HEX (encode)
+import Data.Byteable (toBytes)
+import Data.Char (toLower)
+import Data.List (sort)
+import Data.Maybe (fromJust)
+import Data.Monoid ((<>))
+import Data.Time.Clock (getCurrentTime)
+import Data.Time.Format (formatTime)
+import Data.Time.LocalTime (utc, utcToLocalTime)
+import Network.HTTP.Types (parseSimpleQuery, urlEncode)
+import Network.Wreq.Internal.Lens
+import Network.Wreq.Internal.Types (AWSAuthVersion(..))
+import System.Locale (defaultTimeLocale)
+import qualified Crypto.Hash as CT (HMAC, SHA256)
+import qualified Crypto.Hash.SHA256 as SHA256 (hash, hashlazy)
+import qualified Data.ByteString.Char8 as S
+import qualified Data.CaseInsensitive  as CI (CI, original)
+import qualified Data.HashSet as HashSet
+import qualified Network.HTTP.Client as HTTP
+
+-- Sign requests following the AWS v4 request signing specification:
+-- http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
+--
+-- Runscope Inc. Traffic Inspector support:
+-- We support (optionally) sending requests through the Runscope
+-- (http://www.runscope.com) Traffic Inspector. If given a Runscope
+-- URL to an AWS service, we will extract and correctly sign the
+-- request for the underlying AWS service. We support Runscope buckets
+-- with and without Bucket Authorization enabled
+-- ("Runscope-Bucket-Auth").
+--
+-- Q: how do we get the payload hash to the signRequest function?
+--
+-- A: we use a (temporary) HTTP header to 'tunnel' the payload hash to
+-- the signing function.  For POST and PUT requests, the
+-- Network.Wreq.Types.payload function adds a HTTP header (name
+-- defined in 'tmpPayloadHashHeader'). The
+-- Network.Wreq.Internal.AWS.signRequest function reads the value of
+-- the header and then removes it from the request.  For GET, HEAD,
+-- and (currently) DELETE that carry no body, we use "" per AWS
+-- documentation Item 6: "use empty string" in
+-- http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
+
+-- TODO: adjust when DELETE supports a body or PATCH is added
+signRequest :: AWSAuthVersion -> S.ByteString -> S.ByteString ->
+               Request -> IO Request
+signRequest AWSv4 = signRequestV4
+
+signRequestV4 :: S.ByteString -> S.ByteString -> Request -> IO Request
+signRequestV4 key secret request = do
+  !ts <- timestamp                         -- YYYYMMDDT242424Z, UTC based
+  let origHost = request ^. host          -- potentially w/ runscope bucket
+      runscopeBucketAuth =
+        lookup "Runscope-Bucket-Auth" $ request ^. requestHeaders
+      noRunscopeHost = removeRunscope origHost -- rm Runscope for signing
+      (service, region) = serviceAndRegion noRunscopeHost
+      date = S.takeWhile (/= 'T') ts      -- YYYYMMDD
+      hashedPayload
+        | request ^. method `elem` ["POST", "PUT"] =
+          fromJust . lookup tmpPayloadHashHeader $ request ^. requestHeaders
+        | otherwise = HEX.encode $ SHA256.hash ""
+      -- add common v4 signing headers, service specific headers, and
+      -- drop tmp header and Runscope-Bucket-Auth header (if present).
+      req = request & requestHeaders %~
+            (([ ("host", noRunscopeHost)
+              , ("x-amz-date", ts)] ++
+              [("x-amz-content-sha256", hashedPayload) | service == "s3"]) ++)
+            . deleteKey tmpPayloadHashHeader -- drop tmp header
+            -- Runscope (correctly) doesn't send Bucket Auth header to AWS,
+            -- remove it from the headers we sign. Adding back in at the end.
+            . deleteKey "Runscope-Bucket-Auth"
+  -- task 1
+  let hl = req ^. requestHeaders . to sort
+      signedHeaders = S.intercalate ";" . map (lowerCI . fst) $ hl
+      canonicalReq = S.intercalate "\n" [
+          req ^. method             -- step 1
+        , req ^. path               -- step 2
+        ,   S.intercalate "&"       -- step 3b, incl. sort
+            -- urlEncode True (QS) to encode ':' and '/' (e.g. in AWS arns)
+          . map (\(k,v) -> urlEncode True k <> "=" <> urlEncode True v)
+          . sort $
+          parseSimpleQuery $ req ^. queryString
+        ,   S.unlines                -- step 4, incl. sort
+          . map (\(k,v) -> lowerCI k <> ":" <> trimHeaderValue v) $ hl
+        , signedHeaders             -- step 5
+        , hashedPayload             -- step 6, handles empty payload
+        ]
+  -- task 2
+  let dateScope = S.intercalate "/" [date, region, service, "aws4_request"]
+      stringToSign = S.intercalate "\n" [
+          "AWS4-HMAC-SHA256"
+        , ts
+        , dateScope
+        , HEX.encode $ SHA256.hash canonicalReq
+        ]
+  -- task 3, steps 1 and 2
+  let signature = ("AWS4" <> secret) &
+                  hmac' date & hmac' region & hmac' service &
+                  hmac' "aws4_request" & hmac' stringToSign & HEX.encode
+      authorization = S.intercalate ", " [
+          "AWS4-HMAC-SHA256 Credential=" <> key <> "/" <> dateScope
+        , "SignedHeaders=" <> signedHeaders
+        , "Signature=" <> signature
+        ]
+  -- Add the AWS Authorization header.
+  -- Restore the Host header to the Runscope endpoint
+  -- so they can proxy accordingly (if used, otherwise this is a nop).
+  -- Add the Runscope Bucket Auth header back in, if it was set originally.
+  return $ setHeader "host" origHost
+        <$> maybe id (setHeader "Runscope-Bucket-Auth") runscopeBucketAuth
+        <$> setHeader "authorization" authorization $ req
+  where
+    lowerCI = S.map toLower . CI.original
+    trimHeaderValue =
+      id -- FIXME, see step 4, whitespace trimming but not in double
+         -- quoted sections, AWS spec.
+    timestamp = render <$> getCurrentTime
+      where render = S.pack . formatTime defaultTimeLocale "%Y%m%dT%H%M%SZ" .
+                     utcToLocalTime utc -- UTC printable: YYYYMMDDTHHMMSSZ
+    hmac' s k = toBytes (hmacGetDigest h)
+      where h = hmac k s :: (CT.HMAC CT.SHA256)
+
+addTmpPayloadHashHeader :: Request -> IO Request
+addTmpPayloadHashHeader req = do
+  let payloadHash = case HTTP.requestBody req of
+        HTTP.RequestBodyBS bs ->
+          HEX.encode $ SHA256.hash bs
+        HTTP.RequestBodyLBS lbs ->
+          HEX.encode $ SHA256.hashlazy lbs
+        _ -> error "addTmpPayloadHashHeader: unexpected request body type"
+  return $ setHeader tmpPayloadHashHeader payloadHash req
+
+tmpPayloadHashHeader :: CI.CI S.ByteString
+tmpPayloadHashHeader = "X-LOCAL-CONTENT-HASH-HEADER-746352"
+                       -- 746352 to reduce collision risk
+
+-- Per AWS documentation at:
+--   http://docs.aws.amazon.com/general/latest/gr/rande.html
+-- For example: "dynamodb.us-east-1.amazonaws.com" -> ("dynamodb", "us-east-1")
+serviceAndRegion :: S.ByteString -> (S.ByteString, S.ByteString)
+serviceAndRegion endpoint
+  -- For s3, use /<bucket> style access, as opposed to
+  -- <bucket>.s3... in the hostname.
+  | endpoint `elem` ["s3.amazonaws.com", "s3-external-1.amazonaws.com"] =
+    ("s3", "us-east-1")
+  | servicePrefix '-' endpoint == "s3" =
+    -- format: e.g. s3-us-west-2.amazonaws.com
+    let region = S.takeWhile (/= '.') $ S.drop 3 endpoint -- drop "s3-"
+    in ("s3", region)
+    -- not s3
+  | svc `HashSet.member` noRegion =
+    (svc, "us-east-1")
+  | otherwise =
+    let service:region:_ = S.split '.' endpoint
+    in (service, region)
+  where
+    svc = servicePrefix '.' endpoint
+    servicePrefix c = S.map toLower . S.takeWhile (/= c)
+    noRegion = HashSet.fromList ["iam", "sts", "importexport", "route53",
+                                 "cloudfront"]
+
+-- If the hostname doesn't end in runscope.net, return the original.
+-- For a hostname that includes runscope.net:
+-- given  sqs-us--east--1-amazonaws-com-<BUCKET>.runscope.net
+-- return sqs.us-east-1.amazonaws.com
+removeRunscope :: S.ByteString -> S.ByteString
+removeRunscope hostname
+  | ".runscope.net" `S.isSuffixOf` hostname =
+    S.concat . Prelude.map (p2 . p1) . S.group -- decode
+    -- drop suffix "-<BUCKET>.runscope.net" before decoding
+    . S.reverse . S.tail . S.dropWhile (/= '-') . S.reverse
+    $ hostname
+  | otherwise = hostname
+    where p1 "-"   = "."
+          p1 other = other
+          p2 "--"   = "-"
+          p2 other  = other
diff --git a/Network/Wreq/Internal/Lens.hs b/Network/Wreq/Internal/Lens.hs
--- a/Network/Wreq/Internal/Lens.hs
+++ b/Network/Wreq/Internal/Lens.hs
@@ -12,6 +12,8 @@
     , queryString
     , requestHeaders
     , requestBody
+    , requestVersion
+    , onRequestBodyException
     , proxy
     , hostAddress
     , rawBody
@@ -21,10 +23,14 @@
     , checkStatus
     , getConnectionWrapper
     , cookieJar
+    , seshCookies
+    , seshManager
+    , seshRun
     -- * Useful functions
     , assoc
     , assoc2
     , setHeader
+    , deleteKey
     ) where
 
 import Control.Lens hiding (makeLenses)
@@ -32,10 +38,12 @@
 import Network.HTTP.Client (Request)
 import Network.HTTP.Types (HeaderName)
 import Network.Wreq.Lens.Machinery (makeLenses)
+import Network.Wreq.Internal.Types (Session)
 import qualified Data.ByteString as S
 import qualified Network.HTTP.Client as HTTP
 
 makeLenses ''HTTP.Request
+makeLenses ''Session
 
 assoc :: (Eq k) => k -> IndexedTraversal' k [(k, a)] a
 assoc i = traverse . itraversed . index i
@@ -48,5 +56,7 @@
              _1 (f . fmap snd) . partition ((==k) . fst)
 
 setHeader :: HeaderName -> S.ByteString -> Request -> Request
-setHeader name value = requestHeaders %~ ((name,value) :) .
-                       filter ((/= name) . fst)
+setHeader name value = requestHeaders %~ ((name,value) :) . deleteKey name
+
+deleteKey :: (Eq a) => a -> [(a,b)] -> [(a,b)]
+deleteKey key = filter ((/= key) . fst)
diff --git a/Network/Wreq/Internal/Types.hs b/Network/Wreq/Internal/Types.hs
--- a/Network/Wreq/Internal/Types.hs
+++ b/Network/Wreq/Internal/Types.hs
@@ -1,4 +1,5 @@
-{-# LANGUAGE DeriveDataTypeable, FlexibleInstances, GADTs, RecordWildCards #-}
+{-# LANGUAGE DeriveDataTypeable, DeriveFunctor, FlexibleInstances, GADTs,
+    OverloadedStrings, RankNTypes, RecordWildCards #-}
 
 -- |
 -- Module      : Network.Wreq.Internal.Types
@@ -15,7 +16,9 @@
     (
     -- * Client configuration
       Options(..)
+    , Mgr
     , Auth(..)
+    , AWSAuthVersion(..)
     -- * Request payloads
     , Payload(..)
     , Postable(..)
@@ -28,24 +31,40 @@
     , Link(..)
     -- * Errors
     , JSONError(..)
+    -- * Request types
+    , Req(..)
+    , reqURL
+    -- * Sessions
+    , Session(..)
+    , Run
+    , Body(..)
+    -- * Caches
+    , CacheEntry(..)
     ) where
 
-import Control.Exception (Exception)
+import Control.Concurrent.MVar (MVar)
+import Control.Exception (Exception, SomeException)
+import Data.Monoid ((<>), mconcat)
 import Data.Text (Text)
+import Data.Time.Clock (UTCTime)
 import Data.Typeable (Typeable)
 import Network.HTTP.Client (CookieJar, Manager, ManagerSettings, Request,
                             RequestBody, destroyCookieJar)
-import Network.HTTP.Client.Internal (Proxy)
-import Network.HTTP.Types (Header)
+import Network.HTTP.Client.Internal (Response, Proxy)
+import Network.HTTP.Types (Header, Status, ResponseHeaders)
 import Prelude hiding (head)
-import qualified Data.ByteString as S
+import qualified Data.ByteString.Char8 as S
+import qualified Data.ByteString.Lazy as L
+import qualified Network.HTTP.Client as HTTP
 
 -- | A MIME content type, e.g. @\"application/octet-stream\"@.
 type ContentType = S.ByteString
 
+type Mgr = Either ManagerSettings Manager
+
 -- | Options for configuring a client.
 data Options = Options {
-    manager :: Either ManagerSettings Manager
+    manager :: Mgr
   -- ^ Either configuration for a 'Manager', or an actual 'Manager'.
   --
   -- If only 'ManagerSettings' are provided, then by default a new
@@ -130,6 +149,13 @@
   -- etc.), this field will be used only for the /first/ HTTP request
   -- to be issued during a 'Network.Wreq.Session.Session'. Any changes
   -- changes made for subsequent requests will be ignored.
+  , checkStatus :: 
+    Maybe (Status -> ResponseHeaders -> CookieJar -> Maybe SomeException)
+  -- ^ Function that checks the status code and potentially returns an exception.
+  --
+  -- This defaults to 'Nothing', which will just use the default of
+  -- 'Network.HTTP.Client.Request' which throws a 'StatusException' if the status
+  -- is not 2XX. 
   } deriving (Typeable)
 
 -- | Supported authentication types.
@@ -148,8 +174,15 @@
             -- to be used only by GitHub). This is treated by whoever
             -- accepts it as the equivalent of a username and
             -- password.
+          | AWSAuth AWSAuthVersion S.ByteString S.ByteString
+            -- ^ Amazon Web Services request signing
+            -- AWSAuthVersion key secret
           deriving (Eq, Show, Typeable)
 
+data AWSAuthVersion = AWSv4
+                      -- ^ AWS request signing version 4
+                    deriving (Eq, Show)
+
 instance Show Options where
   show (Options{..}) = concat ["Options { "
                               , "manager = ", case manager of
@@ -218,3 +251,49 @@
       linkURL :: S.ByteString
     , linkParams :: [(S.ByteString, S.ByteString)]
     } deriving (Eq, Show, Typeable)
+
+-- | A request that is ready to be submitted.
+data Req = Req Mgr Request
+
+reqURL :: Req -> S.ByteString
+reqURL (Req _ req) = mconcat [
+    if https then "https" else "http"
+  , "://"
+  , HTTP.host req
+  , case (HTTP.port req, https) of
+      (80, False) -> ""
+      (443, True) -> ""
+      (p, _)      -> S.pack (show p)
+  , HTTP.path req
+  , case HTTP.queryString req of
+      qs | S.null qs -> ""
+         | otherwise -> "?" <> qs
+  ]
+  where https = HTTP.secure req
+
+-- | A function that runs a request and returns the associated
+-- response.
+type Run body = Req -> IO (Response body)
+
+-- | A session that spans multiple requests.
+data Session = Session {
+      seshCookies :: MVar CookieJar
+    , seshManager :: Manager
+    , seshRun :: Session -> Run Body -> Run Body
+    }
+
+instance Show Session where
+    show _ = "Session"
+
+data CacheEntry body = CacheEntry {
+    entryCreated  :: UTCTime
+  , entryExpires  :: Maybe UTCTime
+  , entryResponse :: Response body
+  } deriving (Functor)
+
+data Body = NoBody
+          | StringBody L.ByteString
+          | ReaderBody HTTP.BodyReader
+
+instance Show (CacheEntry body) where
+    show _ = "CacheEntry"
diff --git a/Network/Wreq/Lens.hs b/Network/Wreq/Lens.hs
--- a/Network/Wreq/Lens.hs
+++ b/Network/Wreq/Lens.hs
@@ -45,6 +45,7 @@
     , params
     , cookie
     , cookies
+    , checkStatus
 
     -- ** Proxy setup
     , Proxy
@@ -95,10 +96,13 @@
 
     -- * Parsing
     , atto
+    , atto_
     ) where
 
+import Control.Applicative ((<*))
+import Control.Exception (SomeException)
 import Control.Lens (Fold, Lens, Lens', Traversal', folding)
-import Data.Attoparsec (Parser, parseOnly)
+import Data.Attoparsec.ByteString (Parser, endOfInput, parseOnly)
 import Data.ByteString (ByteString)
 import Data.Text (Text)
 import Data.Time.Clock (UTCTime)
@@ -126,6 +130,20 @@
 --'Network.HTTP.Client.OpenSSL.withOpenSSL' $
 --  'Network.Wreq.getWith' opts \"https:\/\/httpbin.org\/get\"
 -- @
+--
+-- In this example, we also set the response timeout to 10000 microseconds:
+--
+-- @
+--import "OpenSSL.Session" ('OpenSSL.Session.context')
+--import "Network.HTTP.Client.OpenSSL"
+--import "Network.HTTP.Client" ('Network.HTTP.Client.defaultManagerSettings', 'Network.HTTP.Client.managerResponseTimeout')
+--
+--let opts = 'Network.Wreq.defaults' 'Control.Lens.&' 'manager' 'Control.Lens..~' Left ('Network.HTTP.Client.OpenSSL.opensslManagerSettings' 'OpenSSL.Session.context')
+--                    'Control.Lens.&' 'manager' 'Control.Lens..~' Left ('Network.HTTP.Client.defaultManagerSettings' { 'Network.HTTP.Client.managerResponseTimeout' = Just 10000 } )
+--
+--'Network.HTTP.Client.OpenSSL.withOpenSSL' $
+--  'Network.Wreq.getWith' opts \"https:\/\/httpbin.org\/get\"
+-- @
 manager :: Lens' Options (Either ManagerSettings Manager)
 manager = TH.manager
 
@@ -134,9 +152,13 @@
 -- Example:
 --
 -- @
---let opts = 'Network.Wreq.defaults' 'Control.Lens.&' 'proxy' 'Control.Lens..~' 'Network.Wreq.httpProxy' \"localhost\" 8000
+--let opts = 'Network.Wreq.defaults' 'Control.Lens.&' 'proxy' 'Control.Lens.?~' 'Network.Wreq.httpProxy' \"localhost\" 8000
 --'Network.Wreq.getWith' opts \"http:\/\/httpbin.org\/get\"
 -- @
+--
+-- Note here the use of the 'Control.Lens.?~' setter to turn a 'Proxy'
+-- into a 'Maybe' 'Proxy', to make the type of the RHS compatible with
+-- the 'Lens.proxy' lens.
 proxy :: Lens' Options (Maybe Proxy)
 proxy = TH.proxy
 
@@ -205,6 +227,10 @@
 redirects :: Lens' Options Int
 redirects = TH.redirects
 
+-- | A lens to get the optional status check function
+checkStatus :: Lens' Options (Maybe (Status -> ResponseHeaders -> CookieJar -> Maybe SomeException))
+checkStatus = TH.checkStatus
+
 -- | A traversal onto the cookie with the given name, if one exists.
 cookie :: ByteString -> Traversal' Options Cookie
 cookie = TH.cookie
@@ -418,6 +444,17 @@
 -- ["GET","HEAD","OPTIONS"]
 atto :: Parser a -> Fold ByteString a
 atto = folding . parseOnly
+
+-- | The same as 'atto', but ensures that the parser consumes the
+-- entire input.
+--
+-- Equivalent to:
+--
+-- @
+--'atto_' myParser = 'atto' (myParser '<*' 'endOfInput')
+-- @
+atto_ :: Parser a -> Fold ByteString a
+atto_ p = atto (p <* endOfInput)
 
 -- $setup
 --
diff --git a/Network/Wreq/Lens/Machinery.hs b/Network/Wreq/Lens/Machinery.hs
--- a/Network/Wreq/Lens/Machinery.hs
+++ b/Network/Wreq/Lens/Machinery.hs
@@ -13,8 +13,8 @@
 defaultRules :: LensRules
 defaultRules = lensRules
 
-fieldName :: (String -> String) -> [Name] -> Name -> [DefName]
-fieldName f _ name = [TopName . mkName . f . nameBase $ name]
+fieldName :: (String -> String) -> Name -> [Name] -> Name -> [DefName]
+fieldName f _ _ name = [TopName . mkName . f . nameBase $ name]
 
 makeLenses :: Name -> Q [Dec]
 makeLenses = makeLensesWith (defaultRules & lensField .~ fieldName id)
diff --git a/Network/Wreq/Lens/TH.hs b/Network/Wreq/Lens/TH.hs
--- a/Network/Wreq/Lens/TH.hs
+++ b/Network/Wreq/Lens/TH.hs
@@ -15,6 +15,7 @@
     , redirects
     , cookie
     , cookies
+    , checkStatus
 
     , HTTP.Cookie
     , cookieName
@@ -57,6 +58,7 @@
     , partFilename
     , partContentType
     , partGetBody
+    , partHeaders
     ) where
 
 import Control.Lens hiding (makeLenses)
diff --git a/Network/Wreq/Session.hs b/Network/Wreq/Session.hs
--- a/Network/Wreq/Session.hs
+++ b/Network/Wreq/Session.hs
@@ -1,9 +1,10 @@
-{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE RankNTypes, RecordWildCards #-}
 
 module Network.Wreq.Session
     (
       Session
     , withSession
+    , withSessionWith
     -- * HTTP verbs
     , get
     , post
@@ -18,31 +19,33 @@
     , optionsWith
     , putWith
     , deleteWith
+    -- * Extending a session
+    , Lens.seshRun
     ) where
 
-import Control.Concurrent.MVar (MVar, modifyMVar, newMVar)
-import Control.Lens ((&), (.~), (^.))
-import Network.Wreq (Options, Response, defaults)
-import Network.Wreq.Internal (defaultManagerSettings)
-import Network.Wreq.Types (Postable, Putable)
+import Control.Concurrent.MVar (modifyMVar, newMVar)
+import Control.Lens ((&), (?~), (^.))
+import Network.Wreq (Options, Response)
+import Network.Wreq.Internal
+import Network.Wreq.Internal.Types (Body(..), Req(..), Session(..))
+import Network.Wreq.Types (Postable, Putable, Run)
 import Prelude hiding (head)
 import qualified Data.ByteString.Lazy as L
 import qualified Network.HTTP.Client as HTTP
 import qualified Network.Wreq as Wreq
-
-data Session = Session {
-      seshCookies :: MVar HTTP.CookieJar
-    , seshManager :: HTTP.Manager
-    }
-
-instance Show Session where
-    show _ = "Session"
+import qualified Network.Wreq.Internal.Lens as Lens
 
 withSession :: (Session -> IO a) -> IO a
-withSession act = do
+withSession = withSessionWith defaultManagerSettings
+
+withSessionWith :: HTTP.ManagerSettings -> (Session -> IO a) -> IO a
+withSessionWith settings act = do
   mv <- newMVar $ HTTP.createCookieJar []
-  HTTP.withManager defaultManagerSettings $ \mgr ->
-    act Session { seshCookies = mv, seshManager = mgr }
+  HTTP.withManager settings $ \mgr ->
+    act Session { seshCookies = mv
+                , seshManager = mgr
+                , seshRun = runWith
+                }
 
 get :: Session -> String -> IO (Response L.ByteString)
 get = getWith defaults
@@ -59,39 +62,44 @@
 put :: Putable a => Session -> String -> a -> IO (Response L.ByteString)
 put = putWith defaults
 
-delete :: Session -> String -> IO (Response ())
+delete :: Session -> String -> IO (Response L.ByteString)
 delete = deleteWith defaults
 
 getWith :: Options -> Session -> String -> IO (Response L.ByteString)
-getWith opts sesh url =
-  override opts sesh $ \opts' -> Wreq.getWith opts' url
+getWith opts sesh url = run string sesh =<< prepareGet opts url
 
 postWith :: Postable a => Options -> Session -> String -> a
          -> IO (Response L.ByteString)
 postWith opts sesh url payload =
-  override opts sesh $ \opts' -> Wreq.postWith opts' url payload
+  run string sesh =<< preparePost opts url payload
 
 headWith :: Options -> Session -> String -> IO (Response ())
-headWith opts sesh url =
-  override opts sesh $ \opts' -> Wreq.headWith opts' url
+headWith opts sesh url = run ignore sesh =<< prepareHead opts url
 
 optionsWith :: Options -> Session -> String -> IO (Response ())
-optionsWith opts sesh url =
-  override opts sesh $ \opts' -> Wreq.optionsWith opts' url
+optionsWith opts sesh url = run ignore sesh =<< prepareOptions opts url
 
 putWith :: Putable a => Options -> Session -> String -> a
         -> IO (Response L.ByteString)
-putWith opts sesh url payload =
-  override opts sesh $ \opts' -> Wreq.putWith opts' url payload
+putWith opts sesh url payload = run string sesh =<< preparePut opts url payload
 
-deleteWith :: Options -> Session -> String -> IO (Response ())
-deleteWith opts sesh url =
-  override opts sesh $ \opts' -> Wreq.deleteWith opts' url
+deleteWith :: Options -> Session -> String -> IO (Response L.ByteString)
+deleteWith opts sesh url = run string sesh =<< prepareDelete opts url
 
-override :: Options -> Session -> (Options -> IO (Response body))
-         -> IO (Response body)
-override opts Session{..} act =
+runWith :: Session -> Run Body -> Run Body
+runWith Session{..} act (Req _ req) =
   modifyMVar seshCookies $ \cj -> do
-    resp <- act $ opts & Wreq.cookies .~ cj &
-                         Wreq.manager .~ Right seshManager
+    resp <- act (Req (Right seshManager) (req & Lens.cookieJar ?~ cj))
     return (resp ^. Wreq.responseCookieJar, resp)
+
+type Mapping a = (Body -> a, a -> Body, Run a)
+
+run :: Mapping a -> Session -> Run a
+run (to,from,act) sesh =
+  fmap (fmap to) . seshRun sesh sesh (fmap (fmap from) . act)
+
+string :: Mapping L.ByteString
+string = (\(StringBody s) -> s, StringBody, runRead)
+
+ignore :: Mapping ()
+ignore = (\_ -> (), const NoBody, runIgnore)
diff --git a/Network/Wreq/Types.hs b/Network/Wreq/Types.hs
--- a/Network/Wreq/Types.hs
+++ b/Network/Wreq/Types.hs
@@ -17,6 +17,7 @@
     -- * Client configuration
       Options(..)
     , Auth(..)
+    , AWSAuthVersion(..)
     -- * Request payloads
     , Payload(..)
     , Postable(..)
@@ -29,6 +30,10 @@
     , Link(..)
     -- * Errors
     , JSONError(..)
+    -- * Request handling
+    , Req
+    , reqURL
+    , Run
     ) where
 
 import Control.Lens ((&), (.~))
@@ -46,6 +51,7 @@
 import qualified Data.Text.Lazy.Builder as TL
 import qualified Network.HTTP.Client as HTTP
 import qualified Network.Wreq.Internal.Lens as Lens
+import qualified Network.Wreq.Internal.AWS as AWS (addTmpPayloadHashHeader)
 
 instance Postable Part where
     postPayload a = postPayload [a]
@@ -134,6 +140,7 @@
     renderFormValue (Just a) = renderFormValue a
     renderFormValue Nothing  = ""
 
-payload :: ContentType -> HTTP.RequestBody -> Request -> IO Request
-payload ct body req = return $ req & Lens.setHeader "Content-Type" ct &
-                      Lens.requestBody .~ body
+payload :: S.ByteString -> HTTP.RequestBody -> Request -> IO Request
+payload ct body req = AWS.addTmpPayloadHashHeader $ req
+                    & Lens.setHeader "Content-Type" ct
+                    & Lens.requestBody .~ body
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -3,7 +3,6 @@
 `wreq` is a library that makes HTTP client programming in Haskell
 easy.
 
-
 # Features
 
 * Simple but powerful `lens`-based API
@@ -23,6 +22,14 @@
 
 * Basic and OAuth2 bearer authentication
 
+* Amazon Web Services (AWS) request signing (Version 4)
+
+* AWS signing supports sending requests through the
+  [Runscope Inc.](https://www.runscope.com) Traffic Inspector
+
+# Tutorials
+
+See [the tutorials](http://www.serpentine.com/wreq/) for a quick-start.
 
 # Is it done?
 
diff --git a/TODO.md b/TODO.md
--- a/TODO.md
+++ b/TODO.md
@@ -17,3 +17,5 @@
 
 * Some poor sod needs to add digest authentication to `http-client` so
   we can use it
+
+* Cache management
diff --git a/changelog b/changelog
--- a/changelog
+++ b/changelog
@@ -1,5 +1,15 @@
 -*- markdown -*-
 
+2014-12-02 0.3.0.0
+
+* Support for Amazon Web Services request signing
+
+* New customMethod, customMethodWith functions allow use of arbitrary
+  HTTP verbs
+
+* httpProxy, basicAuth, oauth2Bearer, oauth2Token: removed Maybe from
+  result types, changed documentation to suggest use of (?~)
+
 2014-08-25 0.2.0.0
 
 * Support for lens 4.4
diff --git a/examples/UploadPaste.hs b/examples/UploadPaste.hs
--- a/examples/UploadPaste.hs
+++ b/examples/UploadPaste.hs
@@ -19,7 +19,8 @@
 import Data.Monoid (mempty)
 import Network.Wreq (FormParam((:=)), post, responseBody)
 import Network.Wreq.Types (FormValue(..))
-import Options.Applicative as Opts hiding ((&), header)
+import Options.Applicative as Opts
+import Options.Applicative.Types (readerAsk)
 import System.FilePath (takeExtension, takeFileName)
 import Text.HTML.TagSoup
 import qualified Data.ByteString.Char8 as B
@@ -97,8 +98,9 @@
 
 -- Try to match a user-supplied name to a Language type, looking at
 -- both full names and filename extensions.
-readLanguage :: Monad m => String -> m Language
-readLanguage l = do
+readLanguage :: ReadM Language
+readLanguage = do
+  l <- readerAsk
   let ll = toLower <$> l
       ms = [lang | (suffixes, lang) <- languages,
             ll == (toLower <$> show lang) || ll `elem` (tail <$> suffixes)]
@@ -158,9 +160,9 @@
           (optional . fmap Channel . strOption $
            long "channel" <> short 'c' <> metavar "CHANNEL" <>
            help "name of IRC channel to announce") <*>
-          (optional . nullOption $
+          (optional . option readLanguage $
            long "language" <> short 'l' <> metavar "LANG" <>
-           help "language to use" <> reader readLanguage) <*>
+           help "language to use") <*>
           (Opts.argument str $ metavar "PATH" <>
                                help "file to upload") <*>
           (pure ())
diff --git a/examples/wreq-examples.cabal b/examples/wreq-examples.cabal
--- a/examples/wreq-examples.cabal
+++ b/examples/wreq-examples.cabal
@@ -32,13 +32,14 @@
   default-language: Haskell98
 
   build-depends:
-    aeson >= 0.7.0.3,
+    aeson,
     ansi-wl-pprint >= 0.6.6,
     base >= 4.5 && < 5,
     bytestring,
     filepath,
     lens,
-    optparse-applicative,
+    optparse-applicative >= 0.11,
+    mtl,
     tagsoup,
     text,
     wreq
diff --git a/httpbin/HttpBin.hs b/httpbin/HttpBin.hs
--- a/httpbin/HttpBin.hs
+++ b/httpbin/HttpBin.hs
@@ -1,124 +1,7 @@
-{-# LANGUAGE OverloadedStrings #-}
-{-# OPTIONS_GHC -fno-warn-missing-signatures #-}
-
--- TBD: basic-auth, gzip
-
 module Main (main) where
 
-import Control.Applicative ((<$>))
-import Data.Aeson (Value(..), eitherDecode, object, toJSON)
-import Data.Aeson.Encode.Pretty (Config(..), encodePretty')
-import qualified Data.ByteString.Base64 as B64
-import Data.ByteString.Char8 (pack)
-import Data.CaseInsensitive (original)
-import Data.Maybe (fromMaybe)
-import Data.Monoid ((<>))
-import Data.Text.Encoding (decodeUtf8)
-import Data.Text.Read (decimal)
-import Snap.Core
-import Snap.Http.Server
-import Snap.Util.GZip (withCompression)
-import qualified Data.ByteString.Char8 as B
-import qualified Data.Map as Map
-import qualified Data.Text.Lazy.Encoding as Lazy
-
-get = respond return
-
-post = respond $ \obj -> do
-  body <- readRequestBody 65536
-  return $ obj <> [("data", toJSON (Lazy.decodeUtf8 body))] <>
-           case eitherDecode body of
-             Left _    -> [("json", Null)]
-             Right val -> [("json", val)]
-
-put = post
-
-delete = respond return
-
-status = do
-  val <- (fromMaybe 200 . rqIntParam "val") <$> getRequest
-  let code | val >= 200 && val <= 505 = val
-           | otherwise                = 400
-  modifyResponse $ setResponseCode code
-
-gzip =
-  localRequest (setHeader "Accept-Encoding" "gzip") . withCompression .
-  respond $ \obj -> return $ obj <> [("gzipped", Bool True)]
-
-setCookies = do
-  params <- rqQueryParams <$> getRequest
-  modifyResponse . foldr (.) id . map addResponseCookie $
-    [Cookie k v Nothing Nothing (Just "/") False False
-     | (k,vs) <- Map.toList params, v <- vs]
-  redirect "/cookies"
-
-redirect_ = do
-  req <- getRequest
-  let n   = fromMaybe (-1::Int) . rqIntParam "n" $ req
-      prefix = B.reverse . B.dropWhile (/='/') . B.reverse . rqURI $ req
-  case undefined of
-    _| n > 1     -> redirect $ prefix <> pack (show (n-1))
-     | n == 1    -> redirect "/get"
-     | otherwise -> modifyResponse $ setResponseCode 400
-
-basicAuth = do
-  req <- getRequest
-  let unauthorized = modifyResponse $
-                     setHeader "WWW-Authenticate" "Basic realm=\"Fake Realm\"" .
-                     setResponseCode 401
-  case (rqParam "user" req, rqParam "pass" req) of
-    (Just [user], Just [passwd]) | not (':' `B.elem` user) ->
-      case getHeader "Authorization" (headers req) of
-        Nothing -> unauthorized
-        Just auth -> do
-          let expected = "Basic " <> B64.encode (user <> ":" <> passwd)
-          if auth /= expected
-            then unauthorized
-            else writeJSON [ ("user", toJSON (B.unpack user))
-                           , ("authenticated", Bool True) ]
-    _ -> modifyResponse $ setResponseCode 400
-
-rqIntParam name req =
-  case rqParam name req of
-    Just (str:_) -> case decimal (decodeUtf8 str) of
-                      Right (n, "") -> Just n
-                      _             -> Nothing
-    _            -> Nothing
-
-writeJSON obj = do
-  modifyResponse $ setContentType "application/json"
-  writeLBS . (<> "\n") . encodePretty' (Config 2 compare) . object $ obj
-
-respond act = do
-  req <- getRequest
-  let step m k v = Map.insert (decodeUtf8 k) (decodeUtf8 (head v)) m
-      params = Map.foldlWithKey' step Map.empty .
-               rqQueryParams $ req
-      wibble (k,v) = (decodeUtf8 (original k), decodeUtf8 v)
-      rqHeaders = headers req
-      hdrs = Map.fromList . map wibble . listHeaders $ rqHeaders
-      url = case getHeader "Host" rqHeaders of
-              Nothing   -> []
-              Just host -> [("url", toJSON . decodeUtf8 $
-                                    "http://" <> host <> rqURI req)]
-  writeJSON =<< act ([ ("args", toJSON params)
-                     , ("headers", toJSON hdrs)
-                     , ("origin", toJSON . decodeUtf8 . rqRemoteAddr $ req)
-                     ] <> url)
+import HttpBin.Server (serve)
+import Snap.Http.Server.Config (commandLineConfig)
 
-main = do
-  cfg <- commandLineConfig
-       . setAccessLog ConfigNoLog
-       . setErrorLog ConfigNoLog
-       $ defaultConfig
-  httpServe cfg $ route [
-      ("/get", methods [GET,HEAD] get)
-    , ("/post", method POST post)
-    , ("/put", method PUT put)
-    , ("/delete", method DELETE delete)
-    , ("/redirect/:n", redirect_)
-    , ("/status/:val", status)
-    , ("/gzip", methods [GET,HEAD] gzip)
-    , ("/cookies/set", methods [GET,HEAD] setCookies)
-    , ("/basic-auth/:user/:pass", methods [GET,HEAD] basicAuth)
-    ]
+main :: IO ()
+main = serve commandLineConfig
diff --git a/httpbin/HttpBin/Server.hs b/httpbin/HttpBin/Server.hs
new file mode 100644
--- /dev/null
+++ b/httpbin/HttpBin/Server.hs
@@ -0,0 +1,162 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# OPTIONS_GHC -fno-warn-missing-signatures #-}
+
+-- TBD: basic-auth, gzip
+
+module HttpBin.Server (serve) where
+
+import Control.Applicative ((<$>))
+import Control.Monad.IO.Class (liftIO)
+import Data.Aeson (Value(..), eitherDecode, object, toJSON)
+import Data.Aeson.Encode.Pretty (Config(..), encodePretty')
+import Data.ByteString.Char8 (pack)
+import Data.CaseInsensitive (original)
+import Data.Maybe (catMaybes, fromMaybe)
+import Data.Monoid ((<>))
+import Data.Text.Encoding (decodeUtf8)
+import Data.Text.Read (decimal)
+import Data.UUID (toASCIIBytes)
+import Data.UUID.V4 (nextRandom)
+import Snap.Core
+import Snap.Http.Server as Snap
+import Snap.Util.GZip (withCompression)
+import System.PosixCompat.Time (epochTime)
+import qualified Data.ByteString.Base64 as B64
+import qualified Data.ByteString.Char8 as B
+import qualified Data.Map as Map
+import qualified Data.Text.Lazy.Encoding as Lazy
+
+get = respond return
+
+post = respond $ \obj -> do
+  body <- readRequestBody 65536
+  return $ obj <> [("data", toJSON (Lazy.decodeUtf8 body))] <>
+           case eitherDecode body of
+             Left _    -> [("json", Null)]
+             Right val -> [("json", val)]
+
+put = post
+
+delete = respond return
+
+status = do
+  val <- (fromMaybe 200 . rqIntParam "val") <$> getRequest
+  let code | val >= 200 && val <= 505 = val
+           | otherwise                = 400
+  modifyResponse $ setResponseCode code
+
+gzip =
+  localRequest (setHeader "Accept-Encoding" "gzip") . withCompression .
+  respond $ \obj -> return $ obj <> [("gzipped", Bool True)]
+
+setCookies = do
+  params <- rqQueryParams <$> getRequest
+  modifyResponse . foldr (.) id . map addResponseCookie $
+    [Cookie k v Nothing Nothing (Just "/") False False
+     | (k,vs) <- Map.toList params, v <- vs]
+  redirect "/cookies"
+
+listCookies = do
+  cks <- rqCookies <$> getRequest
+  let cs = [(decodeUtf8 (cookieName c),
+             toJSON (decodeUtf8 (cookieValue c))) | c <- cks]
+  respond $ \obj -> return $ obj <> [("cookies", object cs)]
+
+redirect_ = do
+  req <- getRequest
+  let n   = fromMaybe (-1::Int) . rqIntParam "n" $ req
+      prefix = B.reverse . B.dropWhile (/='/') . B.reverse . rqURI $ req
+  case undefined of
+    _| n > 1     -> redirect $ prefix <> pack (show (n-1))
+     | n == 1    -> redirect "/get"
+     | otherwise -> modifyResponse $ setResponseCode 400
+
+unauthorized = modifyResponse $
+               setHeader "WWW-Authenticate" "Basic realm=\"Fake Realm\"" .
+               setResponseCode 401
+
+simpleAuth expect = do
+  req <- getRequest
+  case expect req of
+    Nothing -> modifyResponse $ setResponseCode 400
+    Just (expected, resp) ->
+      case getHeader "Authorization" (headers req) of
+        Nothing -> unauthorized
+        Just auth | auth == expected -> writeJSON $
+                                        resp <> [("authenticated", Bool True)]
+                  | otherwise -> unauthorized
+
+basicAuth = simpleAuth $ \req ->
+  case (rqParam "user" req, rqParam "pass" req) of
+    (Just [user], Just [passwd]) | not (':' `B.elem` user) ->
+      Just ("Basic " <> B64.encode (user <> ":" <> passwd),
+            [("user", toJSON (B.unpack user))])
+    _ -> Nothing
+
+oauth2token = simpleAuth $ \req ->
+  case (rqParam "kind" req, rqParam "token" req) of
+    (Just [kind], Just [token]) ->
+      Just (kind <> " " <> token,
+            [("token", toJSON (B.unpack token))])
+    _ -> Nothing
+
+cache = do
+  hdrs <- headers <$> getRequest
+  let cond = not . null . catMaybes . map (flip getHeader hdrs) $
+             ["If-Modified-Since", "If-None-Match"]
+  if cond
+    then modifyResponse $ setResponseCode 304
+    else do
+      now <- liftIO $ formatHttpTime =<< epochTime
+      uuid <- liftIO nextRandom
+      modifyResponse $ setHeader "Last-Modified" now .
+                       setHeader "ETag" (toASCIIBytes uuid)
+      respond return
+
+rqIntParam name req =
+  case rqParam name req of
+    Just (str:_) -> case decimal (decodeUtf8 str) of
+                      Right (n, "") -> Just n
+                      _             -> Nothing
+    _            -> Nothing
+
+writeJSON obj = do
+  modifyResponse $ setContentType "application/json"
+  writeLBS . (<> "\n") . encodePretty' (Config 2 compare) . object $ obj
+
+respond act = do
+  req <- getRequest
+  let step m k v = Map.insert (decodeUtf8 k) (decodeUtf8 (head v)) m
+      params = Map.foldlWithKey' step Map.empty .
+               rqQueryParams $ req
+      wibble (k,v) = (decodeUtf8 (original k), decodeUtf8 v)
+      rqHeaders = headers req
+      hdrs = Map.fromList . map wibble . listHeaders $ rqHeaders
+      url = case getHeader "Host" rqHeaders of
+              Nothing   -> []
+              Just host -> [("url", toJSON . decodeUtf8 $
+                                    "http://" <> host <> rqURI req)]
+  writeJSON =<< act ([ ("args", toJSON params)
+                     , ("headers", toJSON hdrs)
+                     , ("origin", toJSON . decodeUtf8 . rqRemoteAddr $ req)
+                     ] <> url)
+
+serve mkConfig = do
+  cfg <- mkConfig
+       . setAccessLog ConfigNoLog
+       . setErrorLog ConfigNoLog
+       $ defaultConfig
+  httpServe cfg $ route [
+      ("/get", methods [GET,HEAD] get)
+    , ("/post", method POST post)
+    , ("/put", method PUT put)
+    , ("/delete", method DELETE delete)
+    , ("/redirect/:n", redirect_)
+    , ("/status/:val", status)
+    , ("/gzip", methods [GET,HEAD] gzip)
+    , ("/cookies/set", methods [GET,HEAD] setCookies)
+    , ("/cookies", methods [GET,HEAD] listCookies)
+    , ("/basic-auth/:user/:pass", methods [GET,HEAD] basicAuth)
+    , ("/oauth2/:kind/:token", methods [GET,HEAD] oauth2token)
+    , ("/cache", methods [GET,HEAD] cache)
+    ]
diff --git a/tests/AWS.hs b/tests/AWS.hs
new file mode 100644
--- /dev/null
+++ b/tests/AWS.hs
@@ -0,0 +1,122 @@
+{-# LANGUAGE ScopedTypeVariables #-}
+
+{-
+A set of end to end Amazon Web Services (AWS) tests to make sure we
+can access a number of AWS services that use various AWS request
+formats.
+
+These tests help us guard against errors we may otherwise introduce
+while refactoring or extending Wreq. The tests are not meant to
+exercise the features of the respective AWS services exhaustively.
+
+** ASSUMPTIONS **
+To configure and run these tests you need an AWS account. We assume
+that you are familiar with AWS concepts and the charging model.
+
+** ENABLING AWS TESTS **
+For now, enable AWS tests by setting the WREQ_AWS_ACCESS_KEY_ID
+env variable per below.
+
+TODO| To enable AWS tests use the `-faws` flag as part of
+TODO|   $ cabal configure --enable-tests -faws ...
+TODO| To capture code coverage information, add the `-fdeveloper` flag.
+
+** REQUIRED CLIENT CONFIGURATION **
+The tests require two environment variables:
+   $ /bin/env WREQ_AWS_ACCESS_KEY_ID='...' \
+              WREQ_AWS_SECRET_ACCESS_KEY='...' \
+       cabal test
+
+** CHARGES/COST **
+These tests may incur small amounts of AWS charges for the minimum
+DynamoDB IOs per second they provision and for the messages sent to
+AWS SQS and objects stored in S3. These charges consume only a tiny
+fraction of the AWS free tier allowance (if not used up otherwise).
+
+** AWS REGIONS **
+Tests are executed against the AWS Region `us-west-2` by default. You
+can change the region by setting the AWS_REGION environment variable
+(e.g. /bin/env WREQ_AWS_REGION=eu-west-1 cabal test).
+
+In the case of S3, we translate 'us-east-1' to
+'s3-external-1.amazonaws.com' denoting the Virginia (only) endpoint.
+(see http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region).
+
+** AWS USER AND LEAST PRIVILEDGE POLICY **
+The file `tests/AWS/policy.json` contains the least priviledge "AWS
+Identity and Access (IAM)" policy sufficient to run these tests. It is
+a best security practice to run the tests using an AWS IAM user you
+created specifically for this purpose. Use the AWS IAM Management
+Console to create a new user, get the WREQ_AWS_ACCESS_KEY and
+WREQ_AWS_SECRET_KEY for that user and apply the policy to the user to
+limit its priviledges.
+
+**AVOID AWS RESOURCE NAME COLLISIONS IN CONCURRENT TESTS**
+To run tests concurrently in same AWS account, set the environment
+variable WREQ_AWS_TEST_PREFIX to a unique string for each test client
+or machine. The default prefix used for all resources created
+(e.g. DynamoDB tables, SQS queues, S3 buckets, etc.) is
+`deleteWreqTest`.
+-}
+
+module AWS (tests) where
+
+import Control.Exception as E (IOException, catch)
+import Control.Lens
+import Data.ByteString.Char8 as BS8 (pack)
+import Data.IORef (newIORef)
+import Network.Info (getNetworkInterfaces, mac)
+import Network.Wreq
+import System.Environment (getEnv)
+import Test.Framework (Test, testGroup)
+import qualified AWS.DynamoDB (tests)
+import qualified AWS.IAM (tests)
+import qualified AWS.S3 (tests)
+import qualified AWS.SQS (tests)
+
+tests :: IO Test
+tests = do
+  -- TODO - use ... configure -faws ... in the future
+  --        but couldn't figure out (yet) how to get
+  --        a hold of the flag value in test code.
+  -- Workaround: for now, the presence of the
+  --   WREQ_AWS_ACCESS_KEY_ID
+  -- env variable enables the tests.
+  flag <- (getEnv "WREQ_AWS_ACCESS_KEY_ID" >> return True) `E.catch`
+          \(_::IOException) -> return False
+  tests0 flag
+
+tests0 :: Bool -> IO Test
+tests0 False =
+  return $ testGroup "aws" [] -- skip AWS tests
+tests0 True = do
+  region <- env "us-west-2" "WREQ_AWS_REGION"
+  key <- BS8.pack `fmap` getEnv "WREQ_AWS_ACCESS_KEY_ID"
+  secret <- BS8.pack `fmap` getEnv "WREQ_AWS_SECRET_ACCESS_KEY"
+  let baseopts = defaults & auth ?~ awsAuth AWSv4 key secret
+  prefix <- env "deleteWreqTest" "WREQ_AWS_TEST_PREFIX"
+  sqsTestState <- newIORef "missing"
+  uniq <- uniqueMachineId
+  return $ testGroup "aws" [
+      AWS.DynamoDB.tests (prefix ++ "DynamoDB") region baseopts
+    , AWS.IAM.tests (prefix ++ "IAM") region baseopts
+    , AWS.SQS.tests (prefix ++ "SQS") region baseopts sqsTestState
+      -- S3 buckets are global entities and the namespace shared among
+      -- all AWS customers. We will use a unique id based on the MAC
+      -- address of our client to avoid naming conflicts among different
+      -- developers running the tests.
+    , AWS.S3.tests (prefix ++ "S3" ++ uniq) region baseopts
+    ]
+
+-- return a globally unique machine id (uses a MAC address)
+uniqueMachineId :: IO String
+uniqueMachineId = do
+  l <- (filter $ (/=) "00:00:00:00:00:00" . show . mac) `fmap`
+         getNetworkInterfaces
+  return $ concatMap (\c -> if c == ':' then [] else [c])
+         . show
+         . mac
+         . head $ l
+
+env :: String -> String -> IO String
+env defVal name = getEnv name `E.catch` \(_::IOException) -> return defVal
diff --git a/tests/AWS/DynamoDB.hs b/tests/AWS/DynamoDB.hs
new file mode 100644
--- /dev/null
+++ b/tests/AWS/DynamoDB.hs
@@ -0,0 +1,169 @@
+{-# LANGUAGE OverloadedStrings, QuasiQuotes #-}
+module AWS.DynamoDB (tests) where
+
+import Control.Concurrent (threadDelay)
+import Control.Lens
+import Data.Aeson.Lens (key, _String, values, _Double)
+import Data.Aeson.QQ
+import Data.Text as T (pack)
+import Network.Wreq
+import System.Timeout (timeout)
+import Test.Framework (Test, testGroup)
+import Test.Framework.Providers.HUnit (testCase)
+import Test.HUnit (assertBool, assertFailure)
+
+-- FIXME: retry create call in case the table is in DELETING state
+-- from a previous test run (error 'Table already exists: ...'). For
+-- now 'create' testcase and all others will fails. Rerun when ongoing
+-- delete operation is complete.
+
+tests :: String -> String -> Options -> Test
+tests prefix region baseopts = testGroup "dynamodb" [
+    testCase "createTable" $ createTable prefix region baseopts
+  , testCase "listTables" $ listTables prefix region baseopts
+  , testCase "awaitTableActive"  $ awaitTableActive prefix region baseopts
+  , testCase "putItem" $ putItem prefix region baseopts
+  , testCase "getItem" $ getItem prefix region baseopts
+  , testCase "deleteItem" $ deleteItem prefix region baseopts
+  , testCase "deleteTable" $ deleteTable prefix region baseopts -- call last
+  ]
+
+createTable :: String -> String -> Options -> IO ()
+createTable prefix region baseopts = do
+  let opts = baseopts
+             & header "X-Amz-Target" .~ ["DynamoDB_20120810.CreateTable"]
+             & header "Content-Type" .~ ["application/x-amz-json-1.0"]
+  r <- postWith opts (url region) $
+         [aesonQQ| {
+             "TableName": #{prefix ++ tablename},
+             "KeySchema": [
+               { "AttributeName": "name", "KeyType": "HASH" },
+               { "AttributeName": "age", "KeyType": "RANGE" }
+             ],
+             "AttributeDefinitions": [
+               { "AttributeName": "name", "AttributeType": "S" },
+               { "AttributeName": "age", "AttributeType": "S" }
+             ],
+             "ProvisionedThroughput": {
+               "ReadCapacityUnits": 1,
+               "WriteCapacityUnits": 1
+             }
+         } |]
+  assertBool "createTables 200" $ r ^. responseStatus . statusCode == 200
+  assertBool "createTables OK" $ r ^. responseStatus . statusMessage == "OK"
+  assertBool "createTables status CREATING" $
+    r ^. responseBody . key "TableDescription" . key "TableStatus" . _String == "CREATING"
+  assertBool "createTables no items in new table" $
+    r ^? responseBody . key "TableDescription" . key "ItemCount" . _Double == Just 0
+
+listTables :: String -> String -> Options -> IO ()
+listTables prefix region baseopts = do
+  let opts = baseopts
+             & header "X-Amz-Target" .~ ["DynamoDB_20120810.ListTables"]
+             & header "Content-Type" .~ ["application/x-amz-json-1.0"]
+  -- FIXME avoid limit to keep tests from failing if there are > tables?
+  r <- postWith opts (url region) [aesonQQ| { "Limit": 100 } |]
+  assertBool "listTables 200" $ r ^. responseStatus . statusCode == 200
+  assertBool "listTables OK" $ r ^. responseStatus . statusMessage == "OK"
+  assertBool "listTables contains test table" $
+    elem (T.pack $ prefix ++ tablename)
+         (r ^.. responseBody . key "TableNames" . values . _String)
+
+awaitTableActive :: String -> String -> Options -> IO ()
+awaitTableActive prefix region baseopts = do
+  let dur = 45 -- typically ACTIVE in 20s or less (us-west-2, Sept 2014)
+  res <- timeout (dur*1000*1000) check
+  case res of
+    Nothing ->
+      assertFailure $ "timeout: table not ACTIVE after " ++ show dur ++ "s"
+    Just () ->
+      return () -- PASS
+  where
+    check = do
+      let opts = baseopts
+                 & header "X-Amz-Target" .~ ["DynamoDB_20120810.DescribeTable"]
+                 & header "Content-Type" .~ ["application/x-amz-json-1.0"]
+      r <- postWith opts (url region)
+             [aesonQQ| { "TableName": #{prefix ++ tablename} } |]
+      assertBool "awaitTableActive 200" $ r ^. responseStatus . statusCode == 200
+      assertBool "awaitTableActive OK" $ r ^. responseStatus . statusMessage == "OK"
+      -- Prelude.putStr "."
+      case r ^. responseBody . key "Table" . key "TableStatus" . _String of
+        "ACTIVE" ->
+          return ()
+        _ -> do
+          threadDelay $ 5*1000*1000 -- 5 sleep
+          check
+
+deleteTable :: String -> String -> Options -> IO ()
+deleteTable prefix region baseopts = do
+  let opts = baseopts
+             & header "X-Amz-Target" .~ ["DynamoDB_20120810.DeleteTable"]
+             & header "Content-Type" .~ ["application/x-amz-json-1.0"]
+  r <- postWith opts (url region) $
+         [aesonQQ| { "TableName": #{prefix ++ tablename} } |]
+  assertBool "deleteTable 200" $ r ^. responseStatus . statusCode == 200
+  assertBool "deleteTable OK" $ r ^. responseStatus . statusMessage == "OK"
+
+putItem :: String -> String -> Options -> IO ()
+putItem prefix region baseopts = do
+  let opts = baseopts
+             & header "X-Amz-Target" .~ ["DynamoDB_20120810.PutItem"]
+             & header "Content-Type" .~ ["application/x-amz-json-1.0"]
+  r <- postWith opts (url region) $
+         [aesonQQ| {
+           "TableName": #{prefix ++ tablename},
+           "Item": {
+             "name": { "S": "someone" },
+             "age": {"S": "whatever"},
+             "bar": {"S": "baz"}
+           }
+         } |]
+  assertBool "putItem 200" $ r ^. responseStatus . statusCode == 200
+  assertBool "putItem OK" $ r ^. responseStatus . statusMessage == "OK"
+
+getItem :: String -> String -> Options -> IO ()
+getItem prefix region baseopts = do
+  let opts = baseopts
+             & header "X-Amz-Target" .~ ["DynamoDB_20120810.GetItem"]
+             & header "Content-Type" .~ ["application/x-amz-json-1.0"]
+  r <- postWith opts (url region) $
+         [aesonQQ| {
+           "TableName": #{prefix ++ tablename},
+           "Key": {
+             "name": { "S": "someone" },
+             "age": {"S": "whatever"}
+           },
+           "AttributesToGet": [ "bar" ],
+           "ConsistentRead": true,
+           "ReturnConsumedCapacity": "TOTAL"
+         } |]
+  assertBool "getItem 200" $ r ^. responseStatus . statusCode == 200
+  assertBool "getItem OK" $ r ^. responseStatus . statusMessage == "OK"
+  assertBool "getItem baz value is bar" $
+    r ^. responseBody . key "Item" . key "bar" . key "S" . _String == "baz"
+
+deleteItem :: String -> String -> Options -> IO ()
+deleteItem prefix region baseopts = do
+  let opts = baseopts
+             & header "X-Amz-Target" .~ ["DynamoDB_20120810.DeleteItem"]
+             & header "Content-Type" .~ ["application/x-amz-json-1.0"]
+  r <- postWith opts (url region) $
+         [aesonQQ| {
+           "TableName": #{prefix ++ tablename},
+           "Key": {
+             "name": { "S": "someone" },
+             "age": {"S": "whatever"}
+           },
+           "ReturnValues": "ALL_OLD"
+         } |]
+  assertBool "getItem 200" $ r ^. responseStatus . statusCode == 200
+  assertBool "getItem OK" $ r ^. responseStatus . statusMessage == "OK"
+
+url :: String -> String
+url region =
+  "https://dynamodb." ++ region ++ ".amazonaws.com/"
+
+tablename :: String
+tablename =
+  "test"
diff --git a/tests/AWS/IAM.hs b/tests/AWS/IAM.hs
new file mode 100644
--- /dev/null
+++ b/tests/AWS/IAM.hs
@@ -0,0 +1,27 @@
+{-# LANGUAGE OverloadedStrings #-}
+module AWS.IAM (tests) where
+
+import Control.Lens
+import Network.Wreq
+import Test.Framework (Test, testGroup)
+import Test.Framework.Providers.HUnit (testCase)
+import Test.HUnit (assertBool)
+
+tests :: String -> String -> Options -> Test
+tests prefix region baseopts = testGroup "iam" [
+    testCase "listUsers"  $ listUsers prefix region baseopts
+  ]
+
+listUsers :: String -> String -> Options -> IO ()
+listUsers _prefix region baseopts = do
+  let opts = baseopts
+             & param  "Action"  .~ ["ListUsers"]
+             & param  "Version" .~ ["2010-05-08"]
+             & header "Accept"  .~ ["application/json"]
+  r <- getWith opts (url region)
+  assertBool "listUsers 200" $ r ^. responseStatus . statusCode == 200
+  assertBool "listUsers OK" $ r ^. responseStatus . statusMessage == "OK"
+
+url :: String -> String
+url _ =
+  "https://iam.amazonaws.com/" -- not region specific
diff --git a/tests/AWS/S3.hs b/tests/AWS/S3.hs
new file mode 100644
--- /dev/null
+++ b/tests/AWS/S3.hs
@@ -0,0 +1,76 @@
+{-# LANGUAGE OverloadedStrings, QuasiQuotes #-}
+module AWS.S3 (tests) where
+
+import Control.Lens
+import Data.Aeson.QQ
+import Data.Char (toLower)
+import Data.Monoid ((<>))
+import Network.Wreq
+import Test.Framework (Test, testGroup)
+import Test.Framework.Providers.HUnit (testCase)
+import Test.HUnit (assertBool)
+import qualified Data.ByteString.Char8 as BS8 (ByteString, pack)
+
+-- FIXME: retry create call in case we get the S3 specific "A
+-- conflicting conditional operation is currently in progress against
+-- this resource. Please try again." error from a previous test run
+-- that is still deleting the test bucket. For now the 'create'
+-- testcase and all others will fails.
+
+tests :: String -> String -> Options -> Test
+tests prefix region baseopts = let
+  lowerPrefix = map toLower prefix
+  in testGroup "s3" [
+        testCase "createBucket" $ createBucket lowerPrefix region baseopts
+      , testCase "putObjectJSON" $ putObjectJSON lowerPrefix region baseopts
+      , testCase "getObjectJSON" $ getObjectJSON lowerPrefix region baseopts
+      , testCase "deleteObjectJSON" $ deleteObjectJSON lowerPrefix region baseopts
+      , testCase "deleteBucket" $ deleteBucket lowerPrefix region baseopts -- call last
+      ]
+
+createBucket :: String -> String -> Options -> IO ()
+createBucket prefix region baseopts = do
+  r <- putWith baseopts (url region ++ prefix ++ "testbucket") $
+         locationConstraint region
+  assertBool "createBucket 200" $ r ^. responseStatus . statusCode == 200
+  assertBool "createBucket OK" $ r ^. responseStatus . statusMessage == "OK"
+
+deleteBucket :: String -> String -> Options -> IO ()
+deleteBucket prefix region baseopts = do
+  r <- deleteWith baseopts (url region ++ prefix ++ "testbucket")
+  assertBool "deleteBucket 204 - no content" $
+    r ^. responseStatus . statusCode == 204
+  assertBool "deleteBucket OK" $
+    r ^. responseStatus . statusMessage == "No Content"
+
+putObjectJSON :: String -> String -> Options -> IO ()
+putObjectJSON prefix region baseopts = do
+  -- S3 write object, incl. correct content-type, uses /bucket/object syntax
+  r <- putWith baseopts (url region ++ prefix ++ "testbucket/blabla-json") $
+         [aesonQQ| { "test": "key", "testdata": [ 1, 2, 3 ] } |]
+  assertBool "putObjectJSON 200" $ r ^. responseStatus . statusCode == 200
+  assertBool "putObjectJSON OK" $ r ^. responseStatus . statusMessage == "OK"
+
+getObjectJSON :: String -> String -> Options -> IO ()
+getObjectJSON prefix region baseopts = do
+  r <- getWith baseopts (url region ++ prefix ++ "testbucket/blabla-json")
+  assertBool "getObjectJSON 200" $ r ^. responseStatus . statusCode == 200
+  assertBool "getObjectJSON OK" $ r ^. responseStatus . statusMessage == "OK"
+
+deleteObjectJSON :: String -> String -> Options -> IO ()
+deleteObjectJSON prefix region baseopts = do
+  r <- deleteWith baseopts (url region ++ prefix ++ "testbucket/blabla-json")
+  assertBool "deleteObjectJSON 204 - no content" $
+    r ^. responseStatus . statusCode == 204
+  assertBool "deleteObjectJSON OK" $
+    r ^. responseStatus . statusMessage == "No Content"
+
+-- see http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region
+url :: String -> String
+url "us-east-1" = "https://s3.amazonaws.com/" -- uses 'classic'
+url region      = "https://s3-" ++ region ++ ".amazonaws.com/"
+
+-- see http://docs.aws.amazon.com/AmazonS3/latest/API/RESTBucketPUT.html
+locationConstraint :: String -> BS8.ByteString
+locationConstraint "us-east-1" = "" -- no loc needed for classic and Virginia
+locationConstraint region = "<CreateBucketConfiguration xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\"><LocationConstraint>" <> BS8.pack region <> "</LocationConstraint></CreateBucketConfiguration>"
diff --git a/tests/AWS/SQS.hs b/tests/AWS/SQS.hs
new file mode 100644
--- /dev/null
+++ b/tests/AWS/SQS.hs
@@ -0,0 +1,118 @@
+{-# LANGUAGE OverloadedStrings #-}
+module AWS.SQS (tests) where
+
+import Control.Lens
+import Data.Aeson.Lens (key, _String, values)
+import Data.IORef (IORef, readIORef, writeIORef)
+import Data.Text as T (Text, pack, unpack, split)
+import Network.Wreq
+import Test.Framework (Test, testGroup)
+import Test.Framework.Providers.HUnit (testCase)
+import Test.HUnit (assertBool)
+
+-- FIXME: retry create call in case we get the SQS specific "wait 1
+-- min after delete" error from a previous test run. For now the
+-- 'create' testcase and all others will fails. Rerun after awaiting
+-- the SQS 1 min window.
+
+tests :: String -> String -> Options -> IORef String -> Test
+tests prefix region baseopts sqsTestState = testGroup "sqs" [
+    testCase "createQueue" $ createQueue prefix region baseopts sqsTestState
+  , testCase "listQueues"  $ listQueues prefix region baseopts
+  , testCase "sendMessage" $ sendMessage prefix region baseopts sqsTestState
+  , testCase "receiveMessage" $ receiveMessage prefix region baseopts sqsTestState
+  , testCase "deleteQueue" $ deleteQueue prefix region baseopts sqsTestState
+  ]
+
+createQueue :: String -> String -> Options -> IORef String -> IO ()
+createQueue prefix region baseopts sqsTestState = do
+  let opts = baseopts
+             & param  "Action" .~ ["CreateQueue"]
+             & param  "QueueName" .~ [T.pack $ prefix ++ queuename]
+             & param  "Version" .~ ["2009-02-01"]
+             & header "Accept" .~ ["application/json"]
+  r <- getWith opts (url region)
+  assertBool "listQueues 200" $ r ^. responseStatus . statusCode == 200
+  assertBool "listQueues OK" $ r ^. responseStatus . statusMessage == "OK"
+  let qurl = r ^. responseBody . key "CreateQueueResponse"
+                              . key "CreateQueueResult"
+                              . key "QueueUrl"
+                              . _String
+  writeIORef sqsTestState $ acctFromQueueUrl qurl
+
+listQueues :: String -> String -> Options -> IO ()
+listQueues prefix region baseopts = do
+  let opts = baseopts
+             & param  "Action"  .~ ["ListQueues"]
+             & param  "Version" .~ ["2009-02-01"]
+             & header "Accept"  .~ ["application/json"]
+  r <- getWith opts (url region)
+  assertBool "listQueues 200" $ r ^. responseStatus . statusCode == 200
+  assertBool "listQueues OK" $ r ^. responseStatus . statusMessage == "OK"
+  let qurls = r ^.. responseBody . key "ListQueuesResponse" .
+                                   key "ListQueuesResult" .
+                                   key "queueUrls" .
+                                   values . _String
+  -- url of form: https://sqs.<region>.amazon.com/<acct>/<queuename>
+  let qurls' = map (T.unpack . last . T.split (=='/')) qurls
+  assertBool "listQueues contains test queue" $
+    elem (prefix ++ queuename) qurls'
+
+deleteQueue :: String -> String -> Options -> IORef String -> IO ()
+deleteQueue prefix region baseopts sqsTestState = do
+  acct <- readIORef sqsTestState
+  let opts = baseopts
+             & param "Action" .~ ["DeleteQueue"]
+             & param "Version" .~ ["2009-02-01"]
+             & header "Accept" .~ ["application/json"]
+  r <- getWith opts (url region ++ acct ++ "/" ++ prefix ++ queuename)
+  assertBool "deleteQueues 200" $ r ^. responseStatus . statusCode == 200
+  assertBool "deleteQueues OK" $ r ^. responseStatus . statusMessage == "OK"
+
+sendMessage :: String -> String -> Options -> IORef String -> IO ()
+sendMessage prefix region baseopts sqsTestState = do
+  acct <- readIORef sqsTestState
+  let opts = baseopts
+             & param "Action" .~ ["SendMessage"]
+             & param "Version" .~ ["2012-11-05"]
+             & param "MessageBody" .~ ["uffda"]
+             & header "Accept" .~ ["application/json"]
+  r <- getWith opts (url region ++ acct ++ "/" ++ prefix ++ queuename)
+  assertBool "sendMessage 200" $ r ^. responseStatus . statusCode == 200
+  assertBool "sendMessage OK" $ r ^. responseStatus . statusMessage == "OK"
+
+receiveMessage :: String -> String -> Options -> IORef String -> IO ()
+receiveMessage prefix region baseopts sqsTestState = do
+  acct <- readIORef sqsTestState
+  let opts = baseopts
+             & param "Action" .~ ["ReceiveMessage"]
+             & param "Version" .~ ["2009-02-01"]
+             & header "Accept" .~ ["application/json"]
+  r <- getWith opts (url region ++ acct ++ "/" ++ prefix ++ queuename)
+  let [msg] = map T.unpack $ r ^.. responseBody . -- we sent only 1 message
+                key "ReceiveMessageResponse" .
+                key "ReceiveMessageResult" .
+                key "messages" .
+                values .
+                key "Body" .
+                _String
+  assertBool "receiveMessage 200" $ r ^. responseStatus . statusCode == 200
+  assertBool "receiveMessage OK" $ r ^. responseStatus . statusMessage == "OK"
+  assertBool "receiveMessage match content" $ msg == "uffda"
+
+url :: String -> String
+url region =
+  "https://sqs." ++ region ++ ".amazonaws.com/"
+
+queuename :: String
+queuename =
+  "test"
+
+-- url of form: https://sqs.<region>.amazon.com/<acct>/<queuename>
+acctFromQueueUrl :: T.Text -> String
+acctFromQueueUrl qurl =
+  case T.split (=='/') qurl of
+    _:_:_:acct:_ ->
+      T.unpack acct
+    _ ->
+      "dummy"
diff --git a/tests/Properties/Store.hs b/tests/Properties/Store.hs
new file mode 100644
--- /dev/null
+++ b/tests/Properties/Store.hs
@@ -0,0 +1,83 @@
+{-# LANGUAGE RecordWildCards #-}
+
+module Properties.Store
+    (
+      tests
+    ) where
+
+import Data.Functor ((<$>))
+import Data.Hashable (Hashable)
+import Data.List (foldl', sort, sortBy)
+import Data.Maybe (listToMaybe)
+import Data.Ord (comparing)
+import Network.Wreq.Cache.Store as S
+import Test.Framework (Test)
+import Test.Framework.Providers.QuickCheck2
+import Test.QuickCheck (Positive(..), Small(..))
+
+data StoreModel k v = StoreModel {
+    smCap :: Int
+  , smGen :: Int
+  , smSize :: Int
+  , smList :: [(k,v,Int)]
+  } deriving (Show)
+
+emptySM :: Int -> StoreModel k v
+emptySM n = StoreModel n 0 0 []
+
+insertSM :: Eq k => k -> v -> StoreModel k v -> StoreModel k v
+insertSM k v sm@StoreModel{..}
+  | smSize < smCap || present =
+    sm { smGen = smGen + 1
+       , smSize = if present then smSize else smSize + 1
+       , smList = (k,v,smGen) : [x | x@(kk,_,_) <- smList, kk /= k]
+       }
+  | otherwise =
+    sm { smGen = smGen + 1
+       , smList = (k,v,smGen) : tail (sortBy (comparing $ \(_,_,g) -> g) smList)
+       }
+  where present = any (\(kk,_,_) -> k == kk) smList
+
+lookupSM :: Eq k => k -> StoreModel k v -> Maybe (v, StoreModel k v)
+lookupSM k sm@StoreModel{..} = listToMaybe
+                               [(v, sm') | (kk,v,_) <- smList, k == kk]
+  where sm' = sm { smGen = smGen + 1
+                 , smList = [(kk,v,if kk == k then smGen else g)
+                            | (kk,v,g) <- smList]
+                 }
+
+fromListSM :: Eq k => Int -> [(k,v)] -> StoreModel k v
+fromListSM = foldl' (flip (uncurry insertSM)) . emptySM
+
+toListSM :: StoreModel k v -> [(k,v)]
+toListSM sm = [(k,v) | (k,v,_) <- smList sm]
+
+unS :: (Ord k, Hashable k, Ord v) => S.Store k v -> [(k,v)]
+unS = sort . S.toList
+
+unM :: (Ord k, Ord v) => StoreModel k v -> [(k,v)]
+unM = sort . toListSM
+
+type N = Positive (Small Int)
+
+unN :: N -> Int
+unN (Positive (Small n)) = n
+
+t_fromList :: N -> [(Char,Char)] -> Bool
+t_fromList n xs = unS (S.fromList (unN n) xs) == unM (fromListSM (unN n) xs)
+
+t_lookup :: N -> Char -> [(Char,Char)] -> Bool
+t_lookup n k xs = (fmap unS <$> S.lookup k s) == (fmap unM <$> lookupSM k m)
+  where
+    s = S.fromList (unN n) xs
+    m = fromListSM (unN n) xs
+
+t_lookup1 :: N -> Char -> Char -> [(Char, Char)] -> Bool
+t_lookup1 n k v xs = t_lookup n k ((k,v):xs)
+
+tests :: [Test]
+tests = [
+    testProperty "t_fromList" t_fromList
+  , testProperty "t_lookup" t_lookup
+  , testProperty "t_lookup1" t_lookup1
+  ]
diff --git a/tests/Tests.hs b/tests/Tests.hs
--- a/tests/Tests.hs
+++ b/tests/Tests.hs
@@ -1,207 +1,14 @@
-{-# LANGUAGE OverloadedStrings, ScopedTypeVariables #-}
-{-# OPTIONS_GHC -fno-warn-incomplete-patterns -fno-warn-missing-signatures
-    -fno-warn-unused-binds #-}
-
 module Main (main) where
 
-import Control.Applicative ((<$>))
-import Control.Exception (Exception)
-import Control.Lens ((^.), (^?), (.~), (&))
-import Control.Monad (unless, void)
-import Data.Aeson (Value(..), object)
-import Data.Aeson.Lens (key)
-import Data.ByteString (ByteString)
-import Data.Char (toUpper)
-import Data.Maybe (isJust)
-import Data.Monoid ((<>))
-import Data.Text (pack)
-import Network.HTTP.Client (HttpException(..))
-import Network.HTTP.Types.Status (status200, status401)
-import Network.HTTP.Types.Version (http11)
-import Network.Wreq
-import Network.Wreq.Lens
-import qualified Network.Wreq.Session as Session
-import System.IO (hClose, hPutStr)
-import System.IO.Temp (withSystemTempFile)
-import Test.Framework (defaultMain, testGroup)
-import Test.Framework.Providers.HUnit (testCase)
-import Test.HUnit (assertBool, assertEqual, assertFailure)
-import qualified Control.Exception as E
-import qualified Data.Text as T
-
-basicGet site = do
-  r <- get (site "/get")
-  assertBool "GET request has User-Agent header" $
-    isJust (r ^. responseBody ^? key "headers" . key "User-Agent")
-  -- test the various lenses
-  assertEqual "GET succeeds" status200 (r ^. responseStatus)
-  assertEqual "GET succeeds 200" 200 (r ^. responseStatus . statusCode)
-  assertEqual "GET succeeds OK" "OK" (r ^. responseStatus . statusMessage)
-  assertEqual "GET response has HTTP/1.1 version" http11 (r ^. responseVersion)
-  assertBool "GET response has Content-Type header" $
-    isJust (r ^? responseHeader "Content-Type")
-  assertBool "GET response has Date header" $
-    isJust (lookup "Date" <$> r ^? responseHeaders)
-
-basicPost site = do
-  r <- post (site "/post") ("wibble" :: ByteString) >>= asValue
-  let body = r ^. responseBody
-  assertEqual "POST succeeds" status200 (r ^. responseStatus)
-  assertEqual "POST echoes input" (Just "wibble") (body ^? key "data")
-  assertEqual "POST is binary" (Just "application/octet-stream")
-                               (body ^? key "headers" . key "Content-Type")
-
-multipartPost site =
-  withSystemTempFile "foo.html" $ \name handle -> do
-    hPutStr handle "<!DOCTYPE html><html></html"
-    hClose handle
-    r <- post (site "/post") (partFile "html" name)
-    assertEqual "POST succeeds" status200 (r ^. responseStatus)
-
-basicHead site = do
-  r <- head_ (site "/get")
-  assertEqual "HEAD succeeds" status200 (r ^. responseStatus)
-
-basicPut site = do
-  r <- put (site "/put") ("wibble" :: ByteString)
-  assertEqual "PUT succeeds" status200 (r ^. responseStatus)
-
-basicDelete site = do
-  r <- delete (site "/delete")
-  assertEqual "DELETE succeeds" status200 (r ^. responseStatus)
-
-throwsStatusCode site =
-    assertThrows "404 causes exception to be thrown" inspect $
-    head_ (site "/status/404")
-  where inspect e = case e of
-                      StatusCodeException _ _ _ -> return ()
-                      _ -> assertFailure "unexpected exception thrown"
-
-getBasicAuth site = do
-  let opts = defaults & auth .~ basicAuth "user" "passwd"
-  r <- getWith opts (site "/basic-auth/user/passwd")
-  assertEqual "basic auth GET succeeds" status200 (r ^. responseStatus)
-  let inspect e = case e of
-                    StatusCodeException status _ _ ->
-                      assertEqual "failed basic auth failed GET gives 401"
-                        status401 status
-  assertThrows "basic auth GET fails if password is bad" inspect $
-    getWith opts (site "/basic-auth/user/asswd")
-
-getRedirect site = do
-  r <- get (site "/redirect/3")
-  let stripProto = T.dropWhile (/=':')
-      smap f (String s) = String (f s)
-  assertEqual "redirect goes to /get"
-    (Just . String . stripProto . pack . site $ "/get")
-    (smap stripProto <$> (r ^. responseBody ^? key "url"))
-
-getParams site = do
-  let opts1 = defaults & param "foo" .~ ["bar"]
-  r1 <- getWith opts1 (site "/get")
-  assertEqual "params set correctly 1" (Just (object [("foo","bar")]))
-    (r1 ^. responseBody ^? key "args")
-  let opts2 = defaults & params .~ [("quux","baz")]
-  r2 <- getWith opts2 (site "/get")
-  assertEqual "params set correctly 2" (Just (object [("quux","baz")]))
-    (r2 ^. responseBody ^? key "args")
-  r3 <- getWith opts2 (site "/get?whee=wat")
-  assertEqual "correctly handle mix of params from URI and Options"
-    (Just (object [("quux","baz"),("whee","wat")]))
-    (r3 ^. responseBody ^? key "args")
-
-getHeaders site = do
-  let opts = defaults & header "X-Wibble" .~ ["bar"]
-  r <- getWith opts (site "/get")
-  assertEqual "extra header set correctly"
-    (Just "bar")
-    (r ^. responseBody ^? key "headers" . key "X-Wibble")
-
-getGzip site = do
-  r <- get (site "/gzip")
-  assertEqual "gzip decoded for us" (Just (Bool True))
-    (r ^. responseBody ^? key "gzipped")
-
-headRedirect site =
-  assertThrows "HEAD of redirect throws exception" inspect $
-    head_ (site "/redirect/3")
-  where inspect e = case e of
-                      StatusCodeException status _ _ ->
-                        let code = status ^. statusCode
-                        in assertBool "code is redirect"
-                           (code >= 300 && code < 400)
-
-redirectOverflow site =
-  assertThrows "GET with too many redirects throws exception" inspect $
-    getWith (defaults & redirects .~ 3) (site "/redirect/5")
-  where inspect e = case e of TooManyRedirects _ -> return ()
-
-invalidURL _site = do
-  let noProto (InvalidUrlException _ _) = return ()
-  assertThrows "exception if no protocol" noProto (get "wheeee")
-  let noHost (InvalidDestinationHost _) = return ()
-  assertThrows "exception if no host" noHost (get "http://")
-
-funkyScheme site = do
-  -- schemes are case insensitive, per RFC 3986 section 3.1
-  let (scheme, rest) = break (==':') $ site "/get"
-  void . get $ map toUpper scheme <> rest
-
-cookiesSet site = do
-  r <- get (site "/cookies/set?x=y")
-  assertEqual "cookies are set correctly" (Just "y")
-    (r ^? responseCookie "x" . cookieValue)
-
-cookieSession site = Session.withSession $ \s -> do
-  void $ Session.get s (site "/cookies/set?foo=bar")
-  r <- Session.get s (site "/cookies")
-  assertEqual "cookies are set correctly" (Just "bar")
-    (r ^? responseCookie "foo" . cookieValue)
-  assertEqual "whee" (Just "bar")
-    (r ^. responseBody ^? key "cookies" . key "foo")
-
-getWithManager site = withManager $ \opts -> do
-  void $ getWith opts (site "/get?a=b")
-  void $ getWith opts (site "/get?b=c")
-
-assertThrows :: (Show e, Exception e) => String -> (e -> IO ()) -> IO a -> IO ()
-assertThrows desc inspect act = do
-  let myInspect e = inspect e `E.catch` \(ee :: E.PatternMatchFail) ->
-        assertFailure (desc <> ": unexpected exception (" <>
-                       show e <> "): " <> show ee)
-  caught <- (act >> return False) `E.catch` \e -> myInspect e >> return True
-  unless caught (assertFailure desc)
+import Test.Framework (testGroup)
+import UnitTests (testWith)
+import qualified AWS (tests)
+import qualified Properties.Store
 
-testsWith site = [
-    testGroup "basic" [
-      testCase "get" $ basicGet site
-    , testCase "post" $ basicPost site
-    , testCase "head" $ basicHead site
-    , testCase "put" $ basicPut site
-    , testCase "delete" $ basicDelete site
-    , testCase "404" $ throwsStatusCode site
-    , testCase "headRedirect" $ headRedirect site
-    , testCase "redirectOverflow" $ redirectOverflow site
-    , testCase "invalidURL" $ invalidURL site
-    , testCase "funkyScheme" $ funkyScheme site
-    ]
-  , testGroup "fancy" [
-      testCase "basic auth" $ getBasicAuth site
-    , testCase "redirect" $ getRedirect site
-    , testCase "params" $ getParams site
-    , testCase "headers" $ getHeaders site
-    , testCase "gzip" $ getGzip site
-    , testCase "cookiesSet" $ cookiesSet site
-    , testCase "cookieSession" $ cookieSession site
-    , testCase "getWithManager" $ getWithManager site
+main :: IO ()
+main = do
+  awsTests <- AWS.tests
+  testWith [
+      testGroup "store" Properties.Store.tests
+    , awsTests
     ]
-  ]
-
-tests = [
-    testGroup "http" $ testsWith ("http://httpbin.org" <>)
-  , testGroup "https" $ testsWith ("https://httpbin.org" <>)
-  ]
-
-main = defaultMain tests
-
-localtest = defaultMain (testsWith ("http://localhost:8000" <>))
diff --git a/tests/UnitTests.hs b/tests/UnitTests.hs
new file mode 100644
--- /dev/null
+++ b/tests/UnitTests.hs
@@ -0,0 +1,327 @@
+{-# LANGUAGE OverloadedStrings, RankNTypes, RecordWildCards,
+    ScopedTypeVariables #-}
+{-# OPTIONS_GHC -fno-warn-incomplete-patterns -fno-warn-missing-signatures
+    -fno-warn-unused-binds #-}
+
+module UnitTests (testWith) where
+
+import Control.Applicative ((<$>))
+import Control.Concurrent (forkIO, killThread)
+import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)
+import Control.Exception (Exception, toException)
+import Control.Lens ((^.), (^?), (.~), (?~), (&))
+import Control.Monad (unless, void)
+import Data.Aeson (Value(..), object)
+import Data.Aeson.Lens (key)
+import Data.ByteString (ByteString)
+import Data.Char (toUpper)
+import Data.Maybe (isJust)
+import Data.Monoid ((<>))
+import HttpBin.Server (serve)
+import Network.HTTP.Client (HttpException(..))
+import Network.HTTP.Types.Status (Status(Status), status200, status401)
+import Network.HTTP.Types.Version (http11)
+import Network.Wreq hiding
+  (get, post, head_, put, options, delete,
+   getWith, postWith, headWith, putWith, optionsWith, deleteWith)
+import Network.Wreq.Lens
+import Network.Wreq.Types (Postable, Putable)
+import Snap.Http.Server.Config
+import System.IO (hClose, hPutStr)
+import System.IO.Temp (withSystemTempFile)
+import Test.Framework (Test, defaultMain, testGroup)
+import Test.Framework.Providers.HUnit (testCase)
+import Test.HUnit (assertBool, assertEqual, assertFailure)
+import qualified Control.Exception as E
+import qualified Data.Text as T
+import qualified Network.Wreq.Session as Session
+import qualified Data.ByteString.Lazy as L
+import qualified Network.Wreq as Wreq
+
+data Verb = Verb {
+    get :: String -> IO (Response L.ByteString)
+  , getWith :: Options -> String -> IO (Response L.ByteString)
+  , post :: Postable a => String -> a -> IO (Response L.ByteString)
+  , postWith :: Postable a => Options -> String -> a
+             -> IO (Response L.ByteString)
+  , head_ :: String -> IO (Response ())
+  , headWith :: Options -> String -> IO (Response ())
+  , put :: Putable a => String -> a -> IO (Response L.ByteString)
+  , putWith :: Putable a => Options -> String -> a -> IO (Response L.ByteString)
+  , options :: String -> IO (Response ())
+  , optionsWith :: Options -> String -> IO (Response ())
+  , delete :: String -> IO (Response L.ByteString)
+  , deleteWith :: Options -> String -> IO (Response L.ByteString)
+  }
+
+basic :: Verb
+basic = Verb { get = Wreq.get, getWith = Wreq.getWith, post = Wreq.post
+             , postWith = Wreq.postWith, head_ = Wreq.head_
+             , headWith = Wreq.headWith, put = Wreq.put
+             , putWith = Wreq.putWith, options = Wreq.options
+             , optionsWith = Wreq.optionsWith, delete = Wreq.delete
+             , deleteWith = Wreq.deleteWith }
+
+session :: Session.Session -> Verb
+session s = Verb { get = Session.get s
+                 , getWith = flip Session.getWith s
+                 , post = Session.post s
+                 , postWith = flip Session.postWith s
+                 , head_ = Session.head_ s
+                 , headWith = flip Session.headWith s
+                 , put = Session.put s
+                 , putWith = flip Session.putWith s
+                 , options = Session.options s
+                 , optionsWith = flip Session.optionsWith s
+                 , delete = Session.delete s
+                 , deleteWith = flip Session.deleteWith s }
+
+basicGet Verb{..} site = do
+  r <- get (site "/get")
+  assertBool "GET request has User-Agent header" $
+    isJust (r ^. responseBody ^? key "headers" . key "User-Agent")
+  -- test the various lenses
+  assertEqual "GET succeeds" status200 (r ^. responseStatus)
+  assertEqual "GET succeeds 200" 200 (r ^. responseStatus . statusCode)
+  assertEqual "GET succeeds OK" "OK" (r ^. responseStatus . statusMessage)
+  assertEqual "GET response has HTTP/1.1 version" http11 (r ^. responseVersion)
+  assertBool "GET response has Content-Type header" $
+    isJust (r ^? responseHeader "Content-Type")
+  assertBool "GET response has Date header" $
+    isJust (lookup "Date" <$> r ^? responseHeaders)
+
+basicPost Verb{..} site = do
+  r <- post (site "/post") ("wibble" :: ByteString) >>= asValue
+  let body = r ^. responseBody
+  assertEqual "POST succeeds" status200 (r ^. responseStatus)
+  assertEqual "POST echoes input" (Just "wibble") (body ^? key "data")
+  assertEqual "POST is binary" (Just "application/octet-stream")
+                               (body ^? key "headers" . key "Content-Type")
+
+multipartPost Verb{..} site =
+  withSystemTempFile "foo.html" $ \name handle -> do
+    hPutStr handle "<!DOCTYPE html><html></html"
+    hClose handle
+    r <- post (site "/post") (partFile "html" name)
+    assertEqual "POST succeeds" status200 (r ^. responseStatus)
+
+basicHead Verb{..} site = do
+  r <- head_ (site "/get")
+  assertEqual "HEAD succeeds" status200 (r ^. responseStatus)
+
+basicPut Verb{..} site = do
+  r <- put (site "/put") ("wibble" :: ByteString)
+  assertEqual "PUT succeeds" status200 (r ^. responseStatus)
+
+basicDelete Verb{..} site = do
+  r <- delete (site "/delete")
+  assertEqual "DELETE succeeds" status200 (r ^. responseStatus)
+
+throwsStatusCode Verb{..} site =
+    assertThrows "404 causes exception to be thrown" inspect $
+    head_ (site "/status/404")
+  where inspect e = case e of
+                      StatusCodeException _ _ _ -> return ()
+                      _ -> assertFailure "unexpected exception thrown"
+
+getBasicAuth Verb{..} site = do
+  let opts = defaults & auth ?~ basicAuth "user" "passwd"
+  r <- getWith opts (site "/basic-auth/user/passwd")
+  assertEqual "basic auth GET succeeds" status200 (r ^. responseStatus)
+  let inspect e = case e of
+                    StatusCodeException status _ _ ->
+                      assertEqual "basic auth failed GET gives 401"
+                        status401 status
+  assertThrows "basic auth GET fails if password is bad" inspect $
+    getWith opts (site "/basic-auth/user/asswd")
+
+getOAuth2 Verb{..} kind ctor site = do
+  let opts = defaults & auth ?~ ctor "token1234"
+  r <- getWith opts (site $ "/oauth2/" <> kind <> "/token1234")
+  assertEqual ("oauth2 " <> kind <> " GET succeeds")
+    status200 (r ^. responseStatus)
+  let inspect e = case e of
+                    StatusCodeException status _ _ ->
+                      assertEqual ("oauth2 " <> kind <> " failed GET gives 401")
+                        status401 status
+  assertThrows ("oauth2 " <> kind <> " GET fails if token is bad") inspect $
+    getWith opts (site $ "/oauth2/" <> kind <> "/token123")
+
+getRedirect Verb{..} site = do
+  r <- get (site "/redirect/3")
+  let stripProto = T.dropWhile (/=':')
+      smap f (String s) = String (f s)
+  assertEqual "redirect goes to /get"
+    (Just . String . stripProto . T.pack . site $ "/get")
+    (smap stripProto <$> (r ^. responseBody ^? key "url"))
+
+getParams Verb{..} site = do
+  let opts1 = defaults & param "foo" .~ ["bar"]
+  r1 <- getWith opts1 (site "/get")
+  assertEqual "params set correctly 1" (Just (object [("foo","bar")]))
+    (r1 ^. responseBody ^? key "args")
+  let opts2 = defaults & params .~ [("quux","baz")]
+  r2 <- getWith opts2 (site "/get")
+  assertEqual "params set correctly 2" (Just (object [("quux","baz")]))
+    (r2 ^. responseBody ^? key "args")
+  r3 <- getWith opts2 (site "/get?whee=wat")
+  assertEqual "correctly handle mix of params from URI and Options"
+    (Just (object [("quux","baz"),("whee","wat")]))
+    (r3 ^. responseBody ^? key "args")
+
+getHeaders Verb{..} site = do
+  let opts = defaults & header "X-Wibble" .~ ["bar"]
+  r <- getWith opts (site "/get")
+  assertEqual "extra header set correctly"
+    (Just "bar")
+    (r ^. responseBody ^? key "headers" . key "X-Wibble")
+
+getCheckStatus Verb {..} site = do
+  let opts = defaults & checkStatus .~ (Just customCs)
+  r <- getWith opts (site "/status/404")
+  assertThrows "Non 404 throws error" inspect $
+    getWith opts (site "/get")
+  assertEqual "Status 404" 
+    404
+    (r ^. responseStatus . statusCode)
+  where 
+    customCs (Status 404 _) _ _ = Nothing 
+    customCs s h cj             = Just . toException . StatusCodeException s h $ cj
+
+    inspect e = case e of
+      (StatusCodeException (Status sc _) _ _) -> 
+        assertEqual "200 Status Error" sc 200 
+
+getGzip Verb{..} site = do
+  r <- get (site "/gzip")
+  assertEqual "gzip decoded for us" (Just (Bool True))
+    (r ^. responseBody ^? key "gzipped")
+
+headRedirect Verb{..} site =
+  assertThrows "HEAD of redirect throws exception" inspect $
+    head_ (site "/redirect/3")
+  where inspect e = case e of
+                      StatusCodeException status _ _ ->
+                        let code = status ^. statusCode
+                        in assertBool "code is redirect"
+                           (code >= 300 && code < 400)
+
+redirectOverflow Verb{..} site =
+  assertThrows "GET with too many redirects throws exception" inspect $
+    getWith (defaults & redirects .~ 3) (site "/redirect/5")
+  where inspect e = case e of TooManyRedirects _ -> return ()
+
+invalidURL Verb{..} _site = do
+  let noProto (InvalidUrlException _ _) = return ()
+  assertThrows "exception if no protocol" noProto (get "wheeee")
+  let noHost (InvalidDestinationHost _) = return ()
+  assertThrows "exception if no host" noHost (get "http://")
+
+funkyScheme Verb{..} site = do
+  -- schemes are case insensitive, per RFC 3986 section 3.1
+  let (scheme, rest) = break (==':') $ site "/get"
+  void . get $ map toUpper scheme <> rest
+
+cookiesSet Verb{..} site = do
+  r <- get (site "/cookies/set?x=y")
+  assertEqual "cookies are set correctly" (Just "y")
+    (r ^? responseCookie "x" . cookieValue)
+
+cookieSession site = Session.withSession $ \s -> do
+  void $ Session.get s (site "/cookies/set?foo=bar")
+  r <- Session.get s (site "/cookies")
+  assertEqual "cookies are set correctly" (Just "bar")
+    (r ^? responseCookie "foo" . cookieValue)
+  assertEqual "whee" (Just "bar")
+    (r ^. responseBody ^? key "cookies" . key "foo")
+
+getWithManager site = withManager $ \opts -> do
+  void $ Wreq.getWith opts (site "/get?a=b")
+  void $ Wreq.getWith opts (site "/get?b=c")
+
+assertThrows :: (Show e, Exception e) => String -> (e -> IO ()) -> IO a -> IO ()
+assertThrows desc inspect act = do
+  let myInspect e = inspect e `E.catch` \(ee :: E.PatternMatchFail) ->
+        assertFailure (desc <> ": unexpected exception (" <>
+                       show e <> "): " <> show ee)
+  caught <- (act >> return False) `E.catch` \e -> myInspect e >> return True
+  unless caught (assertFailure desc)
+
+commonTestsWith verb site = [
+    testGroup "basic" [
+      testCase "get" $ basicGet verb site
+    , testCase "post" $ basicPost verb site
+    , testCase "head" $ basicHead verb site
+    , testCase "put" $ basicPut verb site
+    , testCase "delete" $ basicDelete verb site
+    , testCase "404" $ throwsStatusCode verb site
+    , testCase "headRedirect" $ headRedirect verb site
+    , testCase "redirectOverflow" $ redirectOverflow verb site
+    , testCase "invalidURL" $ invalidURL verb site
+    , testCase "funkyScheme" $ funkyScheme verb site
+    ]
+  , testGroup "fancy" [
+      testCase "basic auth" $ getBasicAuth verb site
+    , testCase "redirect" $ getRedirect verb site
+    , testCase "params" $ getParams verb site
+    , testCase "headers" $ getHeaders verb site
+    , testCase "gzip" $ getGzip verb site
+    , testCase "cookiesSet" $ cookiesSet verb site
+    , testCase "getWithManager" $ getWithManager site
+    , testCase "cookieSession" $ cookieSession site
+    , testCase "getCheckStatus" $ getCheckStatus verb site
+    ]
+  ]
+
+-- Snap responds incorrectly to HEAD (by sending a response body),
+-- thereby killing http-client's ability to continue a session.
+-- https://github.com/snapframework/snap-core/issues/192
+snapHeadSessionBug site = Session.withSession $ \s -> do
+  basicHead (session s) site
+  -- will crash with (InvalidStatusLine "0")
+  basicGet (session s) site
+
+httpbinTestsWith verb site = commonTestsWith verb site <> [
+  ]
+
+-- Tests that our local httpbin clone doesn't yet support.
+httpbinTests verb = [testGroup "httpbin" [
+    testGroup "http" $ httpbinTestsWith verb ("http://httpbin.org" <>)
+  , testGroup "https" $ httpbinTestsWith verb ("https://httpbin.org" <>)
+  ]]
+
+-- Tests that httpbin.org doesn't support.
+localTests verb site = commonTestsWith verb site <> [
+    testCase "oauth2 Bearer" $ getOAuth2 verb "Bearer" oauth2Bearer site
+  , testCase "oauth2 token" $ getOAuth2 verb "token" oauth2Token site
+  ]
+
+startServer = do
+  started <- newEmptyMVar
+  let go n | n >= 100 = putMVar started Nothing
+           | otherwise = do
+        let port = 8000 + n
+            startedUp p = putMVar started (Just ("http://localhost:" <> p))
+            mkCfg = return . setBind ("localhost") . setPort port .
+                    setVerbose False .
+                    setStartupHook (const (startedUp (show port)))
+        serve mkCfg `E.catch` \(_::E.IOException) -> go (n+1)
+  tid <- forkIO $ go 0
+  (,) tid <$> takeMVar started
+
+testWith :: [Test] -> IO ()
+testWith tests = do
+  (tid, mserv) <- startServer
+  Session.withSession $ \s ->
+    flip E.finally (killThread tid) .
+    defaultMain $ tests <>
+                  [ testGroup "plain" $ httpbinTests basic
+                  , testGroup "session" $ httpbinTests (session s)] <>
+      case mserv of
+        Nothing -> []
+        Just binding -> [
+            testGroup "localhost" [
+              testGroup "plain" $ localTests basic (binding <>)
+            , testGroup "session" $ localTests (session s) (binding <>)
+            ]
+          ]
diff --git a/wreq.cabal b/wreq.cabal
--- a/wreq.cabal
+++ b/wreq.cabal
@@ -1,5 +1,5 @@
 name:                wreq
-version:             0.2.0.0
+version:             0.3.0.0
 synopsis:            An easy-to-use HTTP client library.
 description:
   .
@@ -49,25 +49,37 @@
 
 -- disable doctests with -f-doctest
 flag doctest
+  description: enable doctest tests
   default: True
   manual: True
 
 -- enable httpbin with -fhttpbin
 flag httpbin
+  description: enable httpbin test daemon
   default: False
   manual: True
 
+flag developer
+  description: build in developer mode
+  default: False
+  manual: True
+
 library
   ghc-options: -Wall -fwarn-tabs -funbox-strict-fields
+  if flag(developer)
+    ghc-options:  -Werror
   default-language: Haskell98
 
   exposed-modules:
     Network.Wreq
+    Network.Wreq.Cache
+    Network.Wreq.Cache.Store
     Network.Wreq.Lens
     Network.Wreq.Session
     Network.Wreq.Types
   other-modules:
     Network.Wreq.Internal
+    Network.Wreq.Internal.AWS
     Network.Wreq.Internal.Lens
     Network.Wreq.Internal.Link
     Network.Wreq.Internal.Types
@@ -75,27 +87,40 @@
     Network.Wreq.Lens.TH
     Paths_wreq
   build-depends:
+    PSQueue >= 1.1,
     aeson >= 0.7.0.3,
     attoparsec >= 0.11.1.0,
     base >= 4.5 && < 5,
+    base16-bytestring,
+    byteable,
     bytestring >= 0.9,
+    case-insensitive,
+    containers,
+    cryptohash,
     exceptions >= 0.5,
-    http-client >= 0.3.1.1,
+    ghc-prim,
+    hashable,
+    http-client >= 0.4.3,
     http-client-tls >= 0.2,
     http-types >= 0.8,
-    lens >= 4.4,
+    lens >= 4.5,
     lens-aeson,
     mime-types,
+    old-locale,
     template-haskell,
     text,
-    time
+    time,
+    unordered-containers
 
 -- A convenient server for testing locally, or if httpbin.org is down.
 executable httpbin
   hs-source-dirs: httpbin
-  main-is:        HttpBin.hs
-  ghc-options:    -Wall -fwarn-tabs -threaded
+  ghc-options:    -Wall -fwarn-tabs -threaded -rtsopts
+  if flag(developer)
+    ghc-options:  -Werror
   default-language: Haskell98
+  main-is:        HttpBin.hs
+  other-modules:  HttpBin.Server
 
   if !flag(httpbin)
     buildable: False
@@ -109,29 +134,56 @@
       case-insensitive,
       containers,
       snap-core,
-      snap-server >= 0.9.4.2,
-      text
+      snap-server >= 0.9.4.4,
+      text,
+      transformers,
+      unix-compat,
+      uuid
 
 test-suite tests
   type:           exitcode-stdio-1.0
-  hs-source-dirs: tests
+  hs-source-dirs: httpbin tests
   main-is:        Tests.hs
-  ghc-options:    -Wall -fwarn-tabs -funbox-strict-fields -threaded
+  ghc-options:    -Wall -fwarn-tabs -funbox-strict-fields -threaded -rtsopts
+  if flag(developer)
+    ghc-options:  -Werror
   default-language: Haskell98
+  other-modules:
+    Properties.Store
+    UnitTests
+    AWS
+    AWS.DynamoDB
+    AWS.IAM
+    AWS.S3
+    AWS.SQS
 
   build-depends:
     HUnit,
+    QuickCheck >= 2.7,
     aeson,
+    aeson-pretty >= 0.7.1,
+    aeson-qq,
     base >= 4.5 && < 5,
+    base64-bytestring,
     bytestring,
+    case-insensitive,
+    containers,
+    hashable,
     http-client,
     http-types,
-    lens >= 4.4,
+    lens,
     lens-aeson,
+    network-info,
+    snap-core,
+    snap-server >= 0.9.4.4,
     temporary,
     test-framework,
     test-framework-hunit,
+    test-framework-quickcheck2,
     text,
+    transformers,
+    unix-compat,
+    uuid,
     wreq
 
 test-suite doctest
@@ -139,6 +191,8 @@
   hs-source-dirs: tests
   main-is:        DocTests.hs
   ghc-options:    -Wall -fwarn-tabs -threaded
+  if flag(developer)
+    ghc-options:  -Werror
   default-language: Haskell98
 
   if !flag(doctest)
diff --git a/www/Makefile b/www/Makefile
--- a/www/Makefile
+++ b/www/Makefile
@@ -3,13 +3,15 @@
 files := index.html tutorial.html
 deps = bootstrap-custom.css background.jpg
 install := $(files) $(deps)
+destdir := $(HOME)/public_html/wreq
 
 all: $(files)
 
 install: $(files)
-	-mkdir -p $(HOME)/public_html/wreq
-	cp -a $(install) $(HOME)/public_html/wreq
-	cp -a $(bootstrap) $(HOME)/public_html/wreq
+	-mkdir -p $(destdir)
+	cp -a $(install) $(destdir)
+	cp -a $(bootstrap) $(destdir)
+	-chcon -R -t httpd_sys_content_t $(destdir)
 
 %.html: %.md template.html $(deps)
 	pandoc $< -o $@ --smart --template template.html \
diff --git a/www/index.md b/www/index.md
--- a/www/index.md
+++ b/www/index.md
@@ -27,7 +27,11 @@
 
 * Basic and OAuth2 bearer authentication
 
+* Amazon Web Services (AWS) request signing (Version 4)
 
+* AWS signing supports sending requests through the
+  [Runscope Inc.](https://www.runscope.com) Traffic Inspector
+
 # Whirlwind tour
 
 ~~~~ {.haskell}
@@ -123,7 +127,7 @@
 
 I'd like to thank Edward Kmett and Shachaf Ben-Kiki for tirelessly
 answering my never-ending stream of
-[lens](https://lens.github.io/)-related questions in `#haskell-lens#`.
+[lens](https://lens.github.io/)-related questions in `#haskell-lens`.
 
 I also want to thank Michael Snoyman for being so quick with helpful
 responses to bug reports and pull requests against his excellent
diff --git a/www/tutorial.md b/www/tutorial.md
--- a/www/tutorial.md
+++ b/www/tutorial.md
@@ -120,7 +120,7 @@
 ~~~~ {.haskell}
 ghci> import Data.Aeson.Lens (_String, key)
 ghci> let opts = defaults & param "foo" .~ ["bar", "quux"]
-ghci> r <- getWith opts "http://httpbin.org"
+ghci> r <- getWith opts "http://httpbin.org/get"
 ghci> r ^. responseBody . key "url" . _String
 "http://httpbin.org/get?foo=bar&foo=quux"
 ~~~~
@@ -386,6 +386,7 @@
 [`partFileSource`](http://hackage.haskell.org/package/wreq/docs/Network-Wreq.html#t:partFileSource).
 
 ~~~~ {.haskell}
+ghci> import Data.Aeson.Lens (members)
 ghci> r <- post "http://httpbin.org/post" (partFile "file" "hello.hs")
 ghci> r ^.. responseBody . key "files" . members . _String
 ["main = putStrLn \"hello\"\n"]
@@ -430,7 +431,7 @@
 [`HttpException`](http://hackage.haskell.org/package/http-client/docs/Network-HTTP-Client.html#t:HttpException).
 
 ~~~~ {.haskell}
-ghci> r <- get "http://httpbin.org/basic-auth/user/pass
+ghci> r <- get "http://httpbin.org/basic-auth/user/pass"
 *** Exception: StatusCodeException (Status {statusCode = 401, {-...-}
 ~~~~
 
@@ -439,18 +440,70 @@
 retry.)
 
 ~~~~ {.haskell}
-ghci> let opts = defaults & auth .~ basicAuth "user" "pass"
+ghci> let opts = defaults & auth ?~ basicAuth "user" "pass"
 ghci> r <- getWith opts "https://httpbin.org/basic-auth/user/pass"
 ghci> r ^. responseBody
 "{\n  \"authenticated\": true,\n  \"user\": \"user\"\n}"
 ~~~~
 
+<div class="alert alert-info">
+We use the
+[`?~`](http://hackage.haskell.org/package/lens/docs/Control-Lens-Setter.html#v:-63--126-)
+ operator to turn an [`Auth`](http://hackage.haskell.org/package/wreq/docs/Network-Wreq.html#t:Auth)
+into a `Maybe Auth` here, to make the type of value on the right hand
+side compatible with the
+[`auth`](http://hackage.haskell.org/package/wreq/docs/Network-Wreq.html#v:auth)
+lens.
+</div>
+
 For OAuth2 bearer authentication, `wreq` supports two flavours:
 [`oauth2Bearer`](http://hackage.haskell.org/package/wreq/docs/Network-Wreq.html#v:oauth2Bearer)
 is the standard bearer token, while
 [`oauth2Token`](http://hackage.haskell.org/package/wreq/docs/Network-Wreq.html#v:oauth2Token)
 is GitHub's variant.  These tokens are equivalent in value to a
 username and password.
+
+## Amazon Web Services (AWS)
+To authenticate to Amazon Web Services (AWS), we use
+[`awsAuth`](http://hackage.haskell.org/package/wreq/docs/Network-Wreq.html#v:awsAuth). In
+this example, we set the `Accept` header to request JSON, as opposed
+to XML output from AWS.
+
+~~~~ {.haskell}
+ghci> let opts = defaults & auth ?~ awsAuth AWSv4 "key" "secret"
+                          & header "Accept" .~ ["application/json"]
+ghci> r <- getWith opts "https://sqs.us-east-1.amazonaws.com/?Action=ListQueues"
+ghci> r ^. responseBody
+"{\"ListQueuesResponse\":{\"ListQueuesResult\":{\"queueUrls\": ... }"
+~~~~
+
+## Runscope support for Amazon Web Services (AWS) requests
+To send requests to AWS through the [Runscope Inc.](https://www.runscope.com)
+Traffic Inspector, convert the AWS service URL to a Runscope Bucket URL
+using the "URL Helper" section in the Runscope dashboard (as you
+would for other HTTP endpoints). Then invoke the AWS service as
+before.  For example, if your Runscope bucket key is
+`7kh11example`, call AWS like so:
+
+~~~~ {.haskell}
+ghci> let opts = defaults & auth ?~ awsAuth AWSv4 "key" "secret"
+                          & header "Accept" .~ ["application/json"]
+ghci> r <- getWith opts "https://sqs-us--east--1-amazonaws-com-7kh11example.runscope.net/?Action=ListQueues"
+ghci> r ^. responseBody
+"{\"ListQueuesResponse\":{\"ListQueuesResult\":{\"queueUrls\": ... }"
+~~~~
+
+If you enabled "Require Authentication Token" in the "Bucket Settings"
+of your Runscope dashboard, set the `Runscope-Bucket-Auth` header like so:
+
+~~~~ {.haskell}
+ghci> let opts = defaults & auth ?~ awsAuth AWSv4 "key" "secret"
+                          & header "Accept" .~ ["application/json"]
+                          & header "Runscope-Bucket-Auth" .~ ["1example-1111-4yyyy-zzzz-xxxxxxxx"]
+ghci> r <- getWith opts "https://sqs-us--east--1-amazonaws-com-7kh11example.runscope.net/?Action=ListQueues"
+ghci> r ^. responseBody
+"{\"ListQueuesResponse\":{\"ListQueuesResult\":{\"queueUrls\": ... }"
+~~~~
 
 
 # Error handling
