ring-clojure / ring

Clojure HTTP server abstraction
MIT License
3.75k stars 519 forks source link

Handling EofException #409

Closed flodiebold closed 5 months ago

flodiebold commented 4 years ago

This is an issue where I'm not 100% sure what part of the stack is really responsible for the problem, so I'll just start here.

We have an API using compojure-api, which is using async but in some cases returning an InputStream as body. If a caller requests that resource, but then closes the connection before the body has finished being written, it results in multiple errors being logged, which we would like to get rid of. So far as I can reconstruct, the following happens:

  1. the StreamableResponseBody implementation tries to write to the connection (here) and gets an org.eclipse.jetty.io.EofException.
  2. because the request was async, the exception goes through a few layers of respond calls, until finally being caught by this try/catch block in compojure-api, which calls raise with it.
  3. the raise call finally reaches compojure-api's wrap-exceptions middleware, which tries to respond with an error response.
  4. because the request has already been responded to (and closed), this results in an IllegalStateException: AsyncContext completed and/or Request lifecycle recycled from Jetty.
  5. as a bonus, while unwinding the ring code tries to close the output stream, which apparently causes the StreamEncoder to write to it, which causes a second EofException.

So... something is handling the exceptions wrongly here, but I'm not sure whether it's a) that middlewares should never call raise after respond, or b) the servlet adapter code (or maybe the jetty adapter?) should catch the EofException, or something completely different. If you think this is a bug in compojure-api, I'll reopen the ticket there of course, but it made more sense to me to open a ticket here first, because it seems to me part of the problem is that the contract for async handlers is a bit unclear (is calling raise after having already called respond allowed?).

weavejester commented 4 years ago

Thanks for the report. This is related to an issue brought up during Ring 2 development, around how to handle exceptions from a respond. The conclusion was that respond should never throw - any exception should be caught and raised instead. So in Compojure-API, the try/catch block should be around coerce-response! but not around respond.

In terms of Ring, the EOF execption should be raised so that user code can react to it in some way, but after the error is raised, subsequent calls to respond should, I think, be ignored. This requires some fixes to the Jetty adapter, in terms of catching the error, raising it correctly, and then ensuring that respond is rendered a no-op for that request cycle.

alexanderkiel commented 3 years ago

Regarding the respond function. I find the rules for a continuation from leonoel/task suitable:

[...] Its return value should be ignored, it must not throw and must not block the calling thread. [...]

You say:

In terms of Ring, the EOF execption should be raised so that user code can react to it in some way [...]

How do you expect to achieve that? The EOF Exception will be thrown inside the respond function an adapter will use to call the handler. If that respond function doesn't throw the exception, as the rule would require, I don't see how the user code could handle it.

In essence, the handler code hands over the response to the respond function and never looks back, as it ignores the return value, don't expect the respond function to throw and even should expect the respond function to not block. Especially if the respond function doesn't block on I/O, the handler function will return before the response is written to the client.

So, IMHO any exception that will be caught during writing of the StreamableResponseBody has to somehow be handled by the adapter itself.

weavejester commented 3 years ago

How do you expect to achieve that? The EOF Exception will be thrown inside the respond function an adapter will use to call the handler.

As I said in my previous comment:

The conclusion was that respond should never throw - any exception should be caught and raised instead.

This can be made more streamlined with the ring.util.async/raising function in the 2.0 branch.

alexanderkiel commented 3 years ago

As I said in my previous comment:

The conclusion was that respond should never throw - any exception should be caught and raised instead.

This can be made more streamlined with the ring.util.async/raising function in the 2.0 branch.

If I understand you correctly, the implementation of the respond method from the adapter should raise exceptions instead of throwing them. But then, how does the adapter become access the the raise function? It will have access to it's own raise function only, which isn't a user provided raise function. In case the handler should be able to intercept the raising, the respond function would need a second argument, a raise function.

IMHO it would be sufficient to just terminate the request handling gracefully, in case an EOF happens while writing the response. First the client doesn't receive data anymore and second, the handler can never assume that the client receives the response anyhow. So I see no need to inform the handler here.

weavejester commented 3 years ago

The exception would be raised when attempting to write to the output stream, so if a developer wanted to handle it themselves, they could:

(defn handler [request respond raise]
  (respond
   {:status 200
    :headers {}
    :body (reify StreamableResponseBody
            (-write-body-to-stream [_ _ output-stream]
              (try
                (with-open [writer (io/writer output-stream)]
                  (.write writer "Hello World"))
                (catch EofException ex
                  (raise ex)))))})

But by default, yes, it'll just swallow the exception and move on.

alexanderkiel commented 3 years ago

In the example you show above, I don't think that raising the exception will work. The default raise of an adapter would try to send an error after the response was started already. An error handling middleware above would probably try to use respond a second time in order to send an error response.

weavejester commented 3 years ago

An error handling middleware above would probably try to use respond a second time in order to send an error response.

Right, and as I said, subsequent responds would be ignored. If the stream is closed you can't respond to the client, you can only log and carry on (or pass a message via another connection).