`expr || :sentinel` silently breaks when expr returns truthy non-sentinel
Setup
I was implementing a polling loop for a client API. The contract: call a remote service, wait for the response to be available, poll with exponential backoff (1s → 5s → 10s + jitter, capped at 5 minutes). When the response was ready, extract the body and return it. The test was straightforward — stub the remote calls, assert the loop calls Kernel.sleep with the right intervals, capture the final body.
The test hung. 28 seconds of real Kernel.sleep burned before I killed it. The stub wasn't preventing actual sleep — the loop was calling the real method because my return-value logic was broken.
Attempt
The polling method looked like this:
def download_export(session_token)
poll_strategy.call do
response = fetch_from_service(session_token)
return capture_body(response) || :done if response.is_a?(Net::HTTPSuccess)
end
end
I was using the || :done pattern to express: "if capture_body returns truthy, return it; otherwise signal :done to stop polling." That idiom works fine when the LHS is nil/false on every path. But capture_body returned the response body — a string. Strings are truthy in Ruby. The || short-circuited, never evaluating the sentinel, and the method returned the body string.
The caller's code expected either the body or the sentinel :done:
case step
when :done
break
else
# sleep and retry
end
Since step was the body string (truthy, non-sentinel), the case hit the else branch, the loop re-entered, and Kernel.sleep ran again. The loop never exited.
Signal
Swapping the return statement to separate concerns:
def download_export(session_token)
poll_strategy.call do
response = fetch_from_service(session_token)
if response.is_a?(Net::HTTPSuccess)
body = capture_body(response)
return body
end
end
end
The polling strategy itself returns :done when the block returns truthy. The loop exited on the first success. The test ran in 0.13 seconds.
Why it worked
Side effects (mutation, external calls) and control-flow decisions should be separate statements. When you chain a return value into ||, you're relying on the LHS to be nil/false always. That's a hidden contract buried in the syntax. The moment the LHS becomes truthy-but-not-the-sentinel, the idiom collapses silently — no error, no warning, just a stuck loop.
The fix isn't about Ruby; it generalizes to Python or, JavaScript ||, any language with boolean-or short-circuiting. Extract the side effect (call the service, extract the body), make the decision (should we return or keep looping?) explicit in a separate statement. The code is clearer, the invariants are obvious, and you won't spend 28 seconds debugging a test that silently hangs.