Skip to content

Copy response headers with merge! instead of merge - #2911

Merged
ericproulx merged 1 commit into
masterfrom
perf/header-copy-merge-bang
Sep 6, 2026
Merged

Copy response headers with merge! instead of merge#2911
ericproulx merged 1 commit into
masterfrom
perf/header-copy-merge-bang

Conversation

@ericproulx

Copy link
Copy Markdown
Contributor

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:

Grape::Util::Header.new.merge(headers)

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.

obj/call
Header.new.merge(h) 2
Header.new.merge!(h) 1

Both sites are on live paths:

  • Grape::API::Instance#call — every request when cascade false is set, the usual configuration for Grape mounted inside Rails. (The default cascade true skips 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. 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.

Rack compatibility

merge! behaves identically across the supported range (rack >= 2.2.4; CI covers 2.2 / 3.0 / 3.1 / 3.2):

  • Rack 2.2 — Rack::Utils::HeaderHash#merge! is other.each { |k, v| self[k] = v }
  • Rack 3 — Rack::Headers defines update and does alias merge! update

Both 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::Headers subclasses Hash, 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

  • Full RSpec suite passes locally (2836 examples, 0 failures).
  • New spec verified to fail (FrozenError) when the copy is removed.
  • RuboCop clean on the changed files.
  • CI green.

🤖 Generated with Claude Code

`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
ericproulx force-pushed the perf/header-copy-merge-bang branch from d5ff90a to 7deac93 Compare September 6, 2026 11:53
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

Danger Report

Errors

  • One of the lines below found in CHANGELOG.md doesn't match the expected format. Please make it look like the other lines, pay attention to version numbers, periods, spaces and date formats.

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

View run

@ericproulx
ericproulx merged commit abd3739 into master Sep 6, 2026
69 checks passed
@ericproulx
ericproulx deleted the perf/header-copy-merge-bang branch September 6, 2026 12:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant