Copy response headers with merge! instead of merge - #2911
Merged
Conversation
`Grape::Util::Header.new.merge(headers)` appears twice on a request
path, and `merge` is a `dup` plus a `merge!` — so the Header built on
the same line is allocated only to be thrown away. `merge!` fills the
one already in hand and returns it, halving the cost:
* `Grape::API::Instance#call`, on every request when `cascade false` is
set — the usual configuration for Grape mounted inside Rails.
* `Grape::Middleware::Error#rack_response`, on every error response.
Both copies stay. `headers` can come from a mounted Rack app, which is
free to hand back a frozen or shared Hash, and both call sites go on to
write into what they are given. The `cascade false` path is now pinned
by a spec that mounts a Rack app returning a frozen Hash; without the
copy it raises `FrozenError`.
`merge!` behaves the same on both halves of the supported Rack range:
Rack 2.2's `HeaderHash#merge!` is `other.each { |k, v| self[k] = v }`,
and Rack 3 aliases `merge!` to `update`. Both route through the
overridden `[]=`, so header names are still downcased. Note that the
shorter-looking `Grape::Util::Header.new(headers)` is not equivalent:
`Rack::Headers` subclasses `Hash`, so on Rack 3 the argument becomes
the default value and the result is empty.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
ericproulx
force-pushed
the
perf/header-copy-merge-bang
branch
from
September 6, 2026 11:53
d5ff90a to
7deac93
Compare
Danger ReportErrors
Markdowns* [#PRNUM](https://github.com/ruby-grape/grape/pull/PRNUM): Copy response headers with `merge!` instead of `merge` in `Grape::API::Instance#call` and `Grape::Middleware::Error`, which allocated a `Grape::Util::Header` only to discard it - [@ericproulx](https://github.com/ericproulx).
does not include a pull request link |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Follow-up to #2910. Sweeping the request path for the same shape turned up two more sites where a response-header copy costs twice what it needs to:
mergeis adupplus amerge!, so theHeaderbuilt on the same line is allocated only to be thrown away.merge!fills the one already in hand and returns it.Header.new.merge(h)Header.new.merge!(h)Both sites are on live paths:
Grape::API::Instance#call— every request whencascade falseis set, the usual configuration for Grape mounted inside Rails. (The defaultcascade trueskips the branch, which is why it didn't show up in the profile behind Keep content-type negotiation in place as ensure_content_type! #2910.)Grape::Middleware::Error#rack_response— every error response.Both copies stay
Unlike #2910, these are not removable.
headerscan come from a mounted Rack app, which is free to hand back a frozen or shared Hash, and both call sites go on to write into what they are given. Thecascade falsepath is now pinned by a spec that mounts a Rack app returning a frozen Hash — without the copy it raisesFrozenError.Rack compatibility
merge!behaves identically across the supported range (rack >= 2.2.4; CI covers 2.2 / 3.0 / 3.1 / 3.2):Rack::Utils::HeaderHash#merge!isother.each { |k, v| self[k] = v }Rack::Headersdefinesupdateand doesalias merge! updateBoth route through the overridden
[]=, so header names are still downcased.Worth recording for anyone tempted by the shorter form:
Grape::Util::Header.new(headers)is not equivalent.Rack::HeaderssubclassesHash, so on Rack 3 the argument silently becomes the Hash default value and the result is empty. It happens to work on Rack 2.2, so it would only fail half the matrix.Test plan
FrozenError) when the copy is removed.🤖 Generated with Claude Code