Skip to content

Commit

Permalink
Fixed #2908: better handling of errors in HTTP::Server
Browse files Browse the repository at this point in the history
  • Loading branch information
Ary Borenszweig committed Jun 24, 2016
1 parent 78b8ee4 commit ecdcea9
Show file tree
Hide file tree
Showing 4 changed files with 146 additions and 49 deletions.
62 changes: 62 additions & 0 deletions spec/std/http/server/server_spec.cr
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
require "spec"
require "http/server"

module IO
class RaiseErrno
def initialize(@value : Int32)
end

include IO

def read(slice : Slice(UInt8))
Errno.value = @value
raise Errno.new "..."
end

def write(slice : Slice(UInt8)) : Nil
raise "not implemented"
end
end
end

module HTTP
class Server
class ReverseResponseOutput
Expand Down Expand Up @@ -207,6 +225,46 @@ module HTTP
end
end

describe HTTP::Server::RequestProcessor do
it "works" do
processor = HTTP::Server::RequestProcessor.new do |context|
context.response.content_type = "text/plain"
context.response.print "Hello world"
end

input = MemoryIO.new("GET / HTTP/1.1\r\n\r\n")
output = MemoryIO.new
processor.process(input, output)
output.rewind
output.gets_to_end.should eq(requestize(<<-RESPONSE
HTTP/1.1 200 OK
Connection: keep-alive
Content-Type: text/plain
Content-Length: 11
Hello world
RESPONSE
))
end

it "handles Errno" do
processor = HTTP::Server::RequestProcessor.new { }
input = IO::RaiseErrno.new(Errno::ECONNRESET)
output = MemoryIO.new
processor.process(input, output)
output.rewind.gets_to_end.empty?.should be_true
end

it "catches raised error on handler" do
processor = HTTP::Server::RequestProcessor.new { raise "OH NO" }
input = MemoryIO.new("GET / HTTP/1.1\r\n\r\n")
output = MemoryIO.new
error = MemoryIO.new
processor.process(input, output, error)
output.rewind.gets_to_end.should match(/Internal Server Error/)
end
end

typeof(begin
# Initialize with custom host
server = Server.new("0.0.0.0", 0) { |ctx| }
Expand Down Expand Up @@ -247,3 +305,7 @@ module HTTP
server.close
end)
end

private def requestize(string)
string.gsub("\n", "\r\n")
end
12 changes: 10 additions & 2 deletions src/http/request.cr
Original file line number Diff line number Diff line change
Expand Up @@ -52,19 +52,27 @@ class HTTP::Request
HTTP.serialize_headers_and_body(io, headers, @body, nil, @version)
end

# :nodoc:
record BadRequest

# Returns:
# * nil: EOF
# * BadRequest: bad request
# * HTTP::Request: successfully parsed
def self.from_io(io)
request_line = io.gets
return unless request_line

parts = request_line.split
return unless parts.size == 3
return BadRequest.new unless parts.size == 3

method, resource, http_version = parts
HTTP.parse_headers_and_body(io) do |headers, body|
return new method, resource, headers, body.try &.gets_to_end, http_version
end

# Unexpected end of http request
nil
BadRequest.new
end

# Lazily parses and return the request's path component.
Expand Down
57 changes: 10 additions & 47 deletions src/http/server.cr
Original file line number Diff line number Diff line change
Expand Up @@ -110,18 +110,22 @@ class HTTP::Server
new("127.0.0.1", port, handler)
end

def initialize(@host : String, @port : Int32, &@handler : Context ->)
def initialize(@host : String, @port : Int32, &handler : Context ->)
@processor = RequestProcessor.new(handler)
end

def initialize(@host : String, @port : Int32, handlers : Array(HTTP::Handler), &handler : Context ->)
@handler = HTTP::Server.build_middleware handlers, handler
handler = HTTP::Server.build_middleware handlers, handler
@processor = RequestProcessor.new(handler)
end

def initialize(@host : String, @port : Int32, handlers : Array(HTTP::Handler))
@handler = HTTP::Server.build_middleware handlers
handler = HTTP::Server.build_middleware handlers
@processor = RequestProcessor.new(handler)
end

def initialize(@host : String, @port : Int32, @handler : HTTP::Handler | HTTP::Handler::Proc)
def initialize(@host : String, @port : Int32, handler : HTTP::Handler | HTTP::Handler::Proc)
@processor = RequestProcessor.new(handler)
end

def port
Expand All @@ -145,6 +149,7 @@ class HTTP::Server

def close
@wants_close = true
@processor.close
if server = @server
server.close
@server = nil
Expand All @@ -163,49 +168,7 @@ class HTTP::Server
end
end

must_close = true
response = Response.new(io)

begin
until @wants_close
begin
request = HTTP::Request.from_io(io)
rescue e
STDERR.puts "Bug: Unhandled exception while parsing request"
e.inspect_with_backtrace(STDERR)
end

unless request
response.respond_with_error("Bad Request", 400)
response.close
return
end

response.version = request.version
response.reset
response.headers["Connection"] = "keep-alive" if request.keep_alive?
context = Context.new(request, response)

@handler.call(context)

if response.upgraded?
must_close = false
return
end

response.output.close
io.flush

break unless request.keep_alive?
end
rescue e
response.respond_with_error
response.close
STDERR.puts "Unhandled exception while computing response, make sure to catch all your exceptions"
e.inspect_with_backtrace(STDERR)
ensure
io.close if must_close
end
@processor.process(io, io)
end

# Builds all handlers as the middleware for HTTP::Server.
Expand Down
64 changes: 64 additions & 0 deletions src/http/server/request_processor.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
require "../server"

class HTTP::Server::RequestProcessor
def initialize(&@handler : HTTP::Handler::Proc)
@wants_close = false
end

def initialize(@handler : HTTP::Handler | HTTP::Handler::Proc)
@wants_close = false
end

def close
@wants_close = true
end

def process(input, output, error = STDERR)
must_close = true
response = Response.new(output)

begin
until @wants_close
request = HTTP::Request.from_io(input)

# EOF
break unless request

if request.is_a?(HTTP::Request::BadRequest)
response.respond_with_error("Bad Request", 400)
response.close
return
end

response.version = request.version
response.reset
response.headers["Connection"] = "keep-alive" if request.keep_alive?
context = Context.new(request, response)

begin
@handler.call(context)
rescue ex
response.respond_with_error
response.close
error.puts "Unhandled exception on HTTP::Handler"
ex.inspect_with_backtrace(error)
return
end

if response.upgraded?
must_close = false
return
end

response.output.close
output.flush

break unless request.keep_alive?
end
rescue ex : Errno
# IO-related error, nothing to do

This comment has been minimized.

Copy link
@jhass

jhass Jun 25, 2016

Member

I'm not sure I agree, if my OS or program is stressed out and starts to throw IO errors at me, or if I hit some limit like maximum open file descriptors or if something took the socket down beneath my feet, I do want to know.

This comment has been minimized.

Copy link
@asterite

asterite Jun 25, 2016

Member

The problem is that it's hard to know when the other end closed the connection and writing will raise. In that case we don't want to respond, because we can't.

We didn't finish the refactor. We think the server needs a logger on its own for this kind of errors, which are more low level than those captured by a handler. So we could log such things there, but still not respond the request.

This comment has been minimized.

Copy link
@jhass

jhass Jun 25, 2016

Member

Is it? that's one specific errno to ignore. Not all of them.

This comment has been minimized.

Copy link
@asterite

asterite Jun 25, 2016

Member

We think ECONNRESET, but libc docs aren't very clear... In any case, we also want to remove Errno from the std and deal with higher level exceptions that still have the original cause, we think.

ensure
input.close if must_close

This comment has been minimized.

Copy link
@ysbaddaden

ysbaddaden Jun 25, 2016

Contributor

Shoudln't it be output.close here? or even both input.close; output.close?

This comment has been minimized.

Copy link
@asterite

asterite Jun 25, 2016

Member

We actually just have three ios so it's testable, http server just passes one io for input and output, and I think any real use case will also pass just one (the socket)

This comment has been minimized.

Copy link
@jhass

jhass Jun 25, 2016

Member

Sorry but that's a bad argument. Calling close twice on the same IO does no harm. Potentially leaving one open, however unlikely, however does.

This comment has been minimized.

Copy link
@asterite

asterite Jun 25, 2016

Member

In fact closing the output will break the specs, as these later need to either rewind the output and check the contents, or get the resulting string, but if the MemoryIO is closed then an exception will happen. I really wouldn't worry about this.

This comment has been minimized.

Copy link
@asterite

asterite Jun 25, 2016

Member

It's basically: read the IO until there are no more requests, writing to the output, then close the input. I don't think anything should be assumed about the output.

This comment has been minimized.

Copy link
@ysbaddaden

ysbaddaden Jun 25, 2016

Contributor

Overall I'm not fond of this change. It's purely for testing, and it don't want to make changes to HTTP2 to have 2 artificially distinct IO when in reality it's a single IO.

This comment has been minimized.

Copy link
@asterite

asterite Jun 25, 2016

Member

At first we had a single IO but we couldn't test it. We stitched to two and it became very easy to test. If you find a way to test it with just a single IO, we can change it (probably simpler and more correct)

This comment has been minimized.

Copy link
@jhass

jhass Jun 25, 2016

Member

How about we do a duplex memory IO? A wrapper around two MemoryIO, one used for reading the other for writing.

end
end
end

0 comments on commit ecdcea9

Please sign in to comment.