From 4a5844bac1bbe0d5891173abd3fe76f99f06d77a Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Fri, 11 Sep 2026 10:45:01 +0200 Subject: [PATCH] Let a parser's Grape errors through the formatter without re-raising MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Formatter#read_rack_input` answers a parser's StandardErrors with a 400, and let Grape's own errors go on to the error middleware by rescuing them first, `rescue Grape::Exceptions::Base => e; raise e`. Re-raising an exception makes Ruby read its backtrace, which builds it as Strings, and at request depth that is the dearest part of the error: about 25 µs. It was paid by every malformed body, since the built-in parsers report one as `InvalidMessageBody`. A matcher module in the `rescue` clause now selects the errors to answer here, StandardErrors that are not Grape errors, so a Grape error is never rescued at this frame and travels on as it is. A parser raising something that is not a StandardError, such as an Interrupt or a SystemExit, keeps propagating rather than being answered as a 400. Nothing pinned that; it now has a spec, which passes before and after this change. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + lib/grape/middleware/formatter.rb | 16 +++++++++++++--- spec/grape/middleware/formatter_spec.rb | 23 +++++++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdc7d3a3e..b4cbe6975 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ * [#2918](https://github.com/ruby-grape/grape/pull/2918): Skip the dry-types round trip when a value already is the declared type - [@ericproulx](https://github.com/ericproulx). * [#2917](https://github.com/ruby-grape/grape/pull/2917): Read path captures out of the router's union match instead of re-running the route's pattern - [@ericproulx](https://github.com/ericproulx). * [#2921](https://github.com/ruby-grape/grape/pull/2921): Pin the router's request-time isolation regressions through requests instead of its instance variables - [@ericproulx](https://github.com/ericproulx). +* [#2932](https://github.com/ruby-grape/grape/pull/2932): Let a parser's Grape errors through the formatter instead of rescuing them to raise again - [@ericproulx](https://github.com/ericproulx). * Your contribution here. ### 4.0.0 (2026-09-07) diff --git a/lib/grape/middleware/formatter.rb b/lib/grape/middleware/formatter.rb index 775c026fd..2da7432e1 100644 --- a/lib/grape/middleware/formatter.rb +++ b/lib/grape/middleware/formatter.rb @@ -152,13 +152,23 @@ def read_rack_input(body) end env[Rack::RACK_REQUEST_FORM_INPUT] = env[Rack::RACK_INPUT] end - rescue Grape::Exceptions::Base => e - raise e - rescue StandardError => e + rescue ForeignParserError => e throw :error, Grape::Exceptions::ErrorResponse.new(status: 400, message: e.message, backtrace: e.backtrace, original_exception: e) end end + # What a parser raises that is not a Grape error, and so is answered as a + # 400 here. A Grape error goes on to the error middleware as it is. It + # used to be rescued just to be raised again, and re-raising at request + # depth cost about 25 µs -- paid by every malformed body, since the + # built-in parsers report one as InvalidMessageBody. + module ForeignParserError + def self.===(exception) + exception.is_a?(StandardError) && !exception.is_a?(Grape::Exceptions::Base) + end + end + private_constant :ForeignParserError + # this middleware will not try to format the following content-types since Rack already handles them # when calling Rack's `params` function # - application/x-www-form-urlencoded diff --git a/spec/grape/middleware/formatter_spec.rb b/spec/grape/middleware/formatter_spec.rb index c3ea04ddb..648811e07 100644 --- a/spec/grape/middleware/formatter_spec.rb +++ b/spec/grape/middleware/formatter_spec.rb @@ -527,4 +527,27 @@ def self.call(_, _) expect(error.original_exception.class).to eq StandardError end end + + # Only a parser's StandardErrors are answered with a 400. Anything else -- + # an Interrupt, a SystemExit -- is not the body's fault and keeps going. + context 'custom parser raises an exception that is not a StandardError' do + it 'lets it through rather than answering 400' do + subject = described_class.new( + app, + parsers: { json: ->(_object, _env) { raise NotImplementedError, 'fatal' } } + ) + io = StringIO.new('{}') + expect do + catch(:error) do + subject.call( + Rack::PATH_INFO => '/info', + Rack::REQUEST_METHOD => Rack::POST, + 'CONTENT_TYPE' => 'application/json', + Rack::RACK_INPUT => io, + 'CONTENT_LENGTH' => io.length.to_s + ) + end + end.to raise_error(NotImplementedError, 'fatal') + end + end end