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
.callunreported, 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
fatalneeds no entry: it is unrescuable, sorescue Exceptionnever sees it. [SystemStackError, ScriptError].freeze
Class Method Summary collapse
-
.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).
- .config ⇒ Object
-
.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.
-
.raises_in_dev? ⇒ Boolean
Whether a guarded failure is re-raised rather than logged — the dev-loud mode.
-
.swallowable?(exception) ⇒ Boolean
Undispatched ancestry, not
exception.is_a?.
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 |
.config ⇒ Object
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.
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.
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.
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 |