Module: Axn::Extensions

Defined in:
lib/axn/extensions.rb,
lib/axn/exceptions.rb,
lib/axn/extensions/config.rb,
lib/axn/extensions/serialization.rb

Overview

The extension-author surface: "for gems building on axn," distinct from Axn::Internal (private) and the user-facing DSL. Not Ruby core-ext/refinements — this is the API sibling gems (Axn::Webhooks, Axn::MCP, ...) may rely on.

Defined Under Namespace

Modules: Serialization Classes: Config

Constant Summary collapse

SWALLOWABLE_BEYOND_STANDARD_ERROR =

The ONLY non-StandardError classes axn will ever swallow — both in a side-channel guard (best_effort) and when settling an exception onto a result (Core::Executor). One list, because both answer the same question: may axn absorb this instead of letting it through?

An ALLOWLIST, deliberately, and never a denylist of "everything that isn't a signal". The set of non-StandardError exceptions in a live process is OPEN — Ruby defines a stable handful, but gems and stdlib add their own direct Exception subclasses (Timeout::ExitException, ActiveSupport::ErrorReporter::UnexpectedError, CGI::InvalidEncoding), and a library is free to invent one tomorrow. Several exist PRECISELY so that nothing swallows them: absorbing Timeout::ExitException makes an enclosing Timeout.timeout silently not fire, and ErrorReporter::UnexpectedError is raised outside StandardError for exactly that reason.

The two ways of being wrong are not symmetric. Swallow something we shouldn't and we silently break another library's control flow — the hardest class of bug to trace. Fail to swallow something we could have, and an unrecognized non-StandardError escapes .call unreported, which is merely the status quo for anything not yet listed, and is fixed by adding a line here.

Both members are unambiguously faults in the code being run, never a signal to anyone:

* SystemStackError — runaway recursion.
* ScriptError — and so NotImplementedError (an unfinished method), LoadError, SyntaxError.

Ruby's fatal needs no entry: it is unrescuable, so rescue Exception never sees it.

[SystemStackError, ScriptError].freeze

Class Method Summary collapse

Class Method Details

.best_effort(desc, action: nil, standard_errors_only: false) ⇒ Object

Runs the block, guarding a best-effort side effect (a hook, callback, observability facet, or a reporter that itself throws). The exception is logged and swallowed (returning nil) so it never breaks the main action flow — EXCEPT in development when Axn.config.best_effort_raises_in_dev is set, where it re-raises (as Axn::ReraiseFailed carrying the original as cause for the rare exception raise cannot hand back as itself). desc names the intent ("resolving webhook subscribers"); action is an optional warn-target (an action instance/class responding to :warn), defaulting to the config logger.

Swallows StandardError plus SWALLOWABLE_BEYOND_STANDARD_ERROR — the right default almost everywhere, and required for a true side channel whose outcome nothing reads (emitting a log line, updating a span, reporting to an error tracker).

standard_errors_only: true narrows it to StandardError, letting the allowlisted classes through. Justified only when escaping beats swallowing, which needs BOTH: nothing already committed at that point, AND an executor boundary that will settle the escape into a reported result instead of re-raising. Resolving a model: record qualifies — it runs inside its own action's validation, so a runaway finder surfaces as a reported exception result naming the real stack rather than a misleading "can't be blank". A post-fan-out callback does NOT: jobs are already enqueued and the orchestrator is an async job whose adapter re-raises an exception outcome, so an escape gets the batch enqueued twice. When in doubt, use the default.

The flag is pinned to the StandardError class boundary, so its meaning cannot drift as the allowlist above grows.



107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/axn/extensions.rb', line 107

def best_effort(desc, action: nil, standard_errors_only: false)
  if standard_errors_only
    begin
      yield
    rescue StandardError => e
      _warn_and_swallow(e, desc, action)
    end
  else
    begin
      yield
    rescue StandardError, *SWALLOWABLE_BEYOND_STANDARD_ERROR => e
      _warn_and_swallow(e, desc, action)
    end
  end
end

.configObject



80
81
82
# File 'lib/axn/extensions.rb', line 80

def config
  @config ||= Config.new
end

.owned_failure?(exception) ⇒ Boolean

True when axn owns this exception's #message — an Axn::Failure, or a user-facing validation error — so the message is meant for the client and may carry a resolved presentation. A FOREIGN exception reclassified via fails_on is not owned: it travels axn's failure path, but its #message is a technical cause, and an adapter surfacing it would leak internals to a caller.

Returns:

  • (Boolean)


76
77
78
# File 'lib/axn/extensions.rb', line 76

def owned_failure?(exception)
  exception.is_a?(Axn::Failure) || Axn::ValidationError.user_facing?(exception)
end

.raises_in_dev?Boolean

Whether a guarded failure is re-raised rather than logged — the dev-loud mode. Exposed so anything that has to reason about what best_effort will DO consults the same condition rather than restating it.

A seam that cannot answer means NOT dev-loud. Both reads are into caller-owned config — the setting and the env object a host application supplies — and a half-booted or misconfigured one raises here, which would make FAILING TO DECIDE the answer: best_effort is called from ensure blocks throughout the executor, so a raise from the decision replaces the exception already in flight with one manufactured while working out how to report it. The two directions are not symmetric. Answering false where true was configured loses a deliberately loud raise in development, which is a development-time annoyance; answering by raising turns swallow-and-log into an escape in any environment, which is the failure this whole guard exists to prevent.

Narrow on the same terms as everything else here: a signal is not a broken config, and axn absorbs one nowhere.

Returns:

  • (Boolean)


53
54
55
56
57
# File 'lib/axn/extensions.rb', line 53

def raises_in_dev?
  Axn.config.best_effort_raises_in_dev && Axn.config.env.development?
rescue StandardError, *SWALLOWABLE_BEYOND_STANDARD_ERROR
  false
end

.swallowable?(exception) ⇒ Boolean

Undispatched ancestry, not exception.is_a?. Not as a defense against exceptions that lie about themselves — that is unwinnable — but because the object's opinion is not the question. This predicate decides whether axn may SWALLOW something, and the only thing that authorizes that is the allowlist actually being in the class's ancestry. Asking the instance made the answer depend on a method the instance defines; Module#=== makes it depend on the hierarchy, which is deterministic for every input. Same seam the span type check and the validate: String check already use.

Returns:

  • (Boolean)


66
67
68
69
70
# File 'lib/axn/extensions.rb', line 66

def swallowable?(exception)
  return true if Internal::Identity.kind?(exception, StandardError)

  SWALLOWABLE_BEYOND_STANDARD_ERROR.any? { |klass| Internal::Identity.kind?(exception, klass) }
end