Skip to content

Validate once per action, so a rejection instruments once - #22

Open
VSN2015 wants to merge 1 commit into
masterfrom
fix/validate-once-per-action
Open

Validate once per action, so a rejection instruments once#22
VSN2015 wants to merge 1 commit into
masterfrom
fix/validate-once-per-action

Conversation

@VSN2015

@VSN2015 VSN2015 commented Sep 4, 2026

Copy link
Copy Markdown
Owner

A second bug fix, found by going looking for siblings of #21 rather than adding features.

The bug

permitted_params is documented as memoized per action — but it only memoized successes. On a violation it raised without storing anything, so the next read revalidated from scratch and fired invalid_parameters.permittable again.

One bad request, several events, double-counted in every dashboard built on that hook — which is the hook the README tells you to build on.

events for one bad request read 4 times: 4   # before
events for one bad request read 4 times: 1   # after

It wasn't a corner case

permittable_violations followed by permitted_params is the pattern the monitor-mode documentation itself suggests for "would this request fail?", and it hit this every single time — permittable_violations triggers a validation pass, swallows the raise, and leaves nothing memoized for the action's own read.

The fix

The memo now remembers the outcome rather than only a value: a rejection is stored and re-raised, so the contract runs — and instruments — exactly once per action per request. The same exception object comes back, not an equal-looking new one.

@permittable_validated ||= {}
if @permittable_validated.key?(action)
  outcome = @permittable_validated[action]
  raise outcome if outcome.is_a?(InvalidParameters)

  return outcome
end

One deliberate exclusion

ArgumentError is still raised fresh every time and never memoized. A contract that doesn't cover the action is a bug to fix, not a verdict on this request — and memoizing it would make the second call look like a rejection. There's a spec pinning that it raises on every call.

Also

Promotes the spec suite's recording_notifications helper out of the monitor-mode block, since the observability block now needs it too.

Verification

  • 203 examples, 0 failures (4 new, written before the fix): one event across four reads, same-object re-raise, ArgumentError never memoized, and a clean read still memoized and silent
  • RuboCop clean

@VSN2015

VSN2015 commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

Code review — #22 Validate once per action

Verdict: ready to merge. The fix does exactly what the description claims, is confined to per-request instance state keyed by action, leaves monitor mode and standalone Contract untouched, and I reproduced the before/after event counts through both the FakeController harness and the real ActionController rescue_from stack. One niche mutation of the memoized exception and two test gaps, none blocking.

Verified

  • bundle exec rspec at 019c89f: 203 examples, 0 failures. rubocop: no offenses.

  • Event counts from a real ActiveSupport::Notifications.subscribe, base 228d14d vs head:

    Scenario Base Head
    FakeController: permittable_violations then 3x permitted_params 4 1
    Real AC: action reads violations then params, default rescue_from renders 2, 422 1, 422, byte-identical body
    Real AC: enforce: true before_action 1 1
    Real AC: mode: :monitor, 3 reads 1 1
    Real AC: :create read twice plus :update in one request 3 2, distinct objects
    Real AC: host rescue_from override reading permittable_violations 1 1
  • Ruby 3.2.2 re-raise semantics: raise e on an exception with a backtrace preserves it. But a first raise where $! is nil leaves @cause undefined, so a later raise e inside a rescue of another exception attaches that exception as cause permanently. Confirmed through the gem: a permitted_params re-read inside rescue IOError leaves the memoized error with cause == #<IOError> for every later read.

Strengths

  • lib/permittable.rb:735-751: memo is @permittable_validated on the controller instance, keyed by action.to_s. Per-request, no class-level or thread-local state; symbol and string action names share a key.
  • :747-751: only InvalidParameters is rescued and stored; ArgumentError and anything from finalize/coercion propagates un-memoized, as the comment and CHANGELOG state.
  • Monitor mode needs no special handling: the pass-through hash is the memoized outcome and the existing spec already pins it.
  • Both entry points (enforce_params_contract :760-767 and permittable_violations :776-787) share the memo, so a host rescue_from override does not re-fire.
  • lib/permittable/contract.rb:96-100: Contract#call! builds a fresh host per call, so the failure memo cannot leak between standalone calls.
  • spec/permittable_spec.rb:33-46 and 644-684 use real subscribe/unsubscribe with ensure, assert identity with be(errors.first), and pin that ArgumentError raises on every call.
  • CHANGELOG and README.md:401, 584 describe the new semantics accurately.

Minor

  1. lib/permittable.rb:738: raise outcome mutates the memoized exception's cause when the re-read happens inside a host rescue of a different exception (see Verified). Impact is low: rescue_from and the envelope ignore cause; only error reporters showing cause chains would display a misleading "caused by IOError". Fix: raise outcome, cause: nil if outcome.is_a?(InvalidParameters). Verified this keeps the same object and backtrace. Also worth one clause in the method comment that the backtrace points at the original raise site, not the second call site.
  2. lib/permittable.rb:736-751: the hit path round-trips through the miss path's rescue. On a memo hit, raise outcome is caught by the method-level rescue InvalidParameters => e, which reassigns the same object and bare-raises. Harmless. A compute-or-fetch shape with a single raise at the bottom would also resolve item 1.
  3. Test gaps against the README's new claims: (a) no spec reads two different actions on one controller instance, so per-action keying of the failure memo is verified only by my repro; (b) the real-ActionController block (spec/permittable_spec.rb:1104-1215) never counts notifications, so "exactly once through the rescue_from stack" is not pinned. Both are a few lines with the existing recording_notifications and IntegrationHarness.

@VSN2015 VSN2015 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of PR #22: Strong fix for instrumentation metrics. Caching validation failures alongside successes ensures that multiple reads in the same request don't double-count rejection metrics.

Comment thread lib/permittable.rb Outdated
if @permittable_validated.key?(action)
outcome = @permittable_validated[action]
raise outcome if outcome.is_a?(InvalidParameters)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Storing outcome in @permittable_validated[action] and immediately re-raising cached InvalidParameters guarantees deterministic single execution of validation and notifications per action.

Comment thread lib/permittable.rb Outdated
@permittable_validated[action] = validate_params_contract!(rule, action)
rescue InvalidParameters => e
# ArgumentError is deliberately NOT memoized: a contract that does not
# cover the action is a bug to fix, not a verdict on this request.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rescuing only InvalidParameters specifically avoids memoizing ArgumentError, ensuring programmer errors or configuration bugs aren't masked across invocations.

permitted_params is documented as memoized per action, but it only
memoized SUCCESSES. On a violation it raised without storing anything,
so the next read revalidated from scratch and fired
invalid_parameters.permittable again — one bad request, several events,
double-counted in every dashboard built on that hook.

It was not a corner case. permittable_violations followed by
permitted_params is the pattern the monitor-mode documentation suggests
for "would this request fail?", and it hit this every time:

  events for one bad request read 4 times: 4   # before
  events for one bad request read 4 times: 1   # after

The memo now remembers the outcome rather than only a value: a
rejection is stored and re-raised, so the contract runs — and
instruments — exactly once per action per request, and the same
exception object comes back rather than an equal-looking new one.

ArgumentError is deliberately still raised fresh every time and never
memoized. A contract that does not cover the action is a bug to fix,
not a verdict on this request, and memoizing it would make the second
call look like a rejection.

Also promotes the spec suite's recording_notifications helper out of
the monitor-mode block, since observability now needs it too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@VSN2015
VSN2015 force-pushed the fix/validate-once-per-action branch from 019c89f to 5e92ed4 Compare September 11, 2026 21:31
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