NAME
Langertha::Role::AsyncHTTP - Async HTTP backend selection (injected > Net::Async::HTTP > sync LWP fallback)
VERSION
version 0.503
_async_http
The backend that satisfies the async do_request contract: do_request( request => $req [, on_header => sub {...}] ) returning a Future that resolves to an HTTP::Response. It is the injection seam — pass _async_http => $client at construction to bring your own client (any object with that method) and it is used verbatim.
When not injected the builder selects, in order: Net::Async::HTTP if it can be loaded (a real async client added to "_async_loop", built with pipeline => 0); otherwise Langertha::Request::SyncHTTP over the engine's user_agent, warning once per process. The warning names the caller's own _f call site; if Net::Async::HTTP is installed but fails to load (for example a missing IO::Async sub-dependency) it says so and includes the first line of the load error instead of claiming the module is unavailable. The sync fallback runs HTTP synchronously and sequentially (blocking, no concurrency) — every _f call still works and returns a Future, but multiple calls awaited "in parallel" run one after another.
The Net::Async::HTTP client does not pipeline HTTP/1.1 requests: a request pipelined behind a long LLM stream would wait for it anyway, and would fail with Connection closed if that stream were cancelled or aborted. It keeps the library's other defaults, including max_connections_per_host (one keep-alive connection per host; the NET_ASYNC_HTTP_MAXCONNS environment variable changes it), so concurrent requests on one engine are sent one after another on that connection. Concurrent requests through different engines use their own clients. Inject a client configured otherwise to change either.
async_request_f
my $response = await $engine->async_request_f($http_request);
die $response->status_line unless $response->is_success;
# streaming: on_header passes through to the backend
await $engine->async_request_f($http_request, on_header => sub { ... });
Sends a prepared HTTP::Request (for example from chat_request or build_tool_chat_request) through the engine's selected backend ("_async_http") and returns a Future that resolves to the HTTP::Response. This is the public face of the do_request contract, for callers outside core that assemble their own requests. On the synchronous fallback the returned future is already complete.
An HTTP error status (4xx/5xx) resolves the future on every backend: check is_success on the response. A transport-level failure (connection refused, DNS, timeout) is not uniform across backends: on Net::Async::HTTP it fails the future with the socket error, while on the synchronous fallback it resolves with the 500 response LWP synthesizes (500 Can't connect ..., header Client-Warning: Internal response) — the future does not fail. Either way the call did not succeed, so always check is_success; do not rely on a failed future alone to catch a dead endpoint. See ADR 0027 for the parity scope.
When the engine has a "user_agent_timeout" in Langertha::Role::HTTP and the backend is a Net::Async::HTTP, it is applied here like on the engine's own _f calls: as the total timeout for a plain request, as the stall_timeout (time without a byte) when on_header is given. On expiry the future fails with <engine class>: request to <url> timed out after Ns (query string and userinfo left out of the URL) and the category timeout or stall_timeout. Passing your own timeout or stall_timeout option overrides it.
On the Net::Async::HTTP backend the modules it loads only when it connects are checked before the request is handed to it: IO::Async::Internals::Connector, and IO::Async::SSL for https. If one fails to load, the future fails with <engine class>: cannot connect to <scheme>://<host>:<port>: <module> failed to load (<reason>); ... and the category connect. Without the check Net::Async::HTTP 0.50 would keep the host's connection slot taken by the connection that never opened, and every later request to that host would wait forever (karr k353). Inline image fetches through such a client ("ensure_base64_f" in Langertha::Content::Image) are checked the same way.
Redirects follow Langertha::HTTP::Redirect on every backend core builds: on Net::Async::HTTP they are followed here one hop at a time (the client itself is told max_redirects => 0), on the synchronous fallback by the engine's Langertha::HTTP::UserAgent. Only GET and HEAD are followed, never from https to http; a redirect on the same origin keeps the request as it was, one to another origin drops every header but the representation ones (so no credential header of any name goes along), the URL's userinfo, and any query value the request carried as a credential. http://host to https://host counts as another origin, so a keyed GET behind such a redirect arrives without its key and gets a 401: configure the https URL. A POST is never followed. A redirect that is not followed resolves the future with the 3xx response, with a Client-Warning header naming the reason (redirect not followed: Langertha::HTTP::Redirect: ..., also when the hop limit ran out). The hop limit is a max_redirects option if given, else the client's own (3 by default); the timeout applies per hop. With on_header the callback sees only the response the chain ends on, a redirect that was not followed included (karr k374). A request passed as uri => instead of request => is not followed at all. An injected client of another class follows redirects on its own terms.
With a "connect_address" in Langertha::Role::HTTP, a request to the host of the engine's url connects to that address: on Net::Async::HTTP the request is given host / port as the connection target and, for https, SSL_hostname and SSL_verifycn_name naming the host (the Host header comes from the request URL as always), and an on_ready check that the connection (new or pooled) goes to the address and, over TLS, was verified for the host; on the synchronous fallback the engine's pinned Langertha::HTTP::UserAgent does it. A redirect from the pinned host to another host is not followed. A client that cannot pin (an injected client of another class, the shim over an agent without the same pin, a Net::Async::HTTP with a proxy, a uri => request) fails the future with category connect_address instead of sending the request (karr k375).
Any extra named options (such as on_header for streaming) are passed to do_request unchanged. The backend object itself is not exposed; see "async_loop" for the event loop.
On the Net::Async::HTTP backend a non-streaming request (no on_header) has its decoded response body bounded by "response_max_bytes" in Langertha::Role::HTTP: the client inflates a Content-Encoding as it streams, so the decoded bytes are counted and the request is aborted past the ceiling, failing the future with <engine class> response body exceeds response_max_bytes (<n>) (a decompression-bomb guard, karr k346). A streaming request (with on_header) is not bounded. Set response_max_bytes to 0 to disable.
async_loop
my $loop = $engine->async_loop // IO::Async::Loop->new;
$loop->add($notifier);
await $loop->delay_future( after => 2 );
Returns the event loop of the active async backend ("_async_http"), or undef — a Maybe[loop]. Core promises no loop (see "EVENT LOOP"):
an injected client that has a
loopmethod: that client's loop, whatever loop the caller put it on;the default Net::Async::HTTP backend: the loop it was added to ("_async_loop");
the synchronous fallback (Langertha::Request::SyncHTTP) or an injected client without a
loopmethod:undef.
Calling it selects the backend if that has not happened yet (so on a clean install it may emit the one-time fallback warning). Code that needs a loop for its own notifiers or timers should use this loop when it is defined, so its futures and the engine's HTTP futures are driven by the same loop; awaiting futures from two different loops in one chain can hang.
EVENT LOOP
Core promises no event loop: IO::Async is only recommended, an injected client may run on any loop, and the synchronous fallback runs on none. "async_loop" reports the backend's loop when there is one and undef otherwise. When it is undef a caller that needs a loop brings its own, typically IO::Async::Loop->new (the process-wide loop).
The backend's loop is not necessarily the process-wide one: an injected "_async_http" client can live on any loop, and "_async_loop" is itself a constructor argument (_async_loop => $my_loop), in which case the default Net::Async::HTTP backend is added to that loop. "async_loop" returns the right loop in all of these cases.
_async_loop
The IO::Async::Loop the real-async client is added to. Built lazily and only on the Net::Async::HTTP path; the sync fallback never touches it, so no event loop is created when running synchronously. The default is IO::Async::Loop->new, the process-wide loop; it can be passed at construction to put the default backend on another loop (see "EVENT LOOP"). Use "async_loop" to read the backend's loop.
SUPPORT
Issues
Please report bugs and feature requests on GitHub at https://github.com/Getty/langertha/issues.
IRC
Join #langertha on irc.perl.org or message Getty directly.
CONTRIBUTING
Contributions are welcome! Please fork the repository and submit a pull request.
AUTHOR
Torsten Raudssus <getty@cpan.org>
COPYRIGHT AND LICENSE
This software is copyright (c) 2026 by Torsten Raudssus https://raudssus.de/.
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.