Skip to content

Fix unhandled exception when headers were already sent to the client#760

Merged
sdogruyol merged 2 commits into
masterfrom
fix-759
Jun 12, 2026
Merged

Fix unhandled exception when headers were already sent to the client#760
sdogruyol merged 2 commits into
masterfrom
fix-759

Conversation

@sdogruyol

@sdogruyol sdogruyol commented Jun 12, 2026

Copy link
Copy Markdown
Member

Fixes #759

Problem

Changing response headers in an after_all filter raises Headers already sent (IO::Error) when the response body exceeds the 8KB output buffer, because Crystal flushes the headers to the socket mid-write. The exception handler made it worse by trying to render a 500 page, which also sets headers and raises again, producing an unhandled exception in the logs and a half-written response.

Solution

Headers must be set before the body is written, so before_* filters are the right place for header changes. This PR doesn't try to make header changes in after filters work (see the discussion below), it fixes the secondary crash instead:

  • Added HTTP::Server::Response#headers_sent? and guarded render_500, the custom error handler paths and the payload-too-large handler with it. When the headers are already on the wire, the exception handler now delivers the response as-is instead of crashing with a second exception.
  • The headers_sent? helper is available on all Crystal versions; the old Output#close override is still only compiled on Crystal < 1.3.
  • Documented in the filter DSL docs that header changes belong in before_* filters and why.

An earlier revision of this PR deferred writing the route's response body until the after filters had run, so header changes in after filters would work for any response size. That approach was dropped after review feedback: it can't cover handlers that write directly to the response IO (e.g. send_file), so it would have fixed the inconsistency only partially. See the discussion with @straight-shoota below.

Behavior changes

  • An exception raised in an after filter before the headers are sent (small responses) renders a proper 500 page, as before.
  • An exception raised after the headers are sent (large responses) no longer produces an unhandled exception in the logs; the response is delivered intact.

@straight-shoota

straight-shoota commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

I'm not sure if delaying the content write is a good change. It breaks as soon as a handler triggers sending the response, for example by writing directly to the response IO (e.g. with the send_file helper).
I don't think there's a way to implement this without caveats.

Headers are supposed to be set before sending any content. So an after hook seems like a naturally bad place for setting headers and I'm questioning the use case for that.

Supporting HTTP trailing headers could be worth considering, but that's a very niche use case.

@sdogruyol

Copy link
Copy Markdown
Member Author

You're right that this can't cover handlers writing directly to the response IO. send_file and streaming responses flush headers early and the defer doesn't help there. That's why I kept the scope narrow: only the route's return value is deferred, direct writes behave exactly as before.

My motivation wasn't really to endorse setting headers in after hooks. It's the inconsistency that bothers me: the same after_all works fine for small responses and blows up with an IO::Error once the body crosses the buffer size. That's a confusing failure mode to debug, and we silently log an unhandled exception on top of it.

That said, I'm not attached to the defer part. If you prefer, I can trim this down to the headers_sent? guards so we at least fix the secondary crash in the exception handler, and document that before filters are the right place for headers. WDYT?

@straight-shoota

Copy link
Copy Markdown
Contributor

That sounds good. At least as a first remedy. We can still move forward with more changes if there's a strong use case for it.

In order to remove the inconsistency, we might consider explicitly flushing the response before running after hooks. That might seem a bit harsh, but I believe it's correct. After hooks should not expect to contribute to the response.

@sdogruyol

Copy link
Copy Markdown
Member Author

@straight-shoota updated the PR according to our discussion going with the simpler approach

@sdogruyol sdogruyol changed the title Run after filters before writing the response body Fix unhandled exception when headers were already sent to the client Jun 12, 2026
Comment thread src/kemal/dsl.cr Outdated
@sdogruyol sdogruyol merged commit 5c03f3e into master Jun 12, 2026
38 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

after_all causes a Headers already sent (IO::Error) on large responses

2 participants