module TestClient

using HTTP, HTTP.Exceptions, MbedTLS, OpenSSL
using HTTP: IOExtras
include(joinpath(dirname(pathof(HTTP)), "../test/resources/TestRequest.jl"))
import ..isok, ..httpbin
using .TestRequest
using .TestRequest2
using Sockets
using JSON
using Test
using URIs
using InteractiveUtils: @which
using ConcurrentUtilities

# ConcurrentUtilities changed a fieldname from max to limit in 2.3.0
const max_or_limit = :max in fieldnames(ConcurrentUtilities.Pool) ? (:max) : (:limit)

# test we can adjust default_connection_limit
for x in (10, 12)
    HTTP.set_default_connection_limit!(x)
    @test getfield(HTTP.Connections.TCP_POOL[], max_or_limit) == x
    @test getfield(HTTP.Connections.MBEDTLS_POOL[], max_or_limit) == x
    @test getfield(HTTP.Connections.OPENSSL_POOL[], max_or_limit) == x
end

@testset "sslconfig without explicit socket_type_tls #1104" begin
    # this was supported before 8f35185
    @test isok(HTTP.get("https://$httpbin/ip", sslconfig=MbedTLS.SSLConfig(false)))
    # The OpenSSL package doesn't have enough docs, but this is a valid way to initialise an SSLContext.
    @test isok(HTTP.get("https://$httpbin/ip", sslconfig=OpenSSL.SSLContext(OpenSSL.TLSClientMethod())))
    # Incompatible socket_type_tls and sslconfig should throw an error.
    @test_throws ArgumentError HTTP.get("https://$httpbin/ip", sslconfig=MbedTLS.SSLConfig(false), socket_type_tls=OpenSSL.SSLStream)
end

@testset "issue 1172" begin
    # Connections through a proxy need to choose an IOType twice rather than
    # just once.
    # https://github.com/JuliaWeb/HTTP.jl/issues/1172

    # This proxy accepts two requests, ignoring the content of the request and
    # returning 200 each time.
    proxy = listen(IPv4(0), 8082)
    try
        @async begin
            sock = accept(proxy)
            while isopen(sock)
                line = readline(sock)
                @show 1, line
                isempty(line) && break
            end
            write(sock, "HTTP/1.1 200\r\n\r\n")
            # Test that we receive something that looks like a client hello
            # (indicating that we tried to upgrade the connection to TLS)
            line = readline(sock)
            @test startswith(line, "\x16")
        end

        @test_throws HTTP.RequestError HTTP.head("https://$httpbin.com"; proxy="http://localhost:8082", readtimeout=1, retry=false)
    finally
        close(proxy)
        HTTP.Connections.closeall()
    end
end

@testset "@client macro" begin
    @eval module MyClient
        using HTTP
        HTTP.@client () ()
    end
    # Test the `@client` sets the location info to the definition site, i.e. this file.
    meth = @which MyClient.get()
    file = String(meth.file)
    @test file == @__FILE__
end

@testset "Custom HTTP Stack" begin
   @testset "Low-level Request" begin
        wasincluded = Ref(false)
        result = TestRequest.get("https://$httpbin/ip"; httptestlayer=wasincluded)
        @test isok(result)
        @test wasincluded[]
    end
    @testset "Low-level Request" begin
        TestRequest2.get("https://$httpbin/ip")
        # tests included in the layers themselves
    end
end

@testset "Client.jl" for tls in [MbedTLS.SSLContext, OpenSSL.SSLStream]
    @testset "GET, HEAD, POST, PUT, DELETE, PATCH" begin
        @test isok(HTTP.get("https://$httpbin/ip", socket_type_tls=tls))
        @test isok(HTTP.head("https://$httpbin/ip", socket_type_tls=tls))
        @test HTTP.post("https://$httpbin/patch"; status_exception=false, socket_type_tls=tls).status == 405
        @test isok(HTTP.post("https://$httpbin/post", socket_type_tls=tls))
        @test isok(HTTP.put("https://$httpbin/put", socket_type_tls=tls))
        @test isok(HTTP.delete("https://$httpbin/delete", socket_type_tls=tls))
        @test isok(HTTP.patch("https://$httpbin/patch", socket_type_tls=tls))
    end

    @testset "decompress" begin
        r = HTTP.get("https://$httpbin/gzip", socket_type_tls=tls)
        @test isok(r)
        @test isascii(String(r.body))
        r = HTTP.get("https://$httpbin/gzip"; decompress=false, socket_type_tls=tls)
        @test isok(r)
        @test !isascii(String(r.body))
        r = HTTP.get("https://$httpbin/gzip"; decompress=false, socket_type_tls=tls)
        @test isascii(String(HTTP.decode(r, "gzip")))
    end

    @testset "ASync Client Requests" begin
        @test isok(fetch(@async HTTP.get("https://$httpbin/ip", socket_type_tls=tls)))
        @test isok(HTTP.get("https://$httpbin/encoding/utf8", socket_type_tls=tls))
    end

    @testset "Query to URI" begin
        r = HTTP.get(URI(HTTP.URI("https://$httpbin/response-headers"); query=Dict("hey"=>"dude")))
        h = Dict(r.headers)
        @test (haskey(h, "Hey") ? h["Hey"] == "dude" : h["hey"] == "dude")
    end

    @testset "Cookie Requests" begin
        empty!(HTTP.COOKIEJAR)
        url = "https://$httpbin/cookies"
        r = HTTP.get(url, cookies=true, socket_type_tls=tls)
        @test String(r.body) == "{}"
        cookies = HTTP.Cookies.getcookies!(HTTP.COOKIEJAR, URI(url))
        @test isempty(cookies)

        url = "https://$httpbin/cookies/set?hey=sailor&foo=bar"
        r = HTTP.get(url, cookies=true, socket_type_tls=tls)
        @test isok(r)
        cookies = HTTP.Cookies.getcookies!(HTTP.COOKIEJAR, URI(url))
        @test length(cookies) == 2

        url = "https://$httpbin/cookies/delete?hey"
        r = HTTP.get(url, socket_type_tls=tls)
        cookies = HTTP.Cookies.getcookies!(HTTP.COOKIEJAR, URI(url))
        @test length(cookies) == 1
    end

    @testset "Client Streaming Test" begin
        r = HTTP.post("https://$httpbin/post"; body="hey", socket_type_tls=tls)
        @test isok(r)

        # stream, but body is too small to actually stream
        r = HTTP.post("https://$httpbin/post"; body="hey", stream=true, socket_type_tls=tls)
        @test isok(r)

        r = HTTP.get("https://$httpbin/stream/100", socket_type_tls=tls)
        @test isok(r)

        bytes = r.body
        a = [JSON.parse(l) for l in split(chomp(String(bytes)), "\n")]
        totallen = length(bytes) # number of bytes to expect

        io = IOBuffer()
        r = HTTP.get("https://$httpbin/stream/100"; response_stream=io, socket_type_tls=tls)
        seekstart(io)
        @test isok(r)

        b = [JSON.parse(l) for l in eachline(io)]
        @test all(zip(a, b)) do (x, y)
            x["args"] == y["args"] &&
            x["id"] == y["id"] &&
            x["url"] == y["url"] &&
            x["origin"] == y["origin"] &&
            x["headers"]["Content-Length"] == y["headers"]["Content-Length"] &&
            x["headers"]["Host"] == y["headers"]["Host"] &&
            x["headers"]["User-Agent"] == y["headers"]["User-Agent"]
        end

        # pass pre-allocated buffer
        body = zeros(UInt8, 100)
        r = HTTP.get("https://$httpbin/bytes/100"; response_stream=body, socket_type_tls=tls)
        @test body === r.body

        # wrapping pre-allocated buffer in IOBuffer will write to buffer directly
        io = IOBuffer(body; write=true)
        r = HTTP.get("https://$httpbin/bytes/100"; response_stream=io, socket_type_tls=tls)
        @test Base.mightalias(body, r.body.data)

        # if provided buffer is too small, we won't grow it for user
        body = zeros(UInt8, 10)
        @test_throws HTTP.RequestError HTTP.get("https://$httpbin/bytes/100"; response_stream=body, socket_type_tls=tls, retry=false)

        # also won't shrink it if buffer provided is larger than response body
        body = zeros(UInt8, 10)
        r = HTTP.get("https://$httpbin/bytes/5"; response_stream=body, socket_type_tls=tls)
        @test body === r.body
        @test length(body) == 10
        @test HTTP.header(r, "Content-Length") == "5"

        # but if you wrap it in a writable IOBuffer, we will grow it
        io = IOBuffer(body; write=true)
        r = HTTP.get("https://$httpbin/bytes/100"; response_stream=io, socket_type_tls=tls)
        # might be a new Array, resized larger
        body = take!(io)
        @test length(body) == 100

        # and you can reuse it
        seekstart(io)
        r = HTTP.get("https://$httpbin/bytes/100"; response_stream=io, socket_type_tls=tls)
        # `take!` should have given it a new Array
        @test !Base.mightalias(body, r.body.data)
        body = take!(io)
        @test length(body) == 100

        # we respect ptr and size
        body = zeros(UInt8, 100)
        io = IOBuffer(body; write=true, append=true) # size=100, ptr=1
        r = HTTP.get("https://$httpbin/bytes/100"; response_stream=io, socket_type_tls=tls)
        body = take!(io)
        @test length(body) == 200

        body = zeros(UInt8, 100)
        io = IOBuffer(body, write=true, append=false)
        write(io, body) # size=100, ptr=101
        r = HTTP.get("https://$httpbin/bytes/100"; response_stream=io, socket_type_tls=tls)
        body = take!(io)
        @test length(body) == 200

    end

    @testset "Client Body Posting - Vector{UTF8}, String, IOStream, IOBuffer, BufferStream, Dict, NamedTuple" begin
        @test isok(HTTP.post("https://$httpbin/post"; body="hey", socket_type_tls=tls))
        @test isok(HTTP.post("https://$httpbin/post"; body=UInt8['h','e','y'], socket_type_tls=tls))
        io = IOBuffer("hey"); seekstart(io)
        @test isok(HTTP.post("https://$httpbin/post"; body=io, socket_type_tls=tls))
        tmp = tempname()
        open(f->write(f, "hey"), tmp, "w")
        io = open(tmp)
        @test isok(HTTP.post("https://$httpbin/post"; body=io, enablechunked=false, socket_type_tls=tls))
        close(io); rm(tmp)
        f = Base.BufferStream()
        write(f, "hey")
        close(f)
        @test isok(HTTP.post("https://$httpbin/post"; body=f, enablechunked=false, socket_type_tls=tls))
        resp = HTTP.post("https://$httpbin/post"; body=Dict("name" => "value"), socket_type_tls=tls)
        @test isok(resp)
        x = JSON.parse(IOBuffer(resp.body))
        @test x["form"] == Dict("name" => ["value"])
        resp = HTTP.post("https://$httpbin/post"; body=(name="value with spaces",), socket_type_tls=tls)
        @test isok(resp)
        x = JSON.parse(IOBuffer(resp.body))
        @test x["form"] == Dict("name" => ["value with spaces"])
    end

    @testset "Chunksize" begin
        #     https://github.com/JuliaWeb/HTTP.jl/issues/60
        #     Currently $httpbin responds with 411 status and “Length Required”
        #     message to any POST/PUT requests that are sent using chunked encoding
        #     See https://github.com/kennethreitz/httpbin/issues/340#issuecomment-330176449
        @test isok(HTTP.post("https://$httpbin/post"; body="hey", socket_type_tls=tls, #=chunksize=2=#))
        @test isok(HTTP.post("https://$httpbin/post"; body=UInt8['h','e','y'], socket_type_tls=tls, #=chunksize=2=#))
        io = IOBuffer("hey"); seekstart(io)
        @test isok(HTTP.post("https://$httpbin/post"; body=io, socket_type_tls=tls, #=chunksize=2=#))
        tmp = tempname()
        open(f->write(f, "hey"), tmp, "w")
        io = open(tmp)
        @test isok(HTTP.post("https://$httpbin/post"; body=io, socket_type_tls=tls, #=chunksize=2=#))
        close(io); rm(tmp)
        f = Base.BufferStream()
        write(f, "hey")
        close(f)
        @test isok(HTTP.post("https://$httpbin/post"; body=f, socket_type_tls=tls, #=chunksize=2=#))
    end

    @testset "ASync Client Request Body" begin
        f = Base.BufferStream()
        write(f, "hey")
        t = @async HTTP.post("https://$httpbin/post"; body=f, enablechunked=false, socket_type_tls=tls)
        #fetch(f) # fetch for the async call to write it's first data
        write(f, " there ") # as we write to f, it triggers another chunk to be sent in our async request
        write(f, "sailor")
        close(f) # setting eof on f causes the async request to send a final chunk and return the response
        @test isok(fetch(t))
    end

    @testset "Client Redirect Following - $read_method" for read_method in ["GET", "HEAD"]
        @test isok(HTTP.request(read_method, "https://$httpbin/redirect/1", socket_type_tls=tls))
        @test HTTP.request(read_method, "https://$httpbin/redirect/1", redirect=false, socket_type_tls=tls).status == 302
        @test HTTP.request(read_method, "https://$httpbin/redirect/6", socket_type_tls=tls).status == 302 #over max number of redirects
        @test isok(HTTP.request(read_method, "https://$httpbin/relative-redirect/1", socket_type_tls=tls))
        @test isok(HTTP.request(read_method, "https://$httpbin/absolute-redirect/1", socket_type_tls=tls))
        @test isok(HTTP.request(read_method, "https://$httpbin/redirect-to?url=http%3A%2F%2Fgoogle.com", socket_type_tls=tls))
    end

    @testset "Client Basic Auth" begin
        @test isok(HTTP.get("https://user:pwd@$httpbin/basic-auth/user/pwd", socket_type_tls=tls))
        @test isok(HTTP.get("https://user:pwd@$httpbin/hidden-basic-auth/user/pwd", socket_type_tls=tls))
        @test isok(HTTP.get("https://test:%40test@$httpbin/basic-auth/test/%40test", socket_type_tls=tls))
    end

    @testset "Misc" begin
        @test isok(HTTP.post("https://$httpbin/post"; body="√", socket_type_tls=tls))
        r = HTTP.request("GET", "https://$httpbin/ip", socket_type_tls=tls)
        @test isok(r)

        uri = HTTP.URI("https://$httpbin/ip")
        r = HTTP.request("GET", uri, socket_type_tls=tls)
        @test isok(r)
        r = HTTP.get(uri)
        @test isok(r)

        r = HTTP.request("GET", "https://$httpbin/ip", socket_type_tls=tls)
        @test isok(r)

        uri = HTTP.URI("https://$httpbin/ip")
        r = HTTP.request("GET", uri, socket_type_tls=tls)
        @test isok(r)

        r = HTTP.get("https://$httpbin/image/png", socket_type_tls=tls)
        @test isok(r)

        # ensure we can use AbstractString for requests
        r = HTTP.get(SubString("https://$httpbin/ip",1), socket_type_tls=tls)

        # canonicalizeheaders
        @test isok(HTTP.get("https://$httpbin/ip"; canonicalizeheaders=false, socket_type_tls=tls))

        # Ensure HEAD requests stay the same through redirects by default
        r = HTTP.head("https://$httpbin/redirect/1")
        @test r.request.method == "HEAD"
        @test iszero(length(r.body))
        # But if explicitly requested, GET can be used instead
        r = HTTP.head("https://$httpbin/redirect/1"; redirect_method="GET")
        @test r.request.method == "GET"
        @test length(r.body) > 0
    end
end

@testset "Incomplete response with known content length" begin
    server = nothing
    try
        server = HTTP.listen!("0.0.0.0", 8080) do http
            HTTP.setstatus(http, 200)
            HTTP.setheader(http, "Content-Length" => "64") # Promise 64 bytes...
            HTTP.startwrite(http)
            HTTP.write(http, rand(UInt8, 63)) # ...but only send 63 bytes.
            # Close the stream so that eof(stream) is true and the client isn't
            # waiting forever for the last byte.
            HTTP.close(http.stream)
        end

        err = try
            HTTP.get("http://localhost:8080"; retry=false)
        catch err
            err
        end
        @test err isa HTTP.RequestError
        @test err.error isa EOFError
    finally
        # Shutdown
        @try Base.IOError close(server)
        HTTP.Connections.closeall()
    end
end

@testset "HTTP.open accepts method::Symbol" begin
    @test isok(HTTP.open(x -> x, :GET, "http://$httpbin/ip"))
end

@testset "readtimeout" begin
    @test_throws HTTP.TimeoutError begin
        HTTP.get("http://$httpbin/delay/5"; readtimeout=1, retry=false)
    end
    HTTP.get("http://$httpbin/delay/1"; readtimeout=2, retry=false)
end

@testset "connect_timeout does not include the time needed to acquire a connection from the pool" begin
    connection_limit = getfield(HTTP.Connections.TCP_POOL[], max_or_limit)
    try
        dummy_conn = HTTP.Connection(Sockets.TCPSocket())
        HTTP.set_default_connection_limit!(1)
        @assert getfield(HTTP.Connections.TCP_POOL[], max_or_limit) == 1
        # drain the pool
        acquire(()->dummy_conn, HTTP.Connections.TCP_POOL[], HTTP.Connections.connectionkey(dummy_conn))
        # Put it back in 10 seconds
        Timer(t->HTTP.Connections.releaseconnection(dummy_conn, false), 10; interval=0)
        # If we count the time it takes to acquire the connection from the pool, we'll get a timeout error.
        HTTP.get("https://$httpbin/get"; connect_timeout=5, retry=false, socket_type_tls=Sockets.TCPSocket)
        @test true # if we get here, we didn't timeout
    finally
        HTTP.set_default_connection_limit!(connection_limit)
    end
end

@testset "Retry all resolved IP addresses" begin
    # See issue https://github.com/JuliaWeb/HTTP.jl/issues/672
    # Bit tricky to test, but can at least be tested if localhost
    # resolves to both IPv4 and IPv6 by listening to the respective
    # interface
    alladdrs = getalladdrinfo("localhost")
    if ip"127.0.0.1" in alladdrs && ip"::1" in alladdrs
        for interface in (IPv4(0), IPv6(0))
            server = nothing
            try
                server = HTTP.listen!(string(interface), 8080) do http
                    HTTP.setstatus(http, 200)
                    HTTP.startwrite(http)
                    HTTP.write(http, "hello, world")
                end
                resp = HTTP.get("http://localhost:8080")
                @test isok(resp)
                @test String(resp.body) == "hello, world"
            finally
                @try Base.IOError close(server)
                HTTP.Connections.closeall()
            end
        end
    end
end

@testset "Sockets.get(sock|peer)name(::HTTP.Stream)" begin
    server = nothing
    try
        server = HTTP.listen!("0.0.0.0", 8080) do http
            sock = Sockets.getsockname(http)
            peer = Sockets.getpeername(http)
            str = sprint() do io
                print(io, sock[1], ":", sock[2], " - ", peer[1], ":", peer[2])
            end
            HTTP.setstatus(http, 200)
            HTTP.setheader(http, "Content-Length" => string(sizeof(str)))
            HTTP.startwrite(http)
            HTTP.write(http, str)
        end

        # Tests for Stream{TCPSocket}
        HTTP.open("GET", "http://localhost:8080") do http
            # Test server peer/sock
            reg = r"^127\.0\.0\.1:8080 - 127\.0\.0\.1:(\d+)$"
            m = match(reg, read(http, String))
            @test m !== nothing
            server_peerport = parse(Int, m[1])
            # Test client peer/sock
            sock = Sockets.getsockname(http)
            @test sock[1] == ip"127.0.0.1"
            @test sock[2] == server_peerport
            peer = Sockets.getpeername(http)
            @test peer[1] == ip"127.0.0.1"
            @test peer[2] == 8080
        end
    finally
        @try Base.IOError close(server)
        HTTP.Connections.closeall()
    end

    # Tests for Stream{SSLContext}
    HTTP.open("GET", "https://julialang.org") do http
        sock = Sockets.getsockname(http)
        if VERSION >= v"1.2.0"
            @test sock[1] in Sockets.getipaddrs()
        end
        peer = Sockets.getpeername(http)
        @test peer[1] in Sockets.getalladdrinfo("julialang.org")
        @test peer[2] == 443
    end
end

@testset "input verification of bad URLs" begin
    # HTTP.jl#527, HTTP.jl#545
    url = "julialang.org"
    @test_throws ArgumentError("missing or unsupported scheme in URL (expected http(s) or ws(s)): $(url)") HTTP.get(url)
    url = "ptth://julialang.org"
    @test_throws ArgumentError("missing or unsupported scheme in URL (expected http(s) or ws(s)): $(url)") HTTP.get(url)
    url = "http:julialang.org"
    @test_throws ArgumentError("missing host in URL: $(url)") HTTP.get(url)
end

@testset "Implicit request headers" begin
    server = nothing
    try
        server = HTTP.listen!("0.0.0.0", 8080) do http
            data = Dict{String,String}(http.message.headers)
            HTTP.setstatus(http, 200)
            HTTP.startwrite(http)
            HTTP.write(http, sprint(JSON.print, data))
        end
        old_user_agent = HTTP.HeadersRequest.USER_AGENT[]
        default_user_agent = "HTTP.jl/$VERSION"
        # Default values
        HTTP.setuseragent!(default_user_agent)
        d = JSON.parse(IOBuffer(HTTP.get("http://localhost:8080").body))
        @test d["Host"] == "localhost:8080"
        @test d["Accept"] == "*/*"
        @test d["User-Agent"] == default_user_agent
        # Overwriting behavior
        headers = ["Host" => "http.jl", "Accept" => "application/json"]
        HTTP.setuseragent!("HTTP.jl test")
        d = JSON.parse(IOBuffer(HTTP.get("http://localhost:8080", headers).body))
        @test d["Host"] == "http.jl"
        @test d["Accept"] == "application/json"
        @test d["User-Agent"] == "HTTP.jl test"
        # No User-Agent
        HTTP.setuseragent!(nothing)
        d = JSON.parse(IOBuffer(HTTP.get("http://localhost:8080").body))
        @test !haskey(d, "User-Agent")

        HTTP.setuseragent!(old_user_agent)
    finally
        @try Base.IOError close(server)
        HTTP.Connections.closeall()
    end
end

import NetworkOptions, MbedTLS
@testset "NetworkOptions for host verification" begin
    # Set up server with self-signed cert
    server = nothing
    try
        cert, key = joinpath.(dirname(pathof(HTTP)), "../test", "resources", ("cert.pem", "key.pem"))
        sslconfig = MbedTLS.SSLConfig(cert, key)
        server = HTTP.listen!("0.0.0.0", 8443; sslconfig=sslconfig) do http
            HTTP.setstatus(http, 200)
            HTTP.startwrite(http)
            HTTP.write(http, "hello, world")
        end
        url = "https://localhost:8443"
        env = ["JULIA_NO_VERIFY_HOSTS" => nothing, "JULIA_SSL_NO_VERIFY_HOSTS" => nothing, "JULIA_ALWAYS_VERIFY_HOSTS" => nothing]
        withenv(env...) do
            @test NetworkOptions.verify_host(url)
            @test NetworkOptions.verify_host(url, "SSL")
            @test_throws HTTP.ConnectError HTTP.get(url; retries=1)
            @test_throws HTTP.ConnectError HTTP.get(url; require_ssl_verification=true, retries=1)
            @test isok(HTTP.get(url; require_ssl_verification=false))
        end
        withenv(env..., "JULIA_NO_VERIFY_HOSTS" => "localhost") do
            @test !NetworkOptions.verify_host(url)
            @test !NetworkOptions.verify_host(url, "SSL")
            @test isok(HTTP.get(url))
            @test_throws HTTP.ConnectError HTTP.get(url; require_ssl_verification=true, retries=1)
            @test isok(HTTP.get(url; require_ssl_verification=false))
        end
        withenv(env..., "JULIA_SSL_NO_VERIFY_HOSTS" => "localhost") do
            @test NetworkOptions.verify_host(url)
            @test !NetworkOptions.verify_host(url, "SSL")
            @test isok(HTTP.get(url))
            @test_throws HTTP.ConnectError HTTP.get(url; require_ssl_verification=true, retries=1)
            @test isok(HTTP.get(url; require_ssl_verification=false))
        end
    finally
        @try Base.IOError close(server)
        HTTP.Connections.closeall()
    end
end

@testset "Public entry point of HTTP.request and friends (e.g. issue #463)" begin
    headers = Dict("User-Agent" => "HTTP.jl")
    query = Dict("hello" => "world")
    body = UInt8[1, 2, 3]
    stack = HTTP.stack()
    function test(r, m)
        @test isok(r)
        d = JSON.parse(IOBuffer(HTTP.payload(r)))
        @test d["headers"]["User-Agent"] == ["HTTP.jl"]
        @test d["data"] == "\x01\x02\x03"
        @test endswith(d["url"], "?hello=world")
    end
    for uri in ("https://$httpbin/anything", HTTP.URI("https://$httpbin/anything"))
        # HTTP.request
        test(HTTP.request("GET", uri; headers=headers, body=body, query=query), "GET")
        test(HTTP.request("GET", uri, headers; body=body, query=query), "GET")
        test(HTTP.request("GET", uri, headers, body; query=query), "GET")
        !isa(uri, HTTP.URI) && test(HTTP.request(stack, "GET", uri; headers=headers, body=body, query=query), "GET")
        test(HTTP.request(stack, "GET", uri, headers; body=body, query=query), "GET")
        test(HTTP.request(stack, "GET", uri, headers, body; query=query), "GET")
        # HTTP.get
        test(HTTP.get(uri; headers=headers, body=body, query=query), "GET")
        test(HTTP.get(uri, headers; body=body, query=query), "GET")
        test(HTTP.get(uri, headers, body; query=query), "GET")
        # HTTP.put
        test(HTTP.put(uri; headers=headers, body=body, query=query), "PUT")
        test(HTTP.put(uri, headers; body=body, query=query), "PUT")
        test(HTTP.put(uri, headers, body; query=query), "PUT")
        # HTTP.post
        test(HTTP.post(uri; headers=headers, body=body, query=query), "POST")
        test(HTTP.post(uri, headers; body=body, query=query), "POST")
        test(HTTP.post(uri, headers, body; query=query), "POST")
        # HTTP.patch
        test(HTTP.patch(uri; headers=headers, body=body, query=query), "PATCH")
        test(HTTP.patch(uri, headers; body=body, query=query), "PATCH")
        test(HTTP.patch(uri, headers, body; query=query), "PATCH")
        # HTTP.delete
        test(HTTP.delete(uri; headers=headers, body=body, query=query), "DELETE")
        test(HTTP.delete(uri, headers; body=body, query=query), "DELETE")
        test(HTTP.delete(uri, headers, body; query=query), "DELETE")
    end
end

@testset "HTTP CONNECT Proxy" begin
    @testset "Host header" begin
        # Stores the http request passed by the client
        req = String[]

        # Trivial implementation of a proxy server
        # We are only interested in the request passed in by the client
        # Returns 400 after reading the http request into req
        proxy = listen(IPv4(0), 8082)
        try
            @async begin
                sock = accept(proxy)
                while isopen(sock)
                    line = readline(sock)
                    isempty(line) && break

                    push!(req, line)
                end
                write(sock, "HTTP/1.1 400 Bad Request\r\n\r\n")
            end

            # Make the HTTP request
            HTTP.get("https://example.com"; proxy="http://localhost:8082", retry=false, status_exception=false)

            # Test if the host header exist in the request
            @test "Host: example.com:443" in req
        finally
            close(proxy)
            HTTP.Connections.closeall()
        end
    end
end

@testset "Retry with request/response body streams" begin
    shouldfail = Ref(true)
    status = Ref(200)
    server = HTTP.listen!(8080) do http
        @assert !eof(http)
        msg = String(read(http))
        if shouldfail[]
            shouldfail[] = false
            error("500 unexpected error")
        end
        HTTP.setstatus(http, status[])
        if status[] != 200
            HTTP.startwrite(http)
            HTTP.write(http, "$(status[]) unexpected error")
            status[] = 200
        end
        HTTP.startwrite(http)
        HTTP.write(http, msg)
    end
    try
        req_body = IOBuffer("hey there sailor")
        seekstart(req_body)
        res_body = IOBuffer()
        resp = HTTP.get("http://localhost:8080/retry"; body=req_body, response_stream=res_body)
        @test isok(resp)
        @test String(take!(res_body)) == "hey there sailor"
        # ensure if retry=false, that we write the response body immediately
        shouldfail[] = true
        seekstart(req_body)
        resp = HTTP.get("http://localhost:8080/retry"; body=req_body, response_stream=res_body, retry=false, status_exception=false)
        @test resp.status == 500
        # even if StatusError, we should still get the right response body
        shouldfail[] = true
        seekstart(req_body)
        try
            resp = HTTP.get("http://localhost:8080/retry"; body=req_body, response_stream=res_body, retry=false, forcenew=true)
        catch e
            @test e isa HTTP.StatusError
            @test e.status == 500
        end
        # don't throw a 500, but set status to status we don't retry by default
        shouldfail[] = false
        status[] = 404
        seekstart(req_body)
        checked = Ref(0)
        @test !HTTP.retryable(404)
        check = (s, ex, req, resp, resp_body) -> begin
            checked[] += 1
            str = String(resp_body)
            if str != "404 unexpected error" || resp.status != 404
                @error "unexpected response body" str
                return false
            end
            @test str == "404 unexpected error"
            return resp.status == 404
        end
        resp = HTTP.get("http://localhost:8080/retry"; body=req_body, response_stream=res_body, retry_check=check)
        @test isok(resp)
        @test String(take!(res_body)) == "hey there sailor"
        @test checked[] >= 1
    finally
        close(server)
        HTTP.Connections.closeall()
    end
end

@testset "Don't retry on internal exceptions" begin
    kws = (retry_delays = [10, 20, 30], retries=3) # ~ 60 secs
    max_wait = 30

    function test_finish_within(f, secs)
        timedout = Ref(false)
        t = Timer((t)->(timedout[] = true), secs)
        try
            f()
        finally
            close(t)
        end
        @test !timedout[]
    end

    expected = ErrorException("request")
    test_finish_within(max_wait) do
        @test_throws expected ErrorRequest.get("https://$httpbin/ip"; request_exception=expected, kws...)
    end
    expected = ArgumentError("request")
    test_finish_within(max_wait) do
        @test_throws expected ErrorRequest.get("https://$httpbin/ip"; request_exception=expected, kws...)
    end

    test_finish_within(max_wait) do
        expected = ErrorException("stream")
        e = try
            ErrorRequest.get("https://$httpbin/ip"; stream_exception=expected, kws...)
        catch e
            e
        end
        @assert e isa HTTP.RequestError
        @test e.error == expected
    end

    test_finish_within(max_wait) do
        expected = ArgumentError("stream")
        e = try
            ErrorRequest.get("https://$httpbin/ip"; stream_exception=expected, kws...)
        catch e
            e
        end
        @assert e isa HTTP.RequestError
        @test e.error == expected
    end
end

@testset "Retry with ConnectError" begin
    mktemp() do path, io
        redirect_stdout(io) do
            redirect_stderr(io) do
                try
                    HTTP.request("GET", "http://n0nexist3nthost/"; verbose=true)
                catch
                    # ignore
                end
            end
        end
        close(io)
        logs = read(path, String)

        if occursin("EAI_NODATA", logs)
            @test occursin("No Retry: GET / HTTP/1.1", logs)
        elseif occursin("EAI_EAGAIN", logs)
            # interestingly some environments also result in EAGAIN in these cases
            @test occursin("Retry HTTP.Exceptions.ConnectError", logs)
        end
    end

    # isrecoverable tests
    @test !HTTP.RetryRequest.isrecoverable(nothing)

    @test !HTTP.RetryRequest.isrecoverable(ErrorException(""))
    @test !HTTP.RetryRequest.isrecoverable(ArgumentError("yikes"))
    @test HTTP.RetryRequest.isrecoverable(ArgumentError("stream is closed or unusable"))

    @test HTTP.RetryRequest.isrecoverable(HTTP.RequestError(nothing, ArgumentError("stream is closed or unusable")))
    @test !HTTP.RetryRequest.isrecoverable(HTTP.RequestError(nothing, ArgumentError("yikes")))

    @test HTTP.RetryRequest.isrecoverable(CapturedException(ArgumentError("stream is closed or unusable"), Any[]))

    recoverable_dns_error = Sockets.DNSError("localhost", Base.UV_EAI_AGAIN)
    unrecoverable_dns_error = Sockets.DNSError("localhost", Base.UV_EAI_NONAME)
    @test HTTP.RetryRequest.isrecoverable(recoverable_dns_error)
    @test !HTTP.RetryRequest.isrecoverable(unrecoverable_dns_error)
    @test HTTP.RetryRequest.isrecoverable(HTTP.Exceptions.ConnectError("http://localhost", recoverable_dns_error))
    @test !HTTP.RetryRequest.isrecoverable(HTTP.Exceptions.ConnectError("http://localhost", unrecoverable_dns_error))
    @test HTTP.RetryRequest.isrecoverable(CompositeException([
        recoverable_dns_error,
        HTTP.Exceptions.ConnectError("http://localhost", recoverable_dns_error),
    ]))
    @test HTTP.RetryRequest.isrecoverable(CompositeException([
        recoverable_dns_error,
        HTTP.Exceptions.ConnectError("http://localhost", recoverable_dns_error),
        CompositeException([recoverable_dns_error])
    ]))
    @test !HTTP.RetryRequest.isrecoverable(CompositeException([
        recoverable_dns_error,
        HTTP.Exceptions.ConnectError("http://localhost", unrecoverable_dns_error),
    ]))
    @test !HTTP.RetryRequest.isrecoverable(CompositeException([
        recoverable_dns_error,
        HTTP.Exceptions.ConnectError("http://localhost", recoverable_dns_error),
        CompositeException([unrecoverable_dns_error])
    ]))
end

findnewline(bytes) = something(findfirst(==(UInt8('\n')), bytes), 0)

@testset "IOExtras.readuntil on Stream" begin
    HTTP.open(:GET, "https://$httpbin/stream/5") do io
        while !eof(io)
            bytes = IOExtras.readuntil(io, findnewline)
            isempty(bytes) && break
            x = JSON.parse(IOBuffer(bytes))
        end
    end
end

@testset "CA_BUNDEL env" begin
    resp = withenv("HTTP_CA_BUNDLE" => HTTP.MbedTLS.MozillaCACerts_jll.cacert) do
        HTTP.get("https://$httpbin/ip"; socket_type_tls=SSLStream)
    end
    @test isok(resp)
    resp = withenv("HTTP_CA_BUNDLE" => HTTP.MbedTLS.MozillaCACerts_jll.cacert) do
        HTTP.get("https://$httpbin/ip")
    end
    @test isok(resp)
end

end # module
