Skip to content

Stop reassigning method parameters across lib - #2908

Merged
ericproulx merged 1 commit into
masterfrom
no-param-reassign-error-middleware
Sep 6, 2026
Merged

Stop reassigning method parameters across lib#2908
ericproulx merged 1 commit into
masterfrom
no-param-reassign-error-middleware

Conversation

@ericproulx

@ericproulx ericproulx commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

A method that reassigns one of its parameters gives the same name two meanings: what the signature documents, and what the lines below the reassignment actually hold. Nothing escapes to the caller — Ruby parameters are local bindings — but the reader has to carry the reassignment for the rest of the method, and a later edit that moves a line above it changes behaviour silently.

This is a sweep of every such site in lib. Each derived value gets its own local; the parameter keeps what the caller passed.

The two that mattered

Most of the diff is naming. Two sites were reassignment wrapped around a mutation of the argument itself, and those did escape.

Middleware::Formatter#ensure_content_type wrote into the response headers Hash and returned the same object, which the caller then reassigned over its own parameter:

headers = ensure_content_type(headers)   # caller

def ensure_content_type(headers)
  return headers if headers[Rack::CONTENT_TYPE]

  headers[Rack::CONTENT_TYPE] = content_type_for(env[Grape::Env::API_FORMAT])
  headers
end

The Hash belongs to the response the app returned; negotiating a content type for it is not a reason to reach back into it. It merges now. Grape::Util::Header#merge preserves the class and its case-insensitive lookup on both Rack 2 (Rack::Utils::HeaderHash) and Rack 3 (Rack::Headers).

ParamsScope#process_oneof! wrote the collected variants back into the options Hash the requires / optional call site built:

validations[:oneof] = variants.map { |block| OneofCollector.collect(block) }

#validates only copied that Hash on the required path (validations.merge(presence: …)), so on the optional path the write landed on the caller's Hash. It is contained today only because **opts manufactures a fresh Hash per call — an accidental defensive copy, one signature change away from being load-bearing. #collected_oneof returns the variants now and #validates merges them.

The rest

File Method Parameter → local
dsl/desc.rb desc optionsresolved_options
dsl/inside_route.rb error! statusresolved_status
dsl/parameters.rb params paramsscoped
dsl/parameters.rb declare asdeclared_as
dsl/routing.rb mount optsmount_opts
dsl/routing.rb route_param requirementsparam_requirements
error_formatter/base.rb present messagepayload
exceptions/validation.rb initialize messagetranslated
middleware/error.rb Options#initialize rescue_options, default_error_formatter
middleware/error.rb rack_response messagebody
middleware/error.rb run_rescue_handler handlercallable
middleware/formatter.rb build_formatted_response headerstyped_headers
middleware/formatter.rb read_rack_input bodyparsed
middleware/stack.rb insert, insert_after indexat
util/path_normalizer.rb call pathnormalized
validations/contract_scope.rb initialize contractdeclared / schema
validations/params_scope.rb validates validationsdeclared
validations/types.rb create_coercer_instance typemapped
types/multiple_type_coercer.rb call valcandidate
types/variant_collection_coercer.rb call valuecoerced
validations/validations_spec.rb validate_value_coercion coerce_typeelement_type

Three are worth a second look:

  • run_rescue_handler is the strongest case, because the type changed rather than the value. handler arrives as a Symbol, a Method or a Procrescue_from with: :some_endpoint_method produces the first, find_handler's method(:error_response) the second, a rescue_from block the third — and arity / & are valid only after resolution. The method re-enters itself from five places, so tracing the recursion meant re-deriving the shape on each pass. callable states the invariant.
  • path_normalizer.rb and error_formatter/base.rb reassign in order to protect the caller: path = "/#{path}" before a chain of squeeze! / delete_suffix! / gsub!, and message = message.dup before delete(:with). The naming was what made that hard to see — it reads as mutating the argument. normalized and payload say the mutated object is a fresh one, and path_normalizer gets a comment recording why the bangs are safe.
  • middleware/error.rb's Options#initialize needed super(...) with explicit keywords instead of zsuper, since bare super forwards whatever the parameters currently hold — which is exactly the pressure that produces x ||= default in a Data initializer.

Not in scope

Grape::Endpoint::Options has the same shape and is fixed separately in #2907, where the reassignment is hiding a real bug rather than only obscuring one.

Verification

  • bundle exec rspec — 2703 examples, 0 failures.
  • bundle exec rubocop — 343 files, no offenses.
  • gemfiles/grape_entity.gemfile (exercises error_formatter/base.rb#present) — 2726 examples, 0 failures.
  • gemfiles/dry_validation.gemfile (exercises contract_scope.rb) — 13 examples, 0 failures.
  • gemfiles/multi_xml.gemfile has 3 pre-existing failures in api_spec.rb's XML error-format examples; identical on master at the same seed, so unrelated to this change.

No new specs: nothing new is asserted, and the behavioural half is covered by the existing formatter and oneof suites.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

Danger Report

No issues found.

View run

@ericproulx
ericproulx marked this pull request as ready for review September 5, 2026 18:05
@ericproulx
ericproulx force-pushed the no-param-reassign-error-middleware branch 2 times, most recently from ab3d147 to 9d7711c Compare September 5, 2026 18:31
@ericproulx ericproulx changed the title Stop reassigning parameters in Grape::Middleware::Error Stop reassigning method parameters across lib Sep 5, 2026
A parameter reassigned partway through a method means the name documents
one thing in the signature and holds another below it. Every site in
`lib` now binds the derived value to its own local, so a parameter keeps
what the caller passed for the whole method.

Two of them were reassigned to hide a mutation of the argument itself,
and those stop writing into what they were given:

* `Middleware::Formatter#ensure_content_type` merged a Content-Type into
  the response headers Hash in place, then returned it.
* `ParamsScope#process_oneof!` wrote the collected variants back into the
  options Hash from the `requires`/`optional` call site. Now
  `#collected_oneof` returns them and the caller merges.

`Endpoint::Options` is left alone; it is fixed in #2907.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ericproulx
ericproulx force-pushed the no-param-reassign-error-middleware branch from 9d7711c to d67b897 Compare September 6, 2026 11:02
@ericproulx
ericproulx merged commit 5c4c0b1 into master Sep 6, 2026
69 checks passed
@ericproulx
ericproulx deleted the no-param-reassign-error-middleware branch September 6, 2026 11:07
ericproulx added a commit that referenced this pull request Sep 6, 2026
#2908 stopped `Grape::Middleware::Formatter#ensure_content_type` from
writing into the headers Hash it was handed, returning
`headers.merge(Rack::CONTENT_TYPE => ...)` instead. That copy runs on
almost every response, and the inline pair made it cost three
allocations rather than one: Ruby builds the one-pair Hash literal, then
copies it again converting the implicit keyword Hash into a positional
argument for `Hash#merge`, and `Rack::Headers#merge` dups the receiver
on top of that.

Measured over 2000 requests through a formatted endpoint:

    merge      19906.25 kB / 196,000 objects
    in place   18968.75 kB / 190,000 objects

Three objects per response on the hot path, for a header the caller is
about to send anyway. The negotiation goes back to writing into
`headers`, and the method carries a `!` so the mutation is visible at
the call site. `build_formatted_response` drops the `typed_headers`
local it only needed in order to hold the copy, so `headers` is still
never reassigned, which is what #2908 was after.

The trade is that a mounted plain Rack app returning a frozen or shared
headers Hash is written into again, as it was before #2908.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
ericproulx added a commit that referenced this pull request Sep 6, 2026
#2908 stopped `Grape::Middleware::Formatter#ensure_content_type` from
writing into the headers Hash it was handed, returning
`headers.merge(Rack::CONTENT_TYPE => ...)` instead. That copy runs on
almost every response, and the inline pair made it cost three
allocations rather than one: Ruby builds the one-pair Hash literal, then
copies it again converting the implicit keyword Hash into a positional
argument for `Hash#merge`, and `Rack::Headers#merge` dups the receiver
on top of that.

Measured over 2000 requests through a formatted endpoint:

    merge      19906.25 kB / 196,000 objects
    in place   18968.75 kB / 190,000 objects

Three objects per response on the hot path, for a header the caller is
about to send anyway. The negotiation goes back to writing into
`headers`, and the method carries a `!` so the mutation is visible at
the call site. `build_formatted_response` drops the `typed_headers`
local it only needed in order to hold the copy, so `headers` is still
never reassigned, which is what #2908 was after.

The trade is that a mounted plain Rack app returning a frozen or shared
headers Hash is written into again, as it was before #2908.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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