NAME

Catalyst::Seal::Prepare - the request preparation path

DESCRIPTION

Catalyst::prepare turns a PSGI environment into a request object. What that costs, measured by replacing each part with a stub that answers from a constant and timing the whole request, on a hello world application with phases 0 to 3 already applied:

prepare_query_parameters   4.3 us on an empty query string
                          34.0 us on "a=1&b=two&c=caf%C3%A9&d=one+two"
prepare_path               8.0 us
prepare_headers            7.3 us

A stub is the ceiling: no implementation of a subroutine beats not running it. Splitting the first of those again says where it goes, and it is not where the plan for this phase expected:

the whole unicode decoding step   23.7 us
  of which Try::Tiny              15.0 us
percent and plus unescaping        2.7 us

So the largest single item in request preparation is not parsing. It is that Catalyst decodes every parameter name and every parameter value inside a Try::Tiny block, which builds two closures and names them, per string.

What this module does

  • Catalyst::_handle_param_unicode_decoding, try rewritten as eval. Query parameters, body parameters and path arguments all decode through it, so it is paid once per string in the request.

  • Catalyst::Request::prepare_headers, building the HTTP::Headers hash directly instead of through 29 header calls.

  • Catalyst::Engine::prepare_path, skipping URI::canonical when the URI it just built is already canonical, which is decidable with one regex and costs three authority parses to ask URI.

The parameter decoder

Catalyst::_handle_param_unicode_decoding is phase 0.4 applied to a third site, with the same three things to preserve:

$@ is read immediately after the eval, and local $@ restores the caller's, which is what Try::Tiny does and a bare eval does not.

eval { ...; 1 } is not used here because the value of the block is the return value. @out distinguishes a failed decode from one that returned false, which testing $@ alone would not.

return unless defined $value returns the empty list in list context, and this subroutine is called from inside a map. It stays exactly as it is.

The headers

HTTP::Headers stores a header as $self->{lc $field}, plus an entry in $self->{'::std_case'} naming the spelling to use on the way out for any field it does not already know. Building that hash directly is a third of prepare_headers.

The spelling is not copied out of HTTP::Headers. Its list of known headers is a lexical, and a copy of it here would be wrong the day a header is added to it, in a way that only shows up in the as_string of a response. Instead the first request that carries a given environment key sets that one header on a throwaway HTTP::Headers the ordinary way and remembers what came out. The answer is HTTP::Headers' own, so there is nothing to keep in step, and the probe is also the check: a field whose result is not one plain string under one key is left to the stock path forever.

The memo is bounded. A client that sends a thousand distinct header names must not be able to grow it, so past the cap an unrecognised key takes the stock path and is not remembered.

The path

prepare_path builds the request URI as a string, blesses a reference to it into URI::http, and calls canonical. URI::_server::canonical parses the authority three times to decide whether anything needs canonicalising, and on a URI that is already canonical it returns the object it was given.

That decision is one regex here: a lower case scheme, no percent escape anywhere in the string, and an authority with no upper case and no port. Under those three conditions URI cannot find anything to change, and returns the same object this would.

The conditions are checked against URI itself at seal time rather than against its source, because what matters is the behaviour and not the spelling. A negative control is part of that: a probe that only ever confirms is a probe that would pass against a canonical that had stopped working.

fast_canonical

my $bool = Catalyst::Seal::Prepare::fast_canonical();

Whether the prepare_path patch was installed. For the test suite.

The query string

The stock prepare_query_parameters builds a decoder closure, a Hash::MultiValue and a regex before it has looked at the query string, splits it in Perl, and puts the name and the value of every pair through unescape_uri and then through _handle_param_unicode_decoding - eight calls a pair, each of them with an eval in it. Measured, best of five:

query string          stock      here
(empty)             1.93 us   0.78 us
a=1&b=2&c=3&d=4    12.90 us   1.29 us
a=caf%C3%A9&b=one+two 8.86 us  1.08 us

The C parser does the split, the percent-decode and the UTF-8 decode in one pass and hands back the hash Catalyst was going to build - a value for a name that appeared once, an array reference for one that appeared more than once.

It hands the whole query to the stock parser rather than half-answering it, for any of: do_not_decode_query, default_query_encoding or do_not_check_query_encoding in the configuration; an encoding that is not UTF-8; a request asking for Hash::MultiValue, whose key order this does not promise to reproduce; or a byte sequence the decoder will not vouch for.

What the decoder calls UTF-8

Exactly what Encode calls UTF-8, which is not the same as what perl calls utf8 and not quite what the encoding's shape alone would suggest. No overlong form, no surrogate, nothing above U+10FFFF, nothing truncated - and none of the sixty-six noncharacters, U+FDD0 to U+FDEF and U+xFFFE and U+xFFFF in every plane, which Encode refuses and a validator written from the bit patterns would accept.

That list was measured against Encode 3.21 rather than read off a specification, and t/56-query.t re-measures it: it walks the boundary of every branch and asks both Encode and the decoder, and fails if they ever disagree.

Being wrong in one direction costs a slow path, and in the other it admits a request that should have been refused. Anything the decoder will not vouch for goes to Encode, which refuses it and words the refusal the way Catalyst's handle_unicode_encoding_exception expects.

The header pass

prepare_headers asks HTTP::Headers for the spelling of each field once, the first time a request carries it, and remembers it. What is left after that is a loop over the environment doing a hash lookup and two stores per key, and that loop is in C: 1.54 us to 0.94, against the 4.28 the stock body costs.

The C pass hands back the keys it could not place - one never seen before, one marked for the long way, or one whose value is not a plain string - and the Perl above finishes those. After the first request of a given shape there are none.

An environment that is not a hash reference at all is left to Perl too. Reading parameters off a bare Catalyst::Request does exactly that, and what the stock body does about it is what keys %$env does: autovivify an empty one and return empty headers.

AUTHOR

LNATION <email@lnation.org>

LICENSE AND COPYRIGHT

This software is Copyright (c) 2026 by LNATION <email@lnation.org>.

This is free software, licensed under:

The Artistic License 2.0 (GPL Compatible)