How `attr_accessor :method` Broke Rails' `try` Method

Davy Cardinaal ·

I've been upgrading an old Rails application. And with old, I mean old. It started life on Rails 4.2, and I've been methodically dragging it into the present, one minor version at a time. The jump from 4.2 to 5.0 went remarkably smoothly. Green test suite, clean deploy, no surprises. So when it was time for Rails 5.1, I felt confident. Tests passing, staging looked good, so I deployed to production.

Within minutes, Sentry lit up. Customers could not complete payments. The payment gateway's webhook was failing, and customers who had just paid were staring at error pages instead of confirmation screens. Ten errors across two different controllers in no time. I rolled back immediately and started digging.

TL;DR — A gem declared attr_accessor :method, overriding Ruby's built-in Object#method. After upgrading to Rails 5.1, this caused try to infinitely recurse via respond_to?, crashing payment flows with SystemStackError: stack level too deep. The fix: replace try with Ruby's safe navigation operator (&.).

The SystemStackError

Both Sentry issues reported the same error: SystemStackError: stack level too deep. That's Ruby telling you it ran out of call stack space. Usually this means infinite recursion. The stack traces confirmed it. Hundreds upon hundreds of the exact same line:

from active_support/core_ext/object/try.rb:6:in `try`
try!(*a, &b) if a.empty? || respond_to?(a.first)

The same line, repeated until Ruby gave up. Rails' try method was calling itself, over and over, until the stack overflowed. Two different controllers, two different code paths, same explosion. The common denominator was obvious: both paths dealt with our payment gateway.

Tracing the Code

The failing code was straightforward. In our webhook controller, we had a line that checked if a payment had valid metadata:

payment.try(:metadata).try(:payment_id)

And in our payment service, we extracted consumer details from the payment:

payment.details.try(:consumer_name)

Nothing exotic here. This is textbook Rails, using try for safe method access on objects that might not respond to a given message. This code had worked perfectly on Rails 5.0. Same payment gem version, same Ruby version. Only Rails changed. So why was try suddenly eating its own stack?

The Root Cause: attr_accessor :method

This is where Claude Code proved invaluable. I pointed it at the two Sentry errors and asked it to investigate. Within minutes, it had traced through the payment gem's source code and surfaced the root cause: one line buried in the gem's Payment class definition.

attr_accessor :id, :status, :amount, :method, :metadata, :details, ...

Did you catch it? Right there in the middle of a perfectly ordinary attribute list: attr_accessor :method.

That single declaration overrides Object#method, one of the most fundamental methods in Ruby's object model.

Without Claude Code, I would have been reading gem source files, cross-referencing stack traces, and manually tracing method dispatch chains. The kind of investigation that easily eats an afternoon. Instead, it connected the dots between the Sentry stack traces, the gem's internals, and the try implementation in about the time it takes to make a coffee.

Why Object#method Matters

Every Ruby object inherits method from Object. It's not some obscure corner of the standard library. It's a core introspection method that returns a Method object, allowing you to inspect, bind, and call methods programmatically. Ruby's internal machinery, including parts of respond_to?, method dispatch, and frameworks that build on top of these, relies on Object#method behaving as expected.

When the payment gem declares attr_accessor :method, it replaces this introspection method with a simple getter that returns a string like "ideal" or "creditcard". From the payment provider's perspective this makes perfect sense: a payment has a method (iDEAL, credit card, etc.), and method is the natural name for that attribute. But from Ruby's perspective, you've just knocked out a load-bearing wall.

How try Creates Infinite Recursion

Here's how try works under the hood:

def try(*a, &b)
try!(*a, &b) if a.empty? || respond_to?(a.first)
end

Before calling a method, try asks the object: "Do you respond to this?" via respond_to?. If yes, it proceeds with public_send. This is the key difference from Ruby's safe navigation operator (&.), which only checks for nil.

On a normal object, this is perfectly fine. But on the Payment, where Object#method has been replaced with a string-returning accessor, the method dispatch infrastructure that respond_to? relies on gets confused. What should be a simple "yes, I have this method" check spirals into try calling itself recursively, hundreds of times, until Ruby's stack is exhausted.

The stack trace tells the story. There is no public_send frame, no try! frame. Just try at line 6, over and over. The recursion happens inside the respond_to? check. We never even make it to the actual method call.

To make matters worse, the payment gem also wraps its metadata and details attributes in OpenStruct. OpenStruct uses method_missing and respond_to_missing? for dynamic attribute access. Combined with the overridden Object#method, this creates the perfect storm for try's method resolution to go haywire.

Why It Worked on Rails 5.0

This is perhaps the most unsettling part. The code was a ticking time bomb the entire time. The attr_accessor :method override existed in the payment gem long before our upgrade. The try calls existed in our code for just as long. On Rails 5.0, the specific combination of method dispatch, respond_to?, and Tryable module loading happened to work. Not because it was correct, but because the internal plumbing happened to avoid the pathological case.

Rails 5.1 didn't change the try implementation itself. The code is character-for-character identical between 5.0 and 5.1. But Rails 5.1 made subtle changes to its internals: middleware ordering, method dispatch in ActionDispatch, how modules are loaded and included. One of these changes shifted the dominoes just enough that try on a Payment now triggered the recursion that had been lurking all along.

This is what makes these bugs so dangerous. They're not caused by a single incorrect line of code. They're emergent, they arise from the interaction between multiple components, each of which is reasonable in isolation. A gem that names an attribute method. A framework helper that uses respond_to?. An upgrade that subtly changes method dispatch. None of these are bugs on their own. Together, they're a SystemStackError on your payment page.

The Fix: Replace try With &.

The fix itself was almost anticlimactic. Replace try with Ruby's safe navigation operator:

# Before
payment.try(:metadata).try(:payment_id)
payment.details.try(:consumer_name)

# After
payment.metadata&.payment_id
payment.details&.consumer_name

Eight lines changed. The safe navigation operator (&.) works fundamentally differently from try. It doesn't call respond_to?. It doesn't use public_send. It simply checks: is the receiver nil? If yes, return nil. If no, call the method normally through standard Ruby method dispatch. No introspection, no intermediate checks, no opportunity for recursion.

Testing With Real Objects

There was a second lesson hiding in this bug. Our existing test suite had full coverage of the payment flow, yet it didn't catch this. Why? Because our tests used double and Struct to mock the payment objects. These test doubles didn't have attr_accessor :method. They didn't wrap attributes in OpenStruct. They were well-behaved Ruby objects that responded to the right methods and returned the right values. The tests were verifying behavior, but on objects that didn't share the dangerous characteristics of the real thing.

I added regression tests that instantiate actual Payment objects with real payment data. Not doubles, not structs. The actual class from the actual gem, with its actual attr_accessor :method and its actual OpenStruct wrapping. These tests would have caught the bug before it reached production.

Takeaways

Be wary of gems that override core Ruby methods. Object#method, Object#class, Object#send, Object#respond_to?. These are the bedrock of Ruby's object model. Any gem that overrides them is a risk, even if it works today. Audit your dependencies. The payment gem had a perfectly good reason to call the attribute method, but the consequences were severe.

try and &. are not interchangeable. They look similar and often produce the same result, but they work very differently. try uses respond_to? and public_send, engaging Ruby's full method resolution machinery. &. is a simple nil guard with direct method dispatch. On well-behaved objects, you'll never notice the difference. On objects that override core methods, the difference is between a working application and a SystemStackError.

Test with real objects at your boundaries. Mock objects are great for isolating behavior, but they can hide integration bugs. For external dependencies, especially ones that do unusual things with Ruby's object model, include at least one test path that uses the real class.

Rails upgrades can expose latent bugs. The bug wasn't introduced by Rails 5.1. It was always there, waiting. The upgrade just changed the conditions enough to trigger it. This is why incremental upgrades with good error monitoring are essential. I caught this in minutes because Sentry alerted immediately. Without that, customers would have been silently failing to pay.

Use the right tools for investigation. The whole investigation, from first Sentry alert to deployed fix, took about two hours. Claude Code did the heavy lifting: reading gem source code, connecting stack traces to root causes, and identifying the exact interaction between try, respond_to?, and the overridden Object#method. Most of that two hours was spent understanding why try was recursing. The actual fix was trivial. That's often how it goes with these kinds of bugs. The hard part isn't writing the code. It's understanding the invisible interactions between the layers of abstraction we build on.