Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix incomplete get handling via HTTP compat bump and don't write failed retry streams to parent buffer in _http_request #516

Merged
merged 23 commits into from
Nov 25, 2021
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6cbdf9e
don't write failed stream to parent buffer
IanButterworth Nov 13, 2021
ff2cf7c
bump HTTP compat to 0.9.17
IanButterworth Nov 17, 2021
e92bd7e
only don't write if EOFError
IanButterworth Nov 17, 2021
8ee267b
add incomplete stream test
IanButterworth Nov 18, 2021
267aca7
fix by using global
IanButterworth Nov 18, 2021
63edcf6
adjust attempt num tracking
IanButterworth Nov 18, 2021
ef70586
account for HTTP wrapping EOFError in a HTTP.IOError
IanButterworth Nov 18, 2021
934e511
fix test
IanButterworth Nov 18, 2021
8d31604
minor formatting/dead code in test
IanButterworth Nov 18, 2021
4d2d871
add DownloadsBackend test
IanButterworth Nov 19, 2021
f86a222
DownloadsBackend: rewind output to starting position to undo incomple…
IanButterworth Nov 19, 2021
5f3e25c
generalize buffer reset
IanButterworth Nov 19, 2021
040aee3
Revert "generalize buffer reset"
IanButterworth Nov 19, 2021
b864923
Revert "DownloadsBackend: rewind output to starting position to undo …
IanButterworth Nov 19, 2021
74e91ac
DownloadsBackend: use sacrificial buffer
IanButterworth Nov 19, 2021
c8ad389
formatting and comment errors
IanButterworth Nov 23, 2021
637a532
make catch more specific
IanButterworth Nov 23, 2021
270925c
add tests for total failures
IanButterworth Nov 23, 2021
929d1c4
always write to buffer on last attempt
IanButterworth Nov 23, 2021
8dec8bb
review suggestions
IanButterworth Nov 24, 2021
1b6f71d
Merge branch 'master' into ib/fix_retry_stream
IanButterworth Nov 24, 2021
34ae376
fix kwarg format for older julia versions
IanButterworth Nov 24, 2021
54e974c
Rename `attempts` to `max_attempts`
omus Nov 24, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ XMLDict = "228000da-037f-5747-90a9-8195ccbf91a5"
[compat]
Compat = "3.29"
GitHub = "5"
HTTP = "0.9.6"
HTTP = "0.9.17"
IniFile = "0.5"
JSON = "0.18, 0.19, 0.20, 0.21"
MbedTLS = "0.6, 0.7, 1"
Expand Down
47 changes: 36 additions & 11 deletions src/utilities/downloads_backend.jl
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,10 @@ function _http_request(backend::DownloadsBackend, request::Request, response_str
# We pass an `input` only when we have content we wish to send.
input = !isempty(request.content) ? IOBuffer(request.content) : nothing

@repeat 4 try
attempts = 4
omus marked this conversation as resolved.
Show resolved Hide resolved
attempt = 0
@repeat attempts try
omus marked this conversation as resolved.
Show resolved Hide resolved
attempt += 1
downloader = @something(backend.downloader, get_downloader())
# set the hook so that we don't follow redirects. Only
# need to do this on per-request downloaders, because we
Expand All @@ -79,16 +82,38 @@ function _http_request(backend::DownloadsBackend, request::Request, response_str
# but the first will send an empty payload.
input !== nothing && seekstart(input)

response = @mock Downloads.request(
request.url;
input=input,
output=output,
method=request.request_method,
headers=request.headers,
verbose=false,
throw=true,
downloader=downloader,
)
# Because Downloads will write incomplete stream failures to the provided output buffer
# a sacrificial buffer is needed so that incomplete data can be discarded
buffer = isnothing(output) ? nothing : Base.BufferStream()
should_write = true
response = try
@mock Downloads.request(
request.url;
input=input,
output=buffer,
method=request.request_method,
headers=request.headers,
verbose=false,
throw=true,
downloader=downloader,
)
catch e
if e isa Downloads.RequestError && e.code == 18
# Downloads.RequestError 18 indicates an incomplete transfer, so don't pass the buffer forward
should_write = false
end
rethrow()
finally
if !isnothing(output)
close(buffer)
# Transfer the contents of the `BufferStream` into `response_stream` variable.
# but only if no EOFError error because of a broken connection OR it's the final attempt.
# i.e. Multiple EOFError retries shouldn't be passed to the `response_stream`
if should_write || attempt == attempts
omus marked this conversation as resolved.
Show resolved Hide resolved
write(response_stream, buffer)
end
end
end

http_response = HTTP.Response(
response.status, response.headers; body=body_was_streamed, request=nothing
Expand Down
21 changes: 17 additions & 4 deletions src/utilities/request.jl
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,10 @@ end
function _http_request(http_backend::HTTPBackend, request::Request, response_stream::IO)
http_options = merge(http_backend.http_options, request.http_options)

@repeat 4 try
attempts = 4
omus marked this conversation as resolved.
Show resolved Hide resolved
attempt = 0
@repeat attempts try
omus marked this conversation as resolved.
Show resolved Hide resolved
attempt += 1
# HTTP options such as `status_exception` need to be used when creating the stack
http_stack = HTTP.stack(;
redirect=false, retry=false, aws_authorization=false, http_options...
Expand All @@ -156,7 +159,7 @@ function _http_request(http_backend::HTTPBackend, request::Request, response_str
# will keep using the `response_stream` kwarg to ensure we aren't relying on the
# response's body being populated.
buffer = Base.BufferStream()

should_write = true
r = try
@mock HTTP.request(
http_stack,
Expand All @@ -168,14 +171,24 @@ function _http_request(http_backend::HTTPBackend, request::Request, response_str
response_stream=buffer,
http_options...,
)
catch e
if e isa HTTP.IOError && e.e isa EOFError
# EOFErrors indicate a broken connection, so don't pass the buffer forward
should_write = false
end
rethrow()
finally
# We're unable to read from the `Base.BufferStream` until it has been closed.
# HTTP.jl will close passed in `response_stream` keyword. This ensures that it
# is always closed (e.g. HTTP.jl 0.9.15)
close(buffer)

# Transfer the contents of the `BufferStream` into `response_stream` variable.
write(response_stream, buffer)
# Transfer the contents of the `BufferStream` into `response_stream` variable
# but only if no EOFError error because of a broken connection OR it's the final attempt.
# i.e. Multiple EOFError retries shouldn't be passed to the `response_stream`
if should_write || attempt == attempts
omus marked this conversation as resolved.
Show resolved Hide resolved
write(response_stream, buffer)
end
end
omus marked this conversation as resolved.
Show resolved Hide resolved

return @mock Response(r, response_stream)
Expand Down
73 changes: 73 additions & 0 deletions test/issues.jl
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,79 @@ try
end
end

@testset "issue 515" begin
# https://github.com/JuliaCloud/AWS.jl/issues/515
n = 100
n_incomplete = n - 1
data = rand(UInt8, n)

@testset "Fail 2 attempts then succeed" begin
i = 2
ATTEMPT_NUM = 0 # reset
patch = if AWS.DEFAULT_BACKEND[] isa AWS.HTTPBackend
@patch function HTTP.request(args...; response_stream, kwargs...)
ATTEMPT_NUM += 1
if ATTEMPT_NUM <= i
write(response_stream, data[1:n_incomplete]) # an incomplete stream that shouldn't be retained
throw(HTTP.IOError(EOFError(), "msg"))
else
write(response_stream, data)
return HTTP.Response(200, "{\"Location\": \"us-east-1\"}")
end
end
elseif AWS.DEFAULT_BACKEND[] isa AWS.DownloadsBackend
@patch function Downloads.request(args...; output, kwargs...)
ATTEMPT_NUM += 1
if ATTEMPT_NUM <= i
write(output, data[1:n_incomplete]) # an incomplete stream that shouldn't be retained
throw(Downloads.RequestError("", 18, "transfer closed with 1 bytes remaining to read", Downloads.Response("http", "", 200, "HTTP/1.1 200 OK", ["content-length" => "64"])))
IanButterworth marked this conversation as resolved.
Show resolved Hide resolved
else
write(output, data)
return Downloads.Response("http", "", 200, "HTTP/1.1 200 OK", ["content-length" => "64"])
end
end
end
config = AWSConfig(; creds=nothing)
apply(patch) do
resp = S3.get_object("www.invenia.ca", "index.html"; aws_config=config) # use public bucket as dummy
@test length(resp) == n
@test resp == data
end
end
@testset "Fail all 4 attempts then throw" begin
i = 4
ATTEMPT_NUM = 0 # reset
patch = if AWS.DEFAULT_BACKEND[] isa AWS.HTTPBackend
@patch function HTTP.request(args...; response_stream, kwargs...)
ATTEMPT_NUM += 1
if ATTEMPT_NUM <= i
write(response_stream, data[1:n_incomplete]) # an incomplete stream that shouldn't be retained
throw(HTTP.IOError(EOFError(), "msg"))
else
write(response_stream, data)
return HTTP.Response(200, "{\"Location\": \"us-east-1\"}")
end
end
elseif AWS.DEFAULT_BACKEND[] isa AWS.DownloadsBackend
@patch function Downloads.request(args...; output, kwargs...)
ATTEMPT_NUM += 1
if ATTEMPT_NUM <= i
write(output, data[1:n_incomplete]) # an incomplete stream that shouldn't be retained
throw(Downloads.RequestError("", 18, "transfer closed with 1 bytes remaining to read", Downloads.Response("http", "", 200, "HTTP/1.1 200 OK", ["content-length" => "64"])))
else
write(output, data)
return Downloads.Response("http", "", 200, "HTTP/1.1 200 OK", ["content-length" => "64"])
end
end
end
IanButterworth marked this conversation as resolved.
Show resolved Hide resolved
config = AWSConfig(; creds=nothing)
apply(patch) do
err_t = AWS.DEFAULT_BACKEND[] isa AWS.HTTPBackend ? HTTP.IOError : Downloads.RequestError
@test_throws err_t S3.get_object("www.invenia.ca", "index.html"; aws_config=config) # use public bucket as dumm
end
end
end
omus marked this conversation as resolved.
Show resolved Hide resolved

finally
S3.delete_bucket(BUCKET_NAME)
end
1 change: 1 addition & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ using AWS.AWSMetadata:
using Base64
using Compat: mergewith
using Dates
using Downloads
using GitHub
using HTTP
using IniFile: Inifile
Expand Down