LLM::Chat::ExceptionText
NAME
LLM::Chat::ExceptionText - An exception's message, for any exception, without ever throwing
SYNOPSIS
use LLM::Chat::ExceptionText;
class X::Quiet is Exception { } # no message of its own
my $loud = X::AdHoc.new(payload => 'disk full');
my $quiet = X::Quiet.new;
say exception-message($loud); # disk full
say exception-message($quiet).raku; # Str (undefined: there is none)
say exception-text($loud); # disk full
say exception-text($quiet); # X::Quiet
$quiet.message; # dies: Stub code executed
DESCRIPTION
In Rakudo the base Exception.message is a stub: its whole body is
..., the yada operator. An exception class that defines neither a
message method nor a $.message attribute therefore has no message
to give. What .message hands back is a Failure whose exception
says "Stub code executed" (an X::StubCode from Rakudo 2026.08 on, an
X::AdHoc before), and that Failure throws the moment anything
uses it as text: interpolates it, stringifies it, calls .lc or
.lines on it.
Such classes are not hypothetical. Cro::HTTP::Client raises
Cro::HTTP::Client::PipelineClosedBeforeHeaders, which has no message,
whenever a server closes the connection before sending response headers
ā a server restarting, a proxy dropping an idle connection, a worker
killed mid-request. Any library can declare one; a test double often
does.
The place this bites is a CATCH block. A handler that formats the
exception it caught with .message throws a second exception from
inside the handler, and that one is not caught by anything. In the main
program it replaces the original error with a stub error; inside a
start block the program prints Unhandled exception in code
scheduled on thread N and exits. The request that should have failed
takes the whole process with it.
start {
my $res = await $client.post: $url, body => $body;
# ...
CATCH {
default {
# WRONG: dies again, from inside the handler, for any
# exception without a message.
$response.quit("{.^name}: {.message}");
}
}
}
.message // '' happens to defuse the stub's Failure, but it is no
help against a message method that dies outright, and an empty
string is a poor thing to report as an error.
These two subs read the message defensively, so the handler cannot fail:
use LLM::Chat::ExceptionText;
start {
my $res = await $client.post: $url, body => $body;
# ...
CATCH {
default {
note "{.^name}: {exception-message($_) // '(no message)'}";
$response.quit(exception-text($_));
}
}
}
Every .message this distribution calls on an exception it did not
create goes through one of them, so a transport, hook or tool error
whose exception has no message fails the request it belongs to ā with
the exception's class name as its error text ā instead of the process.
WHICH ONE
exception-textwhen you need something to show: the error handed to.quit, the content of a failed tool result, a log line that does not already name the class. It is never undefined, so it can go straight into aStr:D. An exception without a message is reported by its class name, which is the most specific thing there is to say about it.exception-messagewhen it matters whether there is a message: matching on the text, or choosing a fallback of your own. The answer is an undefinedStrwhen there is none, which is exactly what//wants:
# Classify by the text, treating "no message" as "matches nothing".
my $text = (exception-message($_) // '').lc;
my $class = $text.contains('refused') ?? 'connection' !! 'unknown';
# A log line that already names the class should not name it twice.
note "{.^name}: {exception-message($_) // '(no message)'}";
WHAT COUNTS AS NO MESSAGE
exception-message returns the message as a Str when there is one,
and an undefined Str when:
.messagereturns an undefined value:Nil, a type object, or aFailureā the last being what the base stub returns. AFailureis marked handled on the way, so it does not resurface later as an "unhandled Failure" warning..messagedies. The exception it throws is caught and discarded.The message's own
.Strthrows, is undefined, or is not aStr.
A defined message that is not a Str is stringified (42 becomes
"42"). An empty string is a message, and is returned as one.
Anything can be passed, not only an Exception: a value without a
message method ā a type object included ā simply has no message.
Neither sub ever throws.
SUBROUTINES
exception-message
sub exception-message(Mu $exception --> Str) is export
The message of $exception as a Str, or an undefined Str when it
has none (see /WHAT COUNTS AS NO MESSAGE).
exception-text
sub exception-text(Mu $exception --> Str:D) is export
The message when there is one, the class name otherwise. Always a
defined Str.
The name is the declared class, without roles mixed into the object
at runtime. That matters because await rethrows every exception with
X::Await::Died mixed in, so the exception a CATCH around an
await sees has a .^name like
Cro::HTTP::Client::PipelineClosedBeforeHeaders+{X::Await::Died}; its
text is Cro::HTTP::Client::PipelineClosedBeforeHeaders.
class X::Quiet is Exception { }
my $p = Promise.new;
$p.break(X::Quiet.new);
try await $p;
say $!.^name; # X::Quiet+{X::Await::Died}
say exception-text($!); # X::Quiet