Allow to set custom codec in client / server (#23)

Which is reponsible for:
* (de)serializing requests / responses
* providing request / response metadata for events

Also permit wider service / args models depending on chosen server / client codecs.

Backport #22.
This commit is contained in:
Andrew Mayorov 2022-10-25 14:59:38 +03:00 committed by GitHub
parent 0301168d04
commit 2866b658ea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 284 additions and 95 deletions

View File

@ -34,10 +34,10 @@
%% Thrift
-type service_name() :: atom().
-type service() :: {module(), service_name()}.
-type service() :: {module(), service_name()} | any().
-type context() :: woody_context:ctx().
-type func() :: atom().
-type args() :: tuple().
-type args() :: tuple() | any().
-type request() :: {service(), func(), args()}.
-type result() :: _.
-type th_handler() :: {service(), handler(options())}.

View File

@ -0,0 +1,81 @@
-module(woody_client_codec).
-type codec() :: module().
-export_type([codec/0]).
-export([get_service_name/2]).
-export([get_rpc_type/3]).
-export([write_call/6]).
-export([read_result/5]).
-type buffer() :: iodata().
-type service() :: woody:service().
-type func() :: woody:func().
-type args() :: woody:args().
-type seqid() :: non_neg_integer().
-type result() ::
ok
| {reply, woody:result()}
| {exception, woody:result()}.
-callback get_service_name(service()) ->
woody:service_name().
-callback get_rpc_type(service(), func()) ->
woody:rpc_type().
-callback write_call(buffer(), service(), func(), args(), seqid()) ->
{ok, buffer()}
| {error, _Reason}.
-callback read_result(buffer(), service(), func(), seqid()) ->
{ok, result(), _Rest :: buffer()}
| {error, _Reason}.
%%
-spec get_service_name(codec(), service()) ->
woody:service_name().
get_service_name(thrift_client_codec, {_Module, Service}) ->
Service;
get_service_name(Codec, Service) ->
Codec:get_service_name(Service).
-spec get_rpc_type(codec(), service(), func()) ->
woody:rpc_type().
get_rpc_type(thrift_client_codec, Service, Function) ->
woody_util:get_rpc_type(Service, Function);
get_rpc_type(Codec, Service, Function) ->
Codec:get_rpc_type(Service, Function).
-spec write_call(codec(), buffer(), service(), func(), args(), seqid()) ->
{ok, buffer()}
| {error, _Reason}.
write_call(thrift_client_codec, Buffer, Service, Function, Args, SeqId) ->
thrift_client_codec:write_function_call(
Buffer,
thrift_strict_binary_codec,
Service,
Function,
Args,
SeqId
);
write_call(Codec, Buffer, Service, Function, Args, SeqId) ->
Codec:write_call(Buffer, Service, Function, Args, SeqId).
-spec read_result(codec(), buffer(), service(), func(), seqid()) ->
{ok, result(), _Rest :: buffer()}
| {error, _Reason}.
read_result(thrift_client_codec, Buffer, Service, Function, SeqId) ->
thrift_client_codec:read_function_result(
Buffer,
thrift_strict_binary_codec,
Service,
Function,
SeqId
);
read_result(Codec, Buffer, Service, Function, SeqId) ->
Codec:read_result(Buffer, Service, Function, SeqId).

View File

@ -16,6 +16,7 @@
event_handler := woody:ev_handlers(),
transport_opts => transport_options(),
resolver_opts => woody_resolver:options(),
codec => woody_client_codec:codec(),
protocol => thrift,
transport => http
}.
@ -45,22 +46,24 @@ child_spec(Options) ->
hackney_pool:child_spec(Name, maps:to_list(TransportOpts)).
-spec call(woody:request(), options(), woody_state:st()) -> woody_client:result().
call({Service = {_, ServiceName}, Function, Args}, Opts, WoodyState) ->
call({Service, Function, Args}, Opts, WoodyState) ->
WoodyContext = woody_state:get_context(WoodyState),
ClientCodec = maps:get(codec, Opts, thrift_client_codec),
WoodyState1 = woody_state:add_ev_meta(
#{
service => ServiceName,
service => woody_client_codec:get_service_name(ClientCodec, Service),
service_schema => Service,
function => Function,
type => woody_util:get_rpc_type(Service, Function),
type => woody_client_codec:get_rpc_type(ClientCodec, Service, Function),
args => Args,
codec => ClientCodec,
deadline => woody_context:get_deadline(WoodyContext),
metadata => woody_context:get_meta(WoodyContext)
},
WoodyState
),
_ = log_event(?EV_CALL_SERVICE, WoodyState1, #{}),
do_call(Service, Function, Args, Opts, WoodyState1).
do_call(ClientCodec, Service, Function, Args, Opts, WoodyState1).
%%
%% Internal functions
@ -71,18 +74,25 @@ call({Service = {_, ServiceName}, Function, Args}, Opts, WoodyState) ->
-type http_headers() :: [{binary(), binary()}].
-type header_parse_value() :: none | woody:http_header_val().
-define(CODEC, thrift_strict_binary_codec).
-define(ERROR_RESP_BODY, <<"parse http response body error">>).
-define(ERROR_RESP_HEADER, <<"parse http response headers error">>).
-define(BAD_RESP_HEADER, <<"reason unknown due to bad ", ?HEADER_PREFIX/binary, "-error- headers">>).
-define(SERVER_VIOLATION_ERROR,
-define(APPLICATION_EXCEPTION_ERROR,
{external, result_unexpected, <<
"server violated thrift protocol: "
"sent TApplicationException (unknown exception) with http code 200"
>>}
).
-define(EXCESS_BODY_ERROR(Bytes, Result),
{external, result_unexpected,
genlib:format(
"server violated thrift protocol: excess ~p bytes in response: ~p",
[byte_size(Bytes), Result]
)}
).
-spec get_transport_opts(options()) -> woody_client_thrift_http_transport:transport_options().
get_transport_opts(Opts) ->
maps:get(transport_opts, Opts, #{}).
@ -91,15 +101,15 @@ get_transport_opts(Opts) ->
get_resolver_opts(Opts) ->
maps:get(resolver_opts, Opts, #{}).
-spec do_call(woody:service(), woody:func(), woody:args(), options(), woody_state:st()) -> woody_client:result().
do_call(Service, Function, Args, Opts, WoodyState) ->
Buffer = ?CODEC:new(),
-spec do_call(module(), woody:service(), woody:func(), woody:args(), options(), woody_state:st()) ->
woody_client:result().
do_call(Codec, Service, Function, Args, Opts, WoodyState) ->
Result =
case thrift_client_codec:write_function_call(Buffer, ?CODEC, Service, Function, Args, 0) of
case woody_client_codec:write_call(Codec, <<>>, Service, Function, Args, 0) of
{ok, Buffer1} ->
case send_call(Buffer1, Opts, WoodyState) of
{ok, Response} ->
handle_result(Service, Function, Response);
handle_result(Codec, Service, Function, Response);
Error ->
Error
end;
@ -109,32 +119,28 @@ do_call(Service, Function, Args, Opts, WoodyState) ->
log_result(Result, WoodyState),
map_result(Result).
-spec handle_result(woody:service(), woody:func(), binary()) -> _Result.
handle_result(Service, Function, Response) ->
Buffer = ?CODEC:new(Response),
case thrift_client_codec:read_function_result(Buffer, ?CODEC, Service, Function, 0) of
{ok, Result, Leftovers} ->
Bytes = ?CODEC:close(Leftovers),
-spec handle_result(module(), woody:service(), woody:func(), binary()) -> _Result.
handle_result(Codec, Service, Function, Response) ->
case woody_client_codec:read_result(Codec, Response, Service, Function, 0) of
{ok, Result, <<>>} ->
case Result of
ok when Bytes == <<>> ->
ok ->
{ok, ok};
{reply, Reply} when Bytes == <<>> ->
{reply, Reply} ->
{ok, Reply};
{exception, #'TApplicationException'{}} when Bytes == <<>> ->
{error, {system, ?SERVER_VIOLATION_ERROR}};
{exception, Exception} when Bytes == <<>> ->
{error, {business, Exception}};
_ ->
{error, {excess_response_body, Bytes, Result}}
{exception, #'TApplicationException'{}} ->
{error, {system, ?APPLICATION_EXCEPTION_ERROR}};
{exception, Exception} ->
{error, {business, Exception}}
end;
{ok, Result, Leftovers} ->
{error, ?EXCESS_BODY_ERROR(Leftovers, Result)};
{error, _} = Error ->
Error
end.
%% erlfmt-ignore
-spec send_call(?CODEC:buffer(), options(), woody_state:st()) ->
{ok, binary()} | {error, {system, _}}.
-spec send_call(iodata(), options(), woody_state:st()) ->
{ok, iodata()} | {error, {system, _}}.
send_call(Buffer, #{url := Url} = Opts, WoodyState) ->
Context = woody_state:get_context(WoodyState),
TransportOpts = get_transport_opts(Opts),

104
src/woody_server_codec.erl Normal file
View File

@ -0,0 +1,104 @@
-module(woody_server_codec).
-type codec() :: module().
-export_type([codec/0]).
-export_type([invocation/0]).
-export([get_service_name/2]).
-export([read_call/3]).
-export([write_result/6]).
-export([catch_business_exception/4]).
-type buffer() :: iodata().
-type service() :: woody:service().
-type func() :: woody:func().
-type args() :: woody:args().
-type seqid() :: non_neg_integer().
-type invocation() :: {woody:rpc_type(), func(), args()}.
-type result() ::
ok
| {reply, woody:result()}
| {exception, _Name, woody:result()}.
-callback get_service_name(service()) ->
woody:service_name().
-callback read_call(buffer(), service()) ->
{ok, seqid(), invocation(), _Rest :: buffer()}
| {error, _Reason}.
-callback write_result(buffer(), service(), func(), result(), seqid()) ->
{ok, buffer()}
| {error, _Reason}.
%%
-spec get_service_name(codec(), service()) ->
woody:service_name().
get_service_name(thrift_processor_codec, {_Module, Service}) ->
Service;
get_service_name(Codec, Service) ->
Codec:get_service_name(Service).
-spec read_call(codec(), buffer(), service()) ->
{ok, seqid(), invocation(), _Rest :: buffer()}
| {error, _Reason}.
read_call(thrift_processor_codec, Buffer, Service) ->
case thrift_processor_codec:read_function_call(Buffer, thrift_strict_binary_codec, Service) of
{ok, SeqId, {Type, Function, Args}, Rest} ->
RpcType = maps:get(Type, #{call => call, oneway => cast}),
{ok, SeqId, {RpcType, Function, Args}, Rest};
{error, _} = Error ->
Error
end;
read_call(Codec, Buffer, Service) ->
Codec:read_call(Buffer, Service).
-spec write_result(codec(), buffer(), service(), func(), result(), seqid()) ->
{ok, buffer()}
| {error, _Reason}.
write_result(thrift_processor_codec, Buffer, Service, Function, {exception, _Name, {Type, Exception}}, SeqId) ->
thrift_processor_codec:write_function_result(
Buffer,
thrift_strict_binary_codec,
Service,
Function,
{exception, Type, Exception},
SeqId
);
write_result(thrift_processor_codec, Buffer, Service, Function, Res, SeqId) ->
thrift_processor_codec:write_function_result(
Buffer,
thrift_strict_binary_codec,
Service,
Function,
Res,
SeqId
);
write_result(Codec, Buffer, Service, Function, Res, SeqId) ->
Codec:write_result(Buffer, Service, Function, Res, SeqId).
-spec catch_business_exception(codec(), service(), func(), _Exception) ->
{exception, _Name :: atom(), _TypedException}
| {error, _Reason}.
catch_business_exception(thrift_processor_codec, Service, Function, Exception) ->
case thrift_processor_codec:match_exception(Service, Function, Exception) of
{ok, Type} ->
{exception, get_exception_name(Type, Exception), {Type, Exception}};
{error, _} = Error ->
Error
end;
catch_business_exception(_Codec, _Service, _Function, _Exception) ->
{error, noimpl}.
-spec get_exception_name(_Type, woody:result()) ->
atom().
get_exception_name({{struct, exception, {_Mod, Name}}, _}, _) ->
Name;
get_exception_name(_Type, Exception) ->
element(1, Exception).

View File

@ -84,7 +84,7 @@ invoke_handler(State) ->
handle_result(Result, Reply, MsgType).
-spec handle_function(woody:handler(woody:options()), woody:func(), woody:args(), woody_state:st()) ->
{ok, woody:result()} | no_return().
{ok, woody:result()} | {exception, _TypeName, woody:result()} | no_return().
handle_function(Handler, Function, Args, WoodyState) ->
_ = woody_event_handler:handle_event(?EV_INVOKE_SERVICE_HANDLER, WoodyState, #{}),
{Module, Opts} = woody_util:get_mod_opts(Handler),

View File

@ -71,9 +71,12 @@
-export_type([options/0]).
-type codec() :: woody_server_codec:codec().
-type route_opts() :: #{
handlers := list(woody:http_handler(woody:th_handler())),
event_handler := woody:ev_handlers(),
codec => codec(),
protocol => thrift,
transport => http,
read_body_opts => read_body_opts(),
@ -99,6 +102,7 @@
-type state() :: #{
th_handler => woody:th_handler(),
ev_handler := woody:ev_handlers(),
codec => module(),
read_body_opts := read_body_opts(),
handler_limits := handler_limits(),
regexp_meta := re_mp(),
@ -213,6 +217,7 @@ get_route(_, Handler) ->
init_state(Opts = #{}) ->
#{
ev_handler => maps:get(event_handler, Opts),
codec => maps:get(codec, Opts, thrift_processor_codec),
read_body_opts => maps:get(read_body_opts, Opts, #{}),
handler_limits => maps:get(handler_limits, Opts, #{}),
regexp_meta => compile_filter_meta()
@ -256,6 +261,7 @@ handle(
Req,
State = #{
url := Url,
codec := ProcessorCodec,
woody_state := WoodyState,
read_body_opts := ReadBodyOpts,
th_handler := ThriftHandler
@ -265,7 +271,7 @@ handle(
case get_body(Req, ReadBodyOpts) of
{ok, Body, Req1} when byte_size(Body) > 0 ->
_ = handle_event(?EV_SERVER_RECEIVE, WoodyState, #{url => Url, status => ok}),
handle_request(Body, ThriftHandler, WoodyState, Req1);
handle_request(ProcessorCodec, Body, ThriftHandler, WoodyState, Req1);
{ok, <<>>, Req1} ->
reply_client_error(400, <<"body empty">>, Req1, State)
end,
@ -294,8 +300,6 @@ terminate(Reason, _Req, #{ev_handler := EvHandler} = Opts) ->
%% init functions
-define(CODEC, thrift_strict_binary_codec).
%% First perform basic http checks: method, content type, etc,
%% then check woody related headers: IDs, deadline, meta.
@ -474,18 +478,15 @@ do_get_body(Body, Req, Opts) ->
do_get_body(<<Body/binary, Body1/binary>>, Req1, Opts)
end.
-spec handle_request(woody:http_body(), woody:th_handler(), woody_state:st(), cowboy_req:req()) -> cowboy_req:req().
handle_request(Body, ThriftHandler = {Service, _}, WoodyState, Req) ->
-spec handle_request(codec(), woody:http_body(), woody:th_handler(), woody_state:st(), cowboy_req:req()) ->
cowboy_req:req().
handle_request(Codec, Body, ThriftHandler = {Service, _}, WoodyState, Req) ->
ok = woody_monitor_h:set_event(?EV_SERVICE_HANDLER_RESULT, Req),
Buffer = ?CODEC:new(Body),
case thrift_processor_codec:read_function_call(Buffer, ?CODEC, Service) of
{ok, SeqId, Invocation, Leftovers} ->
case ?CODEC:close(Leftovers) of
<<>> ->
handle_invocation(SeqId, Invocation, ThriftHandler, Req, WoodyState);
Bytes ->
handle_decode_error({excess_response_body, Bytes, Invocation}, Req, WoodyState)
end;
case woody_server_codec:read_call(Codec, Body, Service) of
{ok, SeqId, Invocation, <<>>} ->
handle_invocation(Codec, SeqId, Invocation, ThriftHandler, Req, WoodyState);
{ok, _SeqId, Invocation, Leftovers} ->
handle_error(client_error({excess_request_bytes, Leftovers, Invocation}), Req, WoodyState);
{error, Reason} ->
handle_decode_error(Reason, Req, WoodyState)
end.
@ -518,30 +519,31 @@ client_error({bad_function_name, FName}) ->
client_error(Reason) ->
{client, woody_util:to_binary([<<"thrift decode error: ">>, woody_error:format_details(Reason)])}.
-spec handle_invocation(integer(), Invocation, woody:th_handler(), cowboy_req:req(), woody_state:st()) ->
-spec handle_invocation(codec(), integer(), Invocation, woody:th_handler(), cowboy_req:req(), woody_state:st()) ->
cowboy_req:req()
when
Invocation :: {call | oneway, woody:func(), woody:args()}.
handle_invocation(SeqId, {ReplyType, Function, Args}, {Service, Handler}, Req, WoodyState) ->
WoodyState1 = add_ev_meta(WoodyState, Service, ReplyType, Function, Args),
case ReplyType of
Invocation :: woody_server_codec:invocation().
handle_invocation(Codec, SeqId, {RpcType, Function, Args}, {Service, Handler}, Req, WoodyState) ->
WoodyState1 = add_ev_meta(WoodyState, Codec, Service, RpcType, Function, Args),
case RpcType of
call ->
Result = handle_call(Handler, Service, Function, Args, WoodyState1),
handle_result(Result, Service, Function, SeqId, Req, WoodyState1);
oneway ->
Result = handle_call(Codec, Handler, Service, Function, Args, WoodyState1),
handle_result(Codec, Result, Service, Function, SeqId, Req, WoodyState1);
cast ->
Req1 = reply(200, Req, WoodyState1),
_Result = handle_call(Handler, Service, Function, Args, WoodyState1),
_Result = handle_call(Codec, Handler, Service, Function, Args, WoodyState1),
Req1
end.
-type call_result() ::
ok
| {reply, woody:result()}
| {exception, _TypeName, _Exception}
| {exception, _Name :: binary(), _Exception :: woody:result()}
| {error, {system, woody_error:system_error()}}.
-spec handle_call(woody:handler(_), woody:service(), woody:func(), woody:args(), woody_state:st()) -> call_result().
handle_call(Handler, Service, Function, Args, WoodyState) ->
-spec handle_call(codec(), woody:handler(_), woody:service(), woody:func(), woody:args(), woody_state:st()) ->
call_result().
handle_call(Codec, Handler, Service, Function, Args, WoodyState) ->
try
Result = call_handler(Handler, Function, Args, WoodyState),
_ = handle_event(
@ -550,35 +552,42 @@ handle_call(Handler, Service, Function, Args, WoodyState) ->
#{status => ok, result => Result}
),
case Result of
{ok, ok} -> ok;
{ok, Reply} -> {reply, Reply}
{ok, Reply} ->
{reply, Reply};
{exception, _Name, BusinessException} ->
_ = handle_event(
?EV_SERVICE_HANDLER_RESULT,
WoodyState,
#{status => error, class => business, result => BusinessException}
),
Result
end
catch
throw:Exception:Stack ->
process_handler_throw(Exception, Stack, Service, Function, WoodyState);
process_handler_throw(Codec, Exception, Stack, Service, Function, WoodyState);
Class:Reason:Stack ->
process_handler_error(Class, Reason, Stack, WoodyState)
end.
-spec call_handler(woody:handler(_), woody:func(), woody:args(), woody_state:st()) ->
{ok, woody:result()} | no_return().
{ok, woody:result()} | {exception, _Name, woody:result()} | no_return().
call_handler(Handler, Function, Args, WoodyState) ->
woody_server_thrift_handler:handle_function(Handler, Function, Args, WoodyState).
-spec process_handler_throw(_Exception, woody_error:stack(), woody:service(), woody:func(), woody_state:st()) ->
{exception, _TypeName, _Exception}
-spec process_handler_throw(codec(), _Ex, woody_error:stack(), woody:service(), woody:func(), woody_state:st()) ->
{exception, _Name, woody:result()}
| {error, {system, woody_error:system_error()}}.
process_handler_throw(Exception, Stack, Service, Function, WoodyState) ->
case thrift_processor_codec:match_exception(Service, Function, Exception) of
{ok, TypeName} ->
process_handler_throw(Codec, ThrownException, Stack, Service, Function, WoodyState) ->
case woody_server_codec:catch_business_exception(Codec, Service, Function, ThrownException) of
{exception, _Name, _TypedException} = Result ->
_ = handle_event(
?EV_SERVICE_HANDLER_RESULT,
WoodyState,
#{status => error, class => business, result => Exception}
#{status => error, class => business, result => ThrownException}
),
{exception, TypeName, Exception};
Result;
{error, _} ->
process_handler_error(throw, Exception, Stack, WoodyState)
process_handler_error(throw, ThrownException, Stack, WoodyState)
end.
-spec process_handler_error(_Class :: atom(), _Reason, woody_error:stack(), woody_state:st()) ->
@ -599,35 +608,27 @@ process_handler_error(Class, Reason, Stack, WoodyState) ->
Error = {internal, result_unexpected, format_unexpected_error(Class, Reason, Stack)},
{error, {system, Error}}.
-spec handle_result(call_result(), woody:service(), woody:func(), integer(), Req, woody_state:st()) -> Req when
-spec handle_result(module(), call_result(), woody:service(), woody:func(), integer(), Req, woody_state:st()) ->
Req
when
Req :: cowboy_req:req().
handle_result(Res, Service, Function, SeqId, Req, WoodyState) when Res == ok; element(1, Res) == reply ->
Buffer = ?CODEC:new(),
case thrift_processor_codec:write_function_result(Buffer, ?CODEC, Service, Function, Res, SeqId) of
{ok, Buffer1} ->
Response = ?CODEC:close(Buffer1),
handle_result(Codec, Res, Service, Function, SeqId, Req, WoodyState) when Res == ok; element(1, Res) == reply ->
case woody_server_codec:write_result(Codec, <<>>, Service, Function, Res, SeqId) of
{ok, Response} ->
reply(200, cowboy_req:set_resp_body(Response, Req), WoodyState);
{error, Reason} ->
handle_encode_error(Reason, Req, WoodyState)
end;
handle_result(Res = {exception, TypeName, Exception}, Service, Function, SeqId, Req, WoodyState) ->
Buffer = ?CODEC:new(),
case thrift_processor_codec:write_function_result(Buffer, ?CODEC, Service, Function, Res, SeqId) of
{ok, Buffer1} ->
ExceptionName = get_exception_name(TypeName, Exception),
Response = ?CODEC:close(Buffer1),
handle_error({business, {ExceptionName, Response}}, Req, WoodyState);
handle_result(Codec, {exception, Name, _Exception} = Result, Service, Function, SeqId, Req, WoodyState) ->
case woody_server_codec:write_result(Codec, <<>>, Service, Function, Result, SeqId) of
{ok, Response} ->
handle_error({business, {genlib:to_binary(Name), Response}}, Req, WoodyState);
{error, Reason} ->
handle_encode_error(Reason, Req, WoodyState)
end;
handle_result({error, Error}, _Service, _Function, _SeqId, Req, WoodyState) ->
handle_result(_Codec, {error, Error}, _Service, _Function, _SeqId, Req, WoodyState) ->
handle_error(Error, Req, WoodyState).
get_exception_name({{struct, exception, {_Mod, Name}}, _}, _) ->
genlib:to_binary(Name);
get_exception_name(_TypeName, Exception) ->
genlib:to_binary(element(1, Exception)).
-spec handle_encode_error(_Reason, cowboy_req:req(), woody_state:st()) -> cowboy_req:req().
handle_encode_error(Reason, Req, WoodyState) ->
_ = handle_event(
@ -641,22 +642,19 @@ handle_encode_error(Reason, Req, WoodyState) ->
Error = {internal, result_unexpected, format_unexpected_error(error, Reason, [])},
handle_error({system, Error}, Req, WoodyState).
add_ev_meta(WoodyState, Service = {_, ServiceName}, ReplyType, Function, Args) ->
add_ev_meta(WoodyState, Codec, Service, RpcType, Function, Args) ->
woody_state:add_ev_meta(
#{
service => ServiceName,
service => woody_server_codec:get_service_name(Codec, Service),
service_schema => Service,
function => Function,
args => Args,
type => get_rpc_reply_type(ReplyType),
type => RpcType,
deadline => woody_context:get_deadline(woody_state:get_context(WoodyState))
},
WoodyState
).
get_rpc_reply_type(oneway) -> cast;
get_rpc_reply_type(call) -> call.
format_unexpected_error(Class, Reason, Stack) ->
woody_util:to_binary(
[Class, ":", woody_error:format_details(Reason), " ", genlib_format:format_stacktrace(Stack)]