NAME

App::Actions::Error - an exception that carries a stable code

SYNOPSIS

Tell one failure from another by its code:

use App::Actions::Error;
use App::Actions::Workflow;

my $workflow = eval { App::Actions::Workflow->load($file) };
if (!$workflow) {
	my $error = App::Actions::Error->caught($@);
	if ($error && $error->is('workflow.file_missing')) {
		print 'No workflow at ', $error->args->{file}, "\n";
	} else {
		die $@;    # not one we handle: pass it on
	}
}

Handle a whole family of failures:

if (my $error = App::Actions::Error->caught($@)) {
	exit 3 if $error->is('runner.image.');    # any image problem
}

Show any failure to a person, without the file and line number:

eval { $runner->run; 1 }
	or print STDERR 'action-run: ', App::Actions::Error->describe($@), "\n";

Code that treats exceptions as strings keeps working:

eval { App::Actions::Workflow->load('missing.yml') };
print "not found\n" if $@ =~ /does not exist/;

DESCRIPTION

When something goes wrong, this distribution throws (dies with) an App::Actions::Error object. The object has:

  • a code, such as workflow.file_missing. The code never changes when the message is translated or reworded, so your program can check it safely;

  • a message, such as Workflow file 'ci.yml' does not exist, in the user's language;

  • the arguments used to build the message, such as { file => 'ci.yml' };

  • a location, such as at lib/App/Actions.pm line 42., the same text croak adds.

As a string, the object is exactly the message followed by the location: the same text croak would have died with. So older code that reads $@ as a string, prints it or matches it with a regular expression sees no difference.

You do not usually make these objects yourself. Code inside the distribution calls $self->throw($code, \%args) from App::Actions::I18N, which looks the code up in the message catalogue, translates and fills in the message, and throws the object. The codes are the keys of the catalogue in App::Actions::I18N::en.

METHODS

new

Creates an exception without throwing it. Most code never calls this: it uses $self->throw($key, \%args) from App::Actions::I18N, which renders the message and records where it was thrown from.

ARGUMENTS

  • code - the catalogue key, such as workflow.no_jobs.

  • message - the rendered text, without a location.

  • args - optional; the values the message was rendered with.

  • location - optional; what follows the message when it is stringified, normally " at FILE line N.\n". Defaults to "\n".

RETURNS

An App::Actions::Error object.

SIDE EFFECTS

None.

EXAMPLE

my $error = App::Actions::Error->new(
	code    => 'workflow.empty',
	message => "Workflow 'ci.yml' is empty",
	args    => { file => 'ci.yml' },
);
die $error;

API SPECIFICATION

INPUT

{
	code     => { type => 'string', matches => qr/\A[a-z][a-z0-9_]*(?:\.[a-z0-9_]+)+\z/ },
	message  => { type => 'string' },
	args     => { type => 'hashref', optional => 1 },
	location => { type => 'string', optional => 1 },
}

OUTPUT

{ type => 'object', isa => 'App::Actions::Error' }

MESSAGES

+---------------------------+------------------------------+-----------------------------+
| Message                   | Meaning                      | Resolution                  |
+---------------------------+------------------------------+-----------------------------+
| Invalid arguments to      | A required argument is       | Pass a dotted code and a    |
| App::Actions::Error::new  | missing or has the wrong type| message string              |
+---------------------------+------------------------------+-----------------------------+

code

Returns the catalogue key the exception was raised with.

EXAMPLE

print $error->code;    # workflow.file_missing

API SPECIFICATION

INPUT

{}

OUTPUT

{ type => 'string' }

MESSAGES

None.

message

Returns the rendered message without the location, as a person would want to read it.

EXAMPLE

print STDERR 'action-run: ', $error->message, "\n";

API SPECIFICATION

INPUT

{}

OUTPUT

{ type => 'string' }

MESSAGES

None.

args

Returns a copy of the values the message was rendered with, so a caller can act on the file, job or image the failure is about.

EXAMPLE

my $file = $error->args->{file};

API SPECIFICATION

INPUT

{}

OUTPUT

{ type => 'hashref' }

MESSAGES

None.

location

Returns what follows the message when it is stringified: " at FILE line N.\n", a stack trace for an exception raised with trace => 1, or "\n" for one that names no place in the code.

EXAMPLE

warn 'raised', $error->location;

API SPECIFICATION

INPUT

{}

OUTPUT

{ type => 'string' }

MESSAGES

None.

text

Returns the message followed by the location: what the object stringifies to, and what croak would have died with.

EXAMPLE

is("$error", $error->text, 'stringification is the text');

API SPECIFICATION

INPUT

{}

OUTPUT

{ type => 'string' }

MESSAGES

None.

is

Says whether the exception has a code, or belongs to a family of codes.

ARGUMENTS

  • $code - a full code (workflow.no_jobs), or a prefix ending in a dot (workflow.) for every code that starts with it.

RETURNS

1 or 0.

EXAMPLE

if ($error->is('runner.image.')) { ... }    # any image failure

API SPECIFICATION

INPUT

{ code => { type => 'string', min => 1 } }

OUTPUT

{ type => 'boolean' }

MESSAGES

None.

caught

my $error = App::Actions::Error->caught($@);

Returns the value if it is an App::Actions::Error (or a subclass of the class it is called on), and undef otherwise, so a caller can ask for the code without first checking what kind of value $@ holds.

EXAMPLE

if (my $error = App::Actions::Error->caught($@)) {
	exit 70 if $error->is('schema.invalid');
}

API SPECIFICATION

INPUT

{ error => { type => 'any', optional => 1 } }

OUTPUT

{ type => 'object', isa => 'App::Actions::Error', optional => 1 }

MESSAGES

None.

describe

print STDERR 'action-run: ', App::Actions::Error->describe($@), "\n";

Returns any exception as a sentence for a person: the message of an App::Actions::Error, or a string exception with its final " at FILE line N." and trailing white space removed. A position the message gives of its own, such as JSON::PP's at character offset 9, is kept, because it is part of the diagnosis.

ARGUMENTS

  • $error - an exception: an object or a string. undef gives the empty string.

RETURNS

A string.

EXAMPLE

App::Actions::Error->describe("No job matched 'x' at lib/App/Actions.pm line 9.\n");
# No job matched 'x'

API SPECIFICATION

INPUT

{ error => { type => 'any', optional => 1 } }

OUTPUT

{ type => 'string' }

MESSAGES

None.

LIMITATIONS

  • Only messages raised through the catalogue are objects. Errors from Perl itself, from CPAN modules and from code that still calls croak with a literal are strings; "describe" and "caught" accept both so a caller does not have to know which it was given.

  • The code is the catalogue key, so renaming a key is an interface change. The keys are listed in the language modules under App::Actions::I18N.

  • The arguments are copied when the exception is made, but values in them that are references are shared with the code that threw it.

COMMON PITFALLS

  • Do not match the message text. It is translated, and its wording may change. Use "is" with the code.

  • Not every exception is an object. Errors from Perl and from other modules are strings. Use "caught", which returns undef for them, before you call a method on $@.

  • is needs a trailing dot for a family. is('runner.image') matches only that exact code; is('runner.image.') matches every code that starts with runner.image..

  • args returns a copy of the hash, but not of what is inside it. If an argument is a reference, changing what it points to changes the error too.

  • A method call does not work inside a string. "$error->code" prints the whole error followed by ->code. Write '...' . $error->code.

  • The object is always true. Even an error with an empty message counts as true, so if ($@) works as expected.

  • Keep a copy of $@. Any eval you run later (even inside a destructor) can reset it. Write my $error = $@; first.

ENCODING

The message is a Perl character string. It may contain any Unicode text, including non-ASCII characters and emoji, because it may be a translation (for example into Chinese) and may quote a workflow's own text. Set an encoding on the handle before you print it, for example binmode STDERR, ':encoding(UTF-8)'. The code is always ASCII: lower-case letters, digits, _ and dots. The location is the file name as Perl knows it.

AUTHOR

Nigel Horne, njh@nigelhorne.com

FORMAL SPECIFICATION

This section describes each routine in the Z notation, a precise mathematical language for describing programs. You do not need to read it to use the module: everything here is also said in plain words in the sections above. It is for readers who want to check exactly what a routine promises.

How to read it: a box names a routine. The lines above the middle rule declare the values it works with. A name ending in ? is an input, and a name ending in ! is an output. ΞState means the routine changes nothing, and ΔState means it can change the state. The lines below the rule are the conditions that are always true when the routine returns.

new

┌─ NewError ────────────────────────────────────────
│ code?     : KEY
│ message?  : seq CHAR
│ args?     : optional (NAME ⇸ VALUE)
│ location? : optional seq CHAR
│ e!        : Error
├───────────────────────────────────────────────────
│ e!.code = code? ∧ e!.message = message?
│ e!.args = (if args? = ⊥ then ∅ else args?)
│ e!.location = (if location? = ⊥ then "\n" else location?)
│ text(e!) = e!.message ⁀ e!.location
└───────────────────────────────────────────────────

code

┌─ Code ────────────────────────────────────────────
│ ΞError
│ code! : KEY
├───────────────────────────────────────────────────
│ code! = θError.code
└───────────────────────────────────────────────────

message

┌─ Message ─────────────────────────────────────────
│ ΞError
│ message! : seq CHAR
├───────────────────────────────────────────────────
│ message! = θError.message
└───────────────────────────────────────────────────

args

┌─ Args ────────────────────────────────────────────
│ ΞError
│ args! : NAME ⇸ VALUE
├───────────────────────────────────────────────────
│ args! = θError.args
└───────────────────────────────────────────────────

location

┌─ Location ────────────────────────────────────────
│ ΞError
│ location! : seq CHAR
├───────────────────────────────────────────────────
│ location! = θError.location
└───────────────────────────────────────────────────

text

┌─ Text ────────────────────────────────────────────
│ ΞError
│ text! : seq CHAR
├───────────────────────────────────────────────────
│ text! = θError.message ⁀ θError.location
└───────────────────────────────────────────────────

is

┌─ Is ──────────────────────────────────────────────
│ ΞError
│ code? : seq CHAR
│ r!    : 𝔹
├───────────────────────────────────────────────────
│ last(code?) = '.' ⇒ (r! ⇔ code? prefix θError.code)
│ last(code?) ≠ '.' ⇒ (r! ⇔ code? = θError.code)
└───────────────────────────────────────────────────

caught

┌─ Caught ──────────────────────────────────────────
│ class? : CLASS
│ value? : VALUE
│ e!     : optional Error
├───────────────────────────────────────────────────
│ value? ∈ class? ⇒ e! = value?
│ value? ¬in; class? ⇒ e! = ⊥
└───────────────────────────────────────────────────

describe

┌─ Describe ────────────────────────────────────────
│ error? : VALUE
│ s!     : seq CHAR
├───────────────────────────────────────────────────
│ error? ∈ Error ⇒ s! = rtrim(error?.message)
│ error? = ⊥ ⇒ s! = ⟨⟩
│ otherwise s! = rtrim(stripLocation(rtrim(error?)))
└───────────────────────────────────────────────────

STATE DIAGRAM

An error object never changes after it is made. This is its life:

$self->throw($code, \%args)              App::Actions::Error->new(...)
(App::Actions::I18N: look up $code,       (rare: a caller that builds
 translate, fill in %args, add the         its own)
 caller's " at FILE line N.")                    |
          |                                      |
          +------------------+-------------------+
                             v
                    +-----------------+
                    |    CREATED      |  code, message, args,
                    |   (immutable)   |  location fixed
                    +-----------------+
                             |
                             | die
                             v
                    +-----------------+
                    |    THROWN       |  in $@
                    +-----------------+
                      |      |      |
  not caught by any   |      |      | caught as a string ("$@", =~, print):
  eval                |      |      | reads as message . location
                      v      |      v
       program stops;        |   old code works unchanged
       STDERR shows the      |
       message and location  | App::Actions::Error->caught($@)
                             v
                    +-----------------+
                    |   INSPECTED     |  is($code) / is('family.')
                    +-----------------+  code / message / args / location
                             |
                             | describe($@)
                             v
                    message without the location, for a person

LICENSE AND COPYRIGHT

Copyright 2026 Nigel Horne.

This program is released under the GNU General Public License, version 2. See the LICENSE file that comes with this distribution. If you use it, please let me know.