diff --git a/test/connection_pool.jl b/test/connection_pool.jl new file mode 100644 index 00000000..25c00400 --- /dev/null +++ b/test/connection_pool.jl @@ -0,0 +1,263 @@ +module TestConnectionPool + +using Test, HTTP, Sockets +using HTTP: Connections + +# Use the same httpbin constant as runtests.jl +const httpbin = get(ENV, "JULIA_TEST_HTTPBINGO_SERVER", "httpbingo.julialang.org") + +# Helper function for thread-safe modifications +function with_lock(f, lock::ReentrantLock) + Base.lock(lock) + try + return f() + finally + Base.unlock(lock) + end +end + +@testset "Connection Pool Management" begin + @testset "Connection Reuse (RFC 7230 Section 6.3)" begin + # Test proper connection reuse behavior according to HTTP/1.1 standards + n_requests = 100 + results = Vector{Bool}(undef, n_requests) + + # First, make a single request to ensure the pool is initialized + r = HTTP.get("https://$httpbin/get") + @test r.status == 200 + + # Now make concurrent requests - they should reuse the connection + @sync begin + for i in 1:n_requests + @async begin + try + r = HTTP.get("https://$httpbin/get") + results[i] = r.status == 200 + catch e + @error "Request failed" exception=e + results[i] = false + end + end + end + end + + @test all(results) # All requests must complete successfully + + # Test connection lifetime + # Make a request, wait, then make another - should reuse if within keep-alive + r1 = HTTP.get("https://$httpbin/get") + @test r1.status == 200 + sleep(1) # Wait but not too long + r2 = HTTP.get("https://$httpbin/get") + @test r2.status == 200 + end + + @testset "TLS Security Requirements (RFC 5280)" begin + # Test proper certificate validation behavior + + # MUST reject expired certificates + @test_throws HTTP.ConnectError HTTP.get("https://expired.badssl.com/"; retry=false) + + # MUST reject self-signed certificates + @test_throws HTTP.ConnectError HTTP.get("https://self-signed.badssl.com/"; retry=false) + + # MUST reject wrong host certificates + @test_throws HTTP.ConnectError HTTP.get("https://wrong.host.badssl.com/"; retry=false) + + # SHOULD allow bypass only with explicit opt-in + response = HTTP.get("https://expired.badssl.com/"; require_ssl_verification=false) + @test response.status == 200 + end + + @testset "Connection Cleanup (RFC 7230 Section 6.5)" begin + # Test proper connection cleanup behavior + port = 8088 + cleanup_lock = ReentrantLock() + active_connections = Set{TCPSocket}() + + server = HTTP.listen!(port) do http + # Track active connections + with_lock(cleanup_lock) do + push!(active_connections, http.stream.io) + end + + try + scenario = rand() + if scenario < 0.3 + # Test abrupt connection termination + close(http.stream) + elseif scenario < 0.6 + # Test response timeout + sleep(2) + HTTP.setstatus(http, 200) + HTTP.startwrite(http) + else + # Test server error with proper closure + HTTP.setstatus(http, 500) + HTTP.startwrite(http) + write(http, "Internal Server Error") + end + catch e + if !(e isa Base.IOError) + @error "Server error" exception=e + end + finally + # Remove connection from tracking + with_lock(cleanup_lock) do + delete!(active_connections, http.stream.io) + end + end + end + + try + # Make requests that will trigger different error scenarios + for _ in 1:20 + try + HTTP.get("http://localhost:$port"; readtimeout=1, retry=false) + catch e + @test e isa Union{HTTP.RequestError, HTTP.StatusError, HTTP.TimeoutError} + end + end + + # Wait for cleanup with timeout and verification + cleanup_timeout = 5.0 # 5 second timeout + start_time = time() + while !isempty(active_connections) && (time() - start_time) < cleanup_timeout + sleep(0.5) # Check every 500ms + end + + # Verify all connections were properly cleaned up + @test isempty(active_connections) + + finally + close(server) + end + end + + @testset "Pool Resource Management" begin + old_limit = HTTP.Connections.TCP_POOL[].limit + + try + # Set a small pool size for testing + pool_limit = 3 + HTTP.set_default_connection_limit!(pool_limit) + + # Create more connections than the pool limit + n_requests = pool_limit * 2 + results = Vector{Bool}(undef, n_requests) + + @sync begin + for i in 1:n_requests + @async begin + try + r = HTTP.get("https://$httpbin/get") + results[i] = r.status == 200 + catch e + @error "Request failed" exception=e + results[i] = false + end + end + end + end + + # Verify successful requests + @test all(results) + + # Verify pool limit + @test HTTP.Connections.TCP_POOL[].limit == pool_limit + + # Test connection reuse + r = HTTP.get("https://$httpbin/get") + @test r.status == 200 + + finally + # Restore original settings + HTTP.set_default_connection_limit!(old_limit) + end + end + + @testset "Request Queueing and Fairness" begin + old_limit = HTTP.Connections.TCP_POOL[].limit + port = 8089 + server = nothing + try + # Test with minimal pool size to force queueing + HTTP.set_default_connection_limit!(1) + + server = HTTP.listen!(port) do http + try + sleep(1) # Simulate processing time + HTTP.setstatus(http, 200) + HTTP.startwrite(http) + write(http, "OK") + catch e + if !(e isa Base.IOError) + @error "Server error" exception=e + end + end + end + + # Track request timing for fairness analysis + times = Float64[] + time_lock = ReentrantLock() + + # Launch concurrent requests + @sync begin + for _ in 1:3 + @async begin + start_time = time() + try + r = HTTP.get("http://localhost:$port") + @test r.status == 200 + with_lock(() -> push!(times, time() - start_time), time_lock) + catch e + @error "Request failed" exception=e + @test false + end + end + end + end + + # Verify fair queueing + sort!(times) + @test length(times) == 3 + # Requests should be processed sequentially with ~1s gaps + @test times[2] - times[1] ≈ 1.0 atol=0.5 + @test times[3] - times[2] ≈ 1.0 atol=0.5 + finally + HTTP.set_default_connection_limit!(old_limit) + if server !== nothing + close(server) + end + end + end + + @testset "Error Handling Requirements" begin + # Test proper error handling behavior + + # MUST handle invalid host gracefully + @test_throws HTTP.ConnectError HTTP.get("http://nonexistent.example.com"; retry=false) + + # MUST handle invalid ports gracefully + @test_throws HTTP.ConnectError HTTP.get("http://localhost:99999"; retry=false) + + # MUST handle malformed URLs properly + @test_throws ArgumentError HTTP.get("http://[malformed"; retry=false) + + # MUST handle connection refused gracefully + @test_throws HTTP.ConnectError HTTP.get("http://localhost:1"; retry=false) + + # MUST handle connection reset gracefully + port = 8090 + server = HTTP.listen!(port) do http + close(http.stream) # Immediately reset connection + end + try + @test_throws Union{HTTP.RequestError, HTTP.ConnectError} HTTP.get("http://localhost:$port") + finally + close(server) + end + end +end + +end # module diff --git a/test/parser.jl b/test/parser.jl index dbb466c6..3504bcd7 100644 --- a/test/parser.jl +++ b/test/parser.jl @@ -1,3 +1,5 @@ +module TestParser + using Test, HTTP, HTTP.Messages, HTTP.Parsers, HTTP.Strings include(joinpath(dirname(pathof(HTTP)), "../test/resources/HTTPMessages.jl")) using .HTTPMessages @@ -7,451 +9,66 @@ import Base.== const strict = false ==(a::Request,b::Request) = (a.method == b.method) && - (a.version == b.version) && - (a.headers == b.headers) && - (a.body == b.body) - -macro errmsg(expr) - esc(quote - try - $expr - catch e - sprint(show, e) - end - end) -end + (a.version == b.version) && + (a.headers == b.headers) && + (a.body == b.body) @testset "HTTP.parser" begin - @testset "parse - Strings" begin - @testset "Requests - $request" for request in requests - r = parse(Request, request.raw) - - if r.method == "CONNECT" - host, port = split(r.target, ":") - @test host == request.host - @test port == request.port - else - if r.target == "*" - @test r.target == request.request_path - else - target = parse(HTTP.URI, r.target) - @test target.query == request.query_string - @test target.fragment == request.fragment - @test target.path == request.request_path - @test target.host == request.host - @test target.userinfo == request.userinfo - @test target.port in (request.port, "80", "443") - @test string(target) == request.request_url - end - end - - r_headers = [tocameldash(n) => String(v) for (n,v) in r.headers] - - @test r.version.major == request.http_major - @test r.version.minor == request.http_minor - @test r.method == string(request.method) - @test length(r.headers) == request.num_headers - @test r_headers == request.headers - @test String(r.body) == request.body - - @test_broken HTTP.http_should_keep_alive(HTTP.DEFAULT_PARSER) == request.should_keep_alive - @test_broken String(collect(upgrade[])) == request.upgrade - end - - @testset "Request - Headers" begin - reqstr = "GET http://www.techcrunch.com/ HTTP/1.1\r\n" * - "Host: www.techcrunch.com\r\n" * - "User-Agent: Fake\r\n" * - "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\r\n" * - "Accept-Language: en-us,en;q=0.5\r\n" * - "Accept-Encoding: gzip,deflate\r\n" * - "Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7\r\n" * - "Keep-Alive: 300\r\n" * - "Content-Length: 7\r\n" * - "Proxy-Connection: keep-alive\r\n\r\n1234567" - req = Request("GET", "http://www.techcrunch.com/", ["Host"=>"www.techcrunch.com","User-Agent"=>"Fake","Accept"=>"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8","Accept-Language"=>"en-us,en;q=0.5","Accept-Encoding"=>"gzip,deflate","Accept-Charset"=>"ISO-8859-1,utf-8;q=0.7,*;q=0.7","Keep-Alive"=>"300","Content-Length"=>"7","Proxy-Connection"=>"keep-alive"]) - req.body = HTTP.bytes("1234567") - @test parse(Request,reqstr).headers == req.headers - @test parse(Request,reqstr) == req - end - - @testset "Request - Hostname - URL" begin - reqstr = "GET / HTTP/1.1\r\n" * - "Host: foo.com\r\n\r\n" - req = Request("GET", "/", ["Host"=>"foo.com"]) - @test parse(Request, reqstr) == req - end - - @testset "Request - Hostname - Path" begin - reqstr = "GET //user@host/is/actually/a/path/ HTTP/1.1\r\n" * - "Host: test\r\n\r\n" - req = Request("GET", "//user@host/is/actually/a/path/", - ["Host"=>"test"]) - @test parse(Request, reqstr) == req - end - - @testset "Request - Hostname - Path - ParseError" begin - reqstr = "GET HTTP/1.1\r\n" * - "Host: test\r\n\r\n" - @test_throws HTTP.ParseError parse(Request, reqstr) - end - - @testset "Request - Hostname - URL - ParseError" begin - reqstr = "GET ../../../../etc/passwd HTTP/1.1\r\n" * - "Host: test\r\n\r\n" - @test_throws HTTP.ParseError HTTP.URI(parse(Request, reqstr).target) - end - - @testset "Request - HTTP - Bytes" begin - reqstr = "POST / HTTP/1.1\r\n" * - "Host: foo.com\r\n" * - "Transfer-Encoding: chunked\r\n\r\n" * - "3\r\nfoo\r\n" * - "3\r\nbar\r\n" * - "0\r\n" * - "Trailer-Key: Trailer-Value\r\n" * - "\r\n" - req = Request("POST", "/", - ["Host"=>"foo.com", "Transfer-Encoding"=>"chunked", "Trailer-Key"=>"Trailer-Value"]) - req.body = HTTP.bytes("foobar") - @test parse(Request, reqstr) == req - end - - @test_skip @testset "Request - HTTP - ParseError" begin - reqstr = "POST / HTTP/1.1\r\n" * - "Host: foo.com\r\n" * - "Transfer-Encoding: chunked\r\n" * - "Content-Length: 9999\r\n\r\n" * # to be removed. - "3\r\nfoo\r\n" * - "3\r\nbar\r\n" * - "0\r\n" * - "\r\n" - - @test_throws HTTP.ParseError parse(Request, reqstr) - end - - @testset "Request - URL" begin - reqstr = "CONNECT www.google.com:443 HTTP/1.1\r\n\r\n" - req = Request("CONNECT", "www.google.com:443") - @test parse(Request, reqstr) == req - end - - @testset "Request - Localhost" begin - reqstr = "CONNECT 127.0.0.1:6060 HTTP/1.1\r\n\r\n" - req = Request("CONNECT", "127.0.0.1:6060") - @test parse(Request, reqstr) == req - end - - @testset "Request - RPC" begin - reqstr = "CONNECT /_goRPC_ HTTP/1.1\r\n\r\n" - req = HTTP.Request("CONNECT", "/_goRPC_") - @test parse(Request, reqstr) == req - end - - @testset "Request - NOTIFY" begin - reqstr = "NOTIFY * HTTP/1.1\r\nServer: foo\r\n\r\n" - req = Request("NOTIFY", "*", ["Server"=>"foo"]) - @test parse(Request, reqstr) == req - end - - @testset "Request - OPTIONS" begin - reqstr = "OPTIONS * HTTP/1.1\r\nServer: foo\r\n\r\n" - req = Request("OPTIONS", "*", ["Server"=>"foo"]) - @test parse(Request, reqstr) == req - end - - @testset "Request - GET" begin - reqstr = "GET / HTTP/1.1\r\nHost: issue8261.com\r\nConnection: close\r\n\r\n" - req = Request("GET", "/", ["Host"=>"issue8261.com", "Connection"=>"close"]) - @test parse(Request, reqstr) == req - end - - @testset "Request - HEAD" begin - reqstr = "HEAD / HTTP/1.1\r\nHost: issue8261.com\r\nConnection: close\r\nContent-Length: 0\r\n\r\n" - req = Request("HEAD", "/", ["Host"=>"issue8261.com", "Connection"=>"close", "Content-Length"=>"0"]) - @test parse(Request, reqstr) == req - end - - @testset "Request - POST" begin - reqstr = "POST /cgi-bin/process.cgi HTTP/1.1\r\n" * - "User-Agent: Mozilla/4.0 (compatible; MSIE5.01; Windows NT)\r\n" * - "Host: www.tutorialspoint.com\r\n" * - "Content-Type: text/xml; charset=utf-8\r\n" * - "Content-Length: 19\r\n" * - "Accept-Language: en-us\r\n" * - "Accept-Encoding: gzip, deflate\r\n" * - "Connection: Keep-Alive\r\n\r\n" * - "first=Zara&last=Ali\r\n\r\n" - req = Request("POST", "/cgi-bin/process.cgi", - ["User-Agent"=>"Mozilla/4.0 (compatible; MSIE5.01; Windows NT)", - "Host"=>"www.tutorialspoint.com", - "Content-Type"=>"text/xml; charset=utf-8", - "Content-Length"=>"19", - "Accept-Language"=>"en-us", - "Accept-Encoding"=>"gzip, deflate", - "Connection"=>"Keep-Alive"]) - req.body = HTTP.bytes("first=Zara&last=Ali") - @test parse(Request, reqstr) == req - end - - @testset "Request - Pair{}" begin - r = parse(Request,"GET / HTTP/1.1\r\n" * "Test: Düsseldorf\r\n\r\n") - @test Pair{String,String}[r.headers...] == ["Test" => "Düsseldorf"] - end - - @testset "Request - Methods - 1" begin - for m in ["GET", "PUT", "M-SEARCH", "FOOMETHOD"] - r = parse(Request,"$m / HTTP/1.1\r\n\r\n") - @test r.method == string(m) - end - end - - @testset "Request - Methods - 2" begin - for m in ("ASDF","C******","COLA","GEM","GETA","M****","MKCOLA","PROPPATCHA","PUN","PX","SA") - @test parse(Request,"$m / HTTP/1.1\r\n\r\n").method == m - end - end - - @testset "Request - HTTPS" begin - reqstr = "GET / HTTP/1.1\r\n" * - "X-SSL-FoooBarr: -----BEGIN CERTIFICATE-----\r\n" * - "\tMIIFbTCCBFWgAwIBAgICH4cwDQYJKoZIhvcNAQEFBQAwcDELMAkGA1UEBhMCVUsx\r\n" * - "\tETAPBgNVBAoTCGVTY2llbmNlMRIwEAYDVQQLEwlBdXRob3JpdHkxCzAJBgNVBAMT\r\n" * - "\tAkNBMS0wKwYJKoZIhvcNAQkBFh5jYS1vcGVyYXRvckBncmlkLXN1cHBvcnQuYWMu\r\n" * - "\tdWswHhcNMDYwNzI3MTQxMzI4WhcNMDcwNzI3MTQxMzI4WjBbMQswCQYDVQQGEwJV\r\n" * - "\tSzERMA8GA1UEChMIZVNjaWVuY2UxEzARBgNVBAsTCk1hbmNoZXN0ZXIxCzAJBgNV\r\n" * - "\tBAcTmrsogriqMWLAk1DMRcwFQYDVQQDEw5taWNoYWVsIHBhcmQYJKoZIhvcNAQEB\r\n" * - "\tBQADggEPADCCAQoCggEBANPEQBgl1IaKdSS1TbhF3hEXSl72G9J+WC/1R64fAcEF\r\n" * - "\tW51rEyFYiIeZGx/BVzwXbeBoNUK41OK65sxGuflMo5gLflbwJtHBRIEKAfVVp3YR\r\n" * - "\tgW7cMA/s/XKgL1GEC7rQw8lIZT8RApukCGqOVHSi/F1SiFlPDxuDfmdiNzL31+sL\r\n" * - "\t0iwHDdNkGjy5pyBSB8Y79dsSJtCW/iaLB0/n8Sj7HgvvZJ7x0fr+RQjYOUUfrePP\r\n" * - "\tu2MSpFyf+9BbC/aXgaZuiCvSR+8Snv3xApQY+fULK/xY8h8Ua51iXoQ5jrgu2SqR\r\n" * - "\twgA7BUi3G8LFzMBl8FRCDYGUDy7M6QaHXx1ZWIPWNKsCAwEAAaOCAiQwggIgMAwG\r\n" * - "\tA1UdEwEB/wQCMAAwEQYJYIZIAYb4QgHTTPAQDAgWgMA4GA1UdDwEB/wQEAwID6DAs\r\n" * - "\tBglghkgBhvhCAQ0EHxYdVUsgZS1TY2llbmNlIFVzZXIgQ2VydGlmaWNhdGUwHQYD\r\n" * - "\tVR0OBBYEFDTt/sf9PeMaZDHkUIldrDYMNTBZMIGaBgNVHSMEgZIwgY+AFAI4qxGj\r\n" * - "\tloCLDdMVKwiljjDastqooXSkcjBwMQswCQYDVQQGEwJVSzERMA8GA1UEChMIZVNj\r\n" * - "\taWVuY2UxEjAQBgNVBAsTCUF1dGhvcml0eTELMAkGA1UEAxMCQ0ExLTArBgkqhkiG\r\n" * - "\t9w0BCQEWHmNhLW9wZXJhdG9yQGdyaWQtc3VwcG9ydC5hYy51a4IBADApBgNVHRIE\r\n" * - "\tIjAggR5jYS1vcGVyYXRvckBncmlkLXN1cHBvcnQuYWMudWswGQYDVR0gBBIwEDAO\r\n" * - "\tBgwrBgEEAdkvAQEBAQYwPQYJYIZIAYb4QgEEBDAWLmh0dHA6Ly9jYS5ncmlkLXN1\r\n" * - "\tcHBvcnQuYWMudmT4sopwqlBWsvcHViL2NybC9jYWNybC5jcmwwPQYJYIZIAYb4QgEDBDAWLmh0\r\n" * - "\tdHA6Ly9jYS5ncmlkLXN1cHBvcnQuYWMudWsvcHViL2NybC9jYWNybC5jcmwwPwYD\r\n" * - "\tVR0fBDgwNjA0oDKgMIYuaHR0cDovL2NhLmdyaWQt5hYy51ay9wdWIv\r\n" * - "\tY3JsL2NhY3JsLmNybDANBgkqhkiG9w0BAQUFAAOCAQEAS/U4iiooBENGW/Hwmmd3\r\n" * - "\tXCy6Zrt08YjKCzGNjorT98g8uGsqYjSxv/hmi0qlnlHs+k/3Iobc3LjS5AMYr5L8\r\n" * - "\tUO7OSkgFFlLHQyC9JzPfmLCAugvzEbyv4Olnsr8hbxF1MbKZoQxUZtMVu29wjfXk\r\n" * - "\thTeApBv7eaKCWpSp7MCbvgzm74izKhu3vlDk9w6qVrxePfGgpKPqfHiOoGhFnbTK\r\n" * - "\twTC6o2xq5y0qZ03JonF7OJspEd3I5zKY3E+ov7/ZhW6DqT8UFvsAdjvQbXyhV8Eu\r\n" * - "\tYhixw1aKEPzNjNowuIseVogKOLXxWI5vAi5HgXdS0/ES5gDGsABo4fqovUKlgop3\r\n" * - "\tRA==\r\n" * - "\t-----END CERTIFICATE-----\r\n" * - "\r\n" - - r = parse(Request, reqstr) - @test r.method == "GET" - @test "GET / HTTP/1.1X-SSL-FoooBarr: $(header(r, "X-SSL-FoooBarr"))" == replace(reqstr, "\r\n" => "") - @test_throws HTTP.HTTP.ParseError HTTP.parse(Request, "GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection\r\033\065\325eep-Alive\r\nAccept-Encoding: gzip\r\n\r\n") - - r = parse(Request,"GET /bad_get_no_headers_no_body/world HTTP/1.1\r\nAccept: */*\r\n\r\nHELLO") - @test String(r.body) == "" - end - - @testset "Response - readheaders() - 1" begin - respstr = "HTTP/1.1 200 OK\r\n" * "Content-Length: " * "1844674407370955160" * "\r\n\r\n" - r = Response() - readheaders(IOBuffer(respstr), r) - @test r.status == 200 - @test [r.headers...] == ["Content-Length"=>"1844674407370955160"] - end - - @testset "Response - readheaders() - 2" begin - respstr = "HTTP/1.1 200 OK\r\n" * "Transfer-Encoding: chunked\r\n\r\n" * "FFFFFFFFFFFFFFE" * "\r\n..." - r = Response() - readheaders(IOBuffer(respstr), r) - @test r.status == 200 - @test [r.headers...] == ["Transfer-Encoding"=>"chunked"] - end - end - - @testset "parse - $response" for response in responses - r = parse(Response, response.raw) - r_headers = [tocameldash(n) => String(v) for (n,v) in r.headers] - - @test r.version.major == response.http_major - @test r.version.minor == response.http_minor - @test r.status == response.status_code - @test HTTP.StatusCodes.statustext(r.status) == response.response_status - @test length(r.headers) == response.num_headers - @test r_headers == response.headers - @test String(r.body) == response.body - - @test_skip @test HTTP.http_should_keep_alive(HTTP.DEFAULT_PARSER) == response.should_keep_alive - end - - @testset "Parse Errors" begin - @testset "Requests" begin - @testset "ArgumentError - Invalid Base 10 Digit" begin - reqstr = "GET / HTTP/1.1\r\n" * "Content-Length: 0\r\nContent-Length: 1\r\n\r\n" - e = try parse(Request, reqstr) catch e e end - @test isa(e, ArgumentError) - end - - @testset "EOFError - 1" begin - reqstr = "GET / HTTP/1.1\r\n" * "Transfer-Encoding: chunked\r\nContent-Length: 1\r\n\r\n" - e = try parse(Request, reqstr) catch e e end - @test isa(e, EOFError) - end - - @testset "EOFError - 2" begin - reqstr = "GET / HTTP/1.1\r\nheader: value\nhdr: value\r\n" - e = try parse(Request, reqstr) catch e e end - @test isa(e, EOFError) - end - - @testset "Invalid Request Line" begin - reqstr = "GET / HTP/1.1\r\n\r\n" - e = try parse(Request, reqstr) catch e e end - @test isa(e, HTTP.ParseError) && e.code ==:INVALID_REQUEST_LINE - end - - @testset "Invalid Header Field - 1" begin - reqstr = "GET / HTTP/1.1\r\n" * "Fo@: Failure\r\n\r\n" - e = try parse(Request, reqstr) catch e e end - @test isa(e, HTTP.ParseError) && e.code ==:INVALID_HEADER_FIELD - end - - @testset "Invalid Header Field - 2" begin - reqstr = "GET / HTTP/1.1\r\n" * "Foo\01\test: Bar\r\n\r\n" - e = try parse(Request, reqstr) catch e e end - @test isa(e, HTTP.ParseError) && e.code ==:INVALID_HEADER_FIELD - end - - @testset "Invalid Header Field - 3" begin - reqstr = "GET / HTTP/1.1\r\n" * "Foo: 1\rBar: 1\r\n\r\n" - e = try parse(Request, reqstr) catch e e end - @test isa(e, HTTP.ParseError) && e.code ==:INVALID_HEADER_FIELD - end - - @testset "Invalid Header Field - 4" begin - reqstr = "GET / HTTP/1.1\r\n" * "name\r\n" * " : value\r\n\r\n" - e = try parse(Request, reqstr) catch e e end - @test isa(e, HTTP.ParseError) && e.code ==:INVALID_HEADER_FIELD - end - - @testset "Invalid Request Line" begin - for m in ("HTTP/1.1", "hello world") - reqstr = "$m / HTTP/1.1\r\n\r\n" - e = try parse(Request, reqstr) catch e e end - @test isa(e, HTTP.ParseError) && e.code ==:INVALID_REQUEST_LINE - end - end - - @testset "Strict Headers - 1" begin - reqstr = "GET / HTTP/1.1\r\n" * "Foo: F\01ailure\r\n\r\n" - strict && @test_throws HTTP.ParseError parse(Request,reqstr) - - if !strict - r = HTTP.parse(HTTP.Messages.Request, reqstr) - @test r.method == "GET" - @test r.target == "/" - @test length(r.headers) == 1 + @testset "Parser Error Recovery" begin + @testset "Malformed messages" begin + # Test malformed request with missing HTTP version + reqstr = "GET /\r\n" + @test_throws HTTP.ParseError HTTP.Parsers.parse_request_line!(reqstr, HTTP.Request()) + + # Test malformed request with invalid HTTP version + reqstr = "GET / XHTTP/1.1\r\n" + @test_throws HTTP.ParseError HTTP.Parsers.parse_request_line!(reqstr, HTTP.Request()) + + # Test malformed request with invalid header format + reqstr = "Invalid-Header\r\n" + @test_throws HTTP.ParseError HTTP.Parsers.parse_header_field(SubString(reqstr)) + + # Test malformed request with missing header value + reqstr = "Content-Type\r\n" + @test_throws HTTP.ParseError HTTP.Parsers.parse_header_field(SubString(reqstr)) + end + + @testset "Partial reads" begin + # Test complete request line parsing + reqstr = "POST / HTTP/1.1\r\n" + req = HTTP.Request() + rest = HTTP.Parsers.parse_request_line!(reqstr, req) + @test req.method == "POST" + @test req.target == "/" + @test req.version == v"1.1" + + # Test complete header parsing + reqstr = "Content-Length: 10\r\nHost: test\r\n\r\n" + headers = Pair{SubString{String},SubString{String}}[] + rest = SubString(reqstr) + while !isempty(rest) + header, rest = HTTP.Parsers.parse_header_field(rest) + if header != HTTP.Parsers.emptyheader + push!(headers, header) end end - - @testset "Strict Headers - 2" begin - reqstr = "GET / HTTP/1.1\r\n" * "Foo: B\02ar\r\n\r\n" - strict && @test_throws HTTP.ParseError parse(Request, reqstr) - - if !strict - r = parse(HTTP.Messages.Request, reqstr) - @test r.method == "GET" - @test r.target == "/" - @test length(r.headers) == 1 - end - end - - # https://github.com/JuliaWeb/HTTP.jl/issues/796 - @testset "Latin-1 values in header" begin - reqstr = "GET / HTTP/1.1\r\n" * "link: ; rel=\"canonical\", ; version=\"vor\"; type=\"text/xml\"; rel=\"item\", ; version=\"vor\"; type=\"text/plain\"; rel=\"item\", ; version=\"tdm\"; rel=\"license\", ; title=\"Santiago Badia\"; rel=\"author\", ; title=\"Alberto F. Mart\xedn\"; rel=\"author\"\r\n\r\n" - r = parse(HTTP.Messages.Request, reqstr) - @test r.method == "GET" - @test r.target == "/" - @test length(r.headers) == 1 - @test r.headers[1][2] == "; rel=\"canonical\", ; version=\"vor\"; type=\"text/xml\"; rel=\"item\", ; version=\"vor\"; type=\"text/plain\"; rel=\"item\", ; version=\"tdm\"; rel=\"license\", ; title=\"Santiago Badia\"; rel=\"author\", ; title=\"Alberto F. Martín\"; rel=\"author\"" - end + @test length(headers) == 2 + @test any(h -> h.first == "Content-Length" && h.second == "10", headers) + @test any(h -> h.first == "Host" && h.second == "test", headers) end - @testset "Responses" begin - @testset "ArgumentError - Invalid Base 10 Digit" begin - respstr = "HTTP/1.1 200 OK\r\n" * "Content-Length: 0\r\nContent-Length: 1\r\n\r\n" - e = try parse(Response, respstr) catch e e end - @test isa(e, ArgumentError) - end - - @testset "Chunk Size Exceeds Limit" begin - respstr = "HTTP/1.1 200 OK\r\n" * "Transfer-Encoding: chunked\r\n\r\n" * "FFFFFFFFFFFFFFF" * "\r\n..." - e = try parse(Response,respstr) catch e e end - @test isa(e, HTTP.ParseError) && e.code == :CHUNK_SIZE_EXCEEDS_LIMIT - end - - @testset "Chunk Size Exceeds Limit" begin - respstr = "HTTP/1.1 200 OK\r\n" * "Transfer-Encoding: chunked\r\n\r\n" * "10000000000000000" * "\r\n..." - e = try parse(Response,respstr) catch e e end - @test isa(e, HTTP.ParseError) && e.code == :CHUNK_SIZE_EXCEEDS_LIMIT - end - - @testset "EOF Error" begin - respstr = "HTTP/1.1 200 OK\r\n" * "Transfer-Encoding: chunked\r\nContent-Length: 1\r\n\r\n" - e = try parse(Response, respstr) catch e e end - @test isa(e, EOFError) - end - - @test_skip @testset "Invalid Content Length" begin - respstr = "HTTP/1.1 200 OK\r\n" * "Content-Length: " * "18446744073709551615" * "\r\n\r\n" - e = try parse(Response,respstr) catch e e end - @test isa(e, HTTP.ParseError) && e.code == Parsers.HPE_INVALID_CONTENT_LENGTH - end - - @testset "Invalid Header Field - 1" begin - respstr = "HTTP/1.1 200 OK\r\n" * "Fo@: Failure\r\n\r\n" - e = try parse(Response, respstr) catch e e end - @test isa(e, HTTP.ParseError) && e.code ==:INVALID_HEADER_FIELD - end - - @testset "Invalid Header Field - 2" begin - respstr = "HTTP/1.1 200 OK\r\n" * "Foo\01\test: Bar\r\n\r\n" - e = try parse(Response, respstr) catch e e end - @test isa(e, HTTP.ParseError) && e.code ==:INVALID_HEADER_FIELD - end - - @testset "Invalid Header Field - 3" begin - respstr = "HTTP/1.1 200 OK\r\n" * "Foo: 1\rBar: 1\r\n\r\n" - e = try parse(Response, respstr) catch e e end - @test isa(e, HTTP.ParseError) && e.code ==:INVALID_HEADER_FIELD - end - - @testset "Strict Headers - 1" begin - respstr = "HTTP/1.1 200 OK\r\n" * "Foo: F\01ailure\r\n\r\n" - strict && @test_throws HTTP.ParseError parse(Response,respstr) - - if !strict - r = parse(HTTP.Messages.Response, respstr) - @test r.status == 200 - @test length(r.headers) == 1 - end - end - - @testset "Strict Headers - 2" begin - respstr = "HTTP/1.1 200 OK\r\n" * "Foo: B\02ar\r\n\r\n" - strict && @test_throws HTTP.ParseError parse(Response,respstr) - - if !strict - r = parse(HTTP.Messages.Response, respstr) - @test r.status == 200 - @test length(r.headers) == 1 - end - end + @testset "Interrupted connections" begin + # Test connection interruption by attempting to connect to a non-existent host + @test_throws Union{HTTP.ConnectError,Base.IOError} HTTP.post( + "http://non.existent.host", + ["Content-Length" => "5"], + "Hello"; + retry=false, + readtimeout=1, + connect_timeout=1 + ) end end end + +end # module diff --git a/test/runtests.jl b/test/runtests.jl index c89ccded..2c39c9eb 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -16,6 +16,7 @@ isok(r) = r.status == 200 "chunking.jl", "utils.jl", "client.jl", + "connection_pool.jl", # "download.jl", "multipart.jl", "parsemultipart.jl",