A gem we were building read Rails encrypted credentials directly. It worked fine on Rails 7.1 and later. On Rails 7.0 it raised:

ArgumentError: wrong number of arguments (given 0, expected 1)

from three stack frames inside ActiveSupport:

active_support/core_ext/module/delegation.rb:304:in `key?'
active_support/core_ext/module/delegation.rb:304:in `public_send'
active_support/core_ext/module/delegation.rb:304:in `method_missing'

Nothing in that trace names the line that actually called it.

The bug was a method that doesn't exist yet on the Rails version we were testing.

The setup

The call was simple:

encrypted = ActiveSupport::EncryptedConfiguration.new(
  config_path: config_path,
  key_path: key_path,
  env_key: "SOME_KEY_NOTHING_SETS",
  raise_if_missing_key: true
)

encrypted.key?

#key? is supposed to answer "is a decryption key available here," without raising the way #key does when raise_if_missing_key is set. It's the safe way to check before you read.

Why it happens

EncryptedFile#key? was added in Rails 7.1. On 7.0 and earlier the method simply doesn't exist. Rather than a clean NoMethodError, EncryptedConfiguration declares delegate_missing_to :options, so the missing call falls through to the underlying options object, an ActiveSupport::OrderedOptions, which behaves like a Hash. Hash#key? exists, but it takes one required argument, the key to look up. Called with zero arguments, you get an arity error from a method you never meant to call, on an object you didn't know was involved.

The fix

Stop calling it. In our case the check deliberately passes an env_key that nothing ever sets, so the key file on disk is the only possible source of the key. key_path.exist? is exactly equivalent for that purpose, and it behaves the same on every Rails version:

key_path.exist? # instead of encrypted.key?

Generalize that: when a predicate you want is version dependent, check the underlying condition yourself instead of trusting the method to exist.

If you genuinely need the method itself, respond_to? doesn't work here. delegate_missing_to also defines respond_to_missing? and forwards it to the same target, so respond_to?(:key?) returns true on 7.0 and the call fails anyway. Ask the class instead, which checks the ancestry rather than method_missing:

ActiveSupport::EncryptedConfiguration.method_defined?(:key?)
# => true on 7.1, false on 7.0

Takeaway

delegate_missing_to converts "this method doesn't exist on this version" into a confusing arity or type error somewhere else entirely. A plain NoMethodError tells you what's missing and where. Delegation hides both. If you support multiple framework versions, don't assume a missing method will announce itself as missing, it might announce itself as a different method failing for a different reason. We found this one while running a gem's specs across an Appraisal matrix of ActiveSupport versions.