NAME

HTTP::API::Client - lightweight HTTP/REST client with per-request signing hooks, retry-with-backoff, and JSON/form encoding

DESCRIPTION

Talking to an authenticated JSON or form-urlencoded REST API with plain LWP::UserAgent usually means the same boilerplate on every call: compute a signature or auth header from the request's own data, remember not to leak the signing secret into the body, retry on a flaky response, and serialize Perl values (which have no native boolean and no native comma-list) unambiguously. HTTP::API::Client is that boilerplate, written once - a thin LWP::UserAgent wrapper with:

  • An event/callback system (see "new_request(%options)") that computes header or data values from the rest of the request at build time - the mechanism for a signed-request header, not a special case bolted on top of it.

  • Retry-with-backoff on failed responses, configurable per status code (see "ENVIRONMENT VARIABLES").

  • JSON and form-urlencoded body encoding from the same %data hash, including HTTP::API::DataTypeMarker's xTRUE/xCSV-family markers for the values Perl scalars can't represent unambiguously on their own.

If none of that boilerplate applies to what you're calling - no signing, no retry policy, plain data - reaching for LWP::UserAgent or HTTP::Tiny directly is simpler. This module earns its weight specifically for authenticated API clients that would otherwise reimplement the same header-signing/retry logic by hand.

USAGE

use HTTP::API::Client;

The constructor takes no required arguments:

my $ua = HTTP::API::Client->new(
    base_url => "https://api.example.com",
);

A signed request

This is the case the module exists for: compute an auth header from the request's own data, once, at construction - every call this client makes carries it automatically, and the secret used to compute it never appears in the request itself.

my $ua = HTTP::API::Client->new(
    base_url => "https://api.example.com",
    pre_defined_data => {
        api_key    => "AKIA123",
        api_secret => sub { "s3cr3t" },    # or read from config/env
    },
    pre_defined_headers => {
        APIKEY    => sub { my (undef, %o) = @_; $o{data}{api_key} },
        Signature => sub {
            my (undef, %o) = @_;
            "$o{data}{api_key}:$o{data}{api_secret}";    # a real HMAC in practice
        },
    },
    pre_defined_events => {
        not_include => { api_secret => 1 },    # keep the secret out of the body/query
    },
);

$ua->get("/search", { q => "widgets" });
# -> GET https://api.example.com/search?api_key=AKIA123&q=widgets
#    APIKEY: AKIA123
#    Signature: AKIA123:s3cr3t

See "new_request(%options)" for the full list of build-time hooks this uses.

Shorthand methods

get/post/put/head/delete are shorthand over send():

$ua->get($url);                       # same as $ua->send( GET, $url )
$ua->post($url, \%data, \%headers);   # same as $ua->send( POST, $url, \%data, \%headers )

JSON in, JSON out

my $ua = HTTP::API::Client->new(base_url => "http://example.com");
$ua->get("/search", { q => "something" });
my $decoded = $ua->json_response;   # the response body, JSON-decoded

Form-urlencoded

my $ua = HTTP::API::Client->new(content_type => "application/x-www-form-urlencoded");
$ua->post("http://example.com", { q => "something" });
my $response = $ua->last_response;   # an HTTP::Response object

Body encoding only knows how to serialize into JSON or a query string (see convert_data() below) - any other content type must be built and passed in already-encoded.

ATTRIBUTES

All constructor arguments below are also read-write accessors ($ua->base_url("http://new-host")), except engine which is read-only after construction. Several fall back to an environment variable (see "ENVIRONMENT VARIABLES") when not passed explicitly.

base_url

Prefixed onto every $path passed to send() (and the shorthand methods). Not set by default - a bare path is used as-is.

username / password

HTTP Basic auth credentials. If either is set, Basic auth is used and auth_token is ignored. Default from HTTP_USERNAME/HTTP_PASSWORD.

auth_token

Sent verbatim as the Authorization header when username/password are both unset. Default from HTTP_AUTH_TOKEN.

content_type

Forces the request content type. Left unset, GET defaults to application/x-www-form-urlencoded and every other method defaults to application/json; charset=$charset.

charset

Used to build the default JSON content-type header and to decide whether UTF-8 byte-encoding is applied to the request body. Default utf8, from HTTP_CHARSET. Must be a real JSON::XS charset method name (utf8, latin1, ascii, ...) - an invalid value dies immediately and clearly, naming the bad value, the next time kvp2json() is called, whether or not json has already been built.

timeout

Request timeout in seconds, passed to the underlying LWP::UserAgent. Default 60, from HTTP_TIMEOUT. Re-applied to ua at the start of every send() call, so changing it between calls takes effect on the next one, not just at construction.

ssl_verify

Passed through as verify_hostname to LWP::UserAgent's ssl_opts. Default off (0), from SSL_VERIFY. Re-applied to ua at the start of every send() call, so changing it between calls takes effect on the next one, not just at construction.

pre_defined_data / pre_defined_headers / pre_defined_events

Hashrefs merged underneath the %data/%headers/%events passed to each individual call - set once at construction time for values every request should carry (an API key, a fixed header, a standing callback), then override per-call as needed.

engine

Read-only. Defaults to "LWP::UserAgent", the only engine send() actually dispatches requests through - _build_ua will call a same-named method on a subclass to build an alternate UA object, but send() itself does not yet know how to use anything other than LWP::UserAgent with it (see t/12_unsupported_engine.t: setting a different engine fails with a clear error rather than silently doing nothing).

last_response

Read-write. The most recent HTTP::Response, set by send() and read by "json_response"/"kvp_response". undef until the first request.

ua

Read-write. The underlying user-agent object send() dispatches through - an LWP::UserAgent by default, built lazily by _build_ua on first use, then reused for every subsequent call. Set this directly to inject a stand-in (a fake, a mock, a pre-configured instance) instead of the real one; this is how the test suite avoids making real network calls. browser_id/timeout/ssl_verify are re-applied to it (via $ua->agent/$ua->timeout/$ua->ssl_opts, each only if ua supports that method) at the start of every send() call, not just when it's first built.

browser_id

Read-write. The User-Agent header value passed to the underlying ua. Defaults to "HTTP API Client v$VERSION" - $VERSION is only set when running the installed CPAN release (Dist::Zilla's [PkgVersion] plugin injects it at build time), so a development checkout reports "HTTP API Client vdev" instead. Re-applied to ua at the start of every send() call, so changing it between calls takes effect on the next one, not just at construction.

retry_config

Read-write hashref { fail_response => $n, fail_status => $csv, delay => $seconds }. The programmatic equivalent of RETRY_FAIL_RESPONSE/RETRY_FAIL_STATUS/RETRY_DELAY - set this directly to configure retry behavior at construction without touching process environment variables. A partial hashref is fine - any key you omit falls back to that same env var's default (0/''/5), not to the environment variable itself. Re-applied at the start of every send() call, so changing it between calls takes effect on the next one, not just at construction. See "ENVIRONMENT VARIABLES" for what each key does.

json

Read-write. The JSON::XS instance kvp2json() encodes through, built lazily with -canonical->allow_nonref>. Set this directly to inject a differently-configured instance (e.g. one with -allow_blessed> enabled) instead of the default. charset is re-applied to it (via its utf8/latin1/etc method) at the start of every kvp2json() call, not just when json is first built, so a charset change takes effect on the next JSON encode even if json was already in use - this also applies to an injected custom instance.

ENVIRONMENT VARIABLES

These environment variables expose the controls without changing the existing code.

HTTP VARIABLES

HTTP_USERNAME   - basic auth username
HTTP_PASSWORD   - basic auth password
HTTP_AUTH_TOKEN - basic auth token string
HTTP_CHARSET    - content type charset. default utf8
HTTP_TIMEOUT    - timeout the request in seconds. default 60 seconds.
SSL_VERIFY      - verify ssl url. default is off

DEBUG VARIABLES

DEBUG_IN_OUT               - print out request and response in string to STDERR
DEBUG_SEND_OUT             - print out request in string to STDERR
DEBUG_RESPONSE             - print out response in string to STDERR
DEBUG_RESPONSE_HEADER_ONLY - print out response header only without the body
DEBUG_RESPONSE_IF_FAIL     - narrows DEBUG_IN_OUT/DEBUG_RESPONSE to only print
                             on a failed response. Does nothing by itself -
                             DEBUG_IN_OUT or DEBUG_RESPONSE must also be set.

RETRY VARIABLES

RETRY_FAIL_RESPONSE  - number of time to retry if response comes back is failed. default 0 retry.
                       A negative value is clamped to 0 (no retry) rather than silently
                       making zero attempts.
RETRY_FAIL_STATUS    - only retry if specified status code. e.g. 500,404
RETRY_DELAY          - retry with wait time of 5 seconds in between, by default. A negative
                       value is clamped to 0 rather than reaching sleep() as-is.

METHODS

get / post / put / head / delete ($path, \%data, \%headers, \%events)

Shorthand for send($METHOD, $path, \%data, \%headers, \%events). $path is appended to the base_url attribute if one was given. Returns whatever send() returns - normally an HTTP::Response.

send($method, $path, \%data, \%headers, \%events)

Builds and sends one HTTP request, retrying per "ENVIRONMENT VARIABLES" if configured. %data and %headers are merged over pre_defined_data and pre_defined_headers. %events are the per-call callbacks documented under new_request() below. Sets and returns the last_response attribute.

Passing $events->{test_request_object} = 1 makes it return the built HTTP::Request instead of sending it - the way this module's own test suite inspects what would have gone out.

json_response

Decode the last_response body as JSON and return the resulting hashref. Never dies - a missing response or invalid JSON comes back as { status => "error", error => $message } instead.

kvp_response

Parse the last_response body as a key=value&key=value query string and return it as a hashref. Returns {} if no request has been made yet, or the response body is empty. A key that repeats (the shape kvp2str_each produces when encoding an array-valued field) decodes to an arrayref of every value seen, in order; a key seen once still decodes to a plain scalar.

new_request(%options)

Builds the HTTP::Request for one call. This is where the %events callbacks passed to send() are read: before_headers, headers_keys / add_headers_keys (which header keys to consider, in what order), before_header/after_header keyed by header name, and after_header_keys - the hooks used to compute things like a signature header from other data at build time. See t/04_callbacks.t for a worked example (API key + signature).

%options also accepts skip_headers, a hashref of header names to exclude from the request no matter what %headers or the events above say. Only reachable by calling new_request() directly with it - send() never sets it, and setting it inside an %events callback has no effect (a callback receives a snapshot copy of %options, not the one new_request() itself is iterating). See t/23_skip_headers.t.

get_content_type(%options)

Resolves the effective content type for one call: the content_type attribute if set explicitly; otherwise application/x-www-form-urlencoded for a GET, or application/json; charset=$charset for anything else. Called internally by convert_data(), prepare_request(), and new_request() - there is normally no need to call this directly.

convert_data(%options)

Turn %options's data hashref into the request body, according to content type: JSON via kvp2json() if the content type contains json, a query string via kvp2str() if it is exactly application/x-www-form-urlencoded. Any other content type returns an empty string for empty data, or dies naming the content type - there is no generic way to serialize arbitrary data into an arbitrary content type.

prepare_request(%options)

Builds the bare HTTP::Request (method, URL, content-type header) and applies authentication: username/password take priority and are sent as HTTP Basic auth via basic_authenticator(); auth_token is used only if neither is set, and is sent as a raw Authorization header value verbatim (no Bearer prefix is added for you - include it in the token itself if the API expects one). Called by new_request() - there is normally no need to call this directly.

basic_authenticator($request, $username, $password)

Sets HTTP Basic auth on $request via $request->headers->authorization_basic. Override this in a subclass to change how basic auth is applied without touching the rest of request building.

kvp2json(%options) / kvp2str(%options)

The two body encoders convert_data dispatches to. kvp2json walks %options's data hashref and JSON::XS-encodes it, resolving any CODE values by calling them and unwrapping HTTP::API::DataTypeMarker's xBOOLEAN-family markers into their JSON form; an xCSV-marked value has no special JSON handling and just JSON-encodes as an ordinary array (there's no key=value&key=value repetition problem in JSON for it to solve). kvp2str produces a key=value&key=value query string instead, with xCSV-marked values joined by commas instead of repeated per key. An empty arrayref value is omitted from kvp2str's output entirely (an empty array has no key=value representation), the same as a key mapped to undef/missing already is. Both respect an $events->{keys} callback to control which keys are included and in what order, and both accept a skip_key hashref in %options (same reachability caveat as new_request()'s skip_headers above - only useful from a direct call, e.g. a data callback recursively re-invoking kvp2str()/kvp2json() on itself to build its own value without infinite-looping on its own key; see t/04_callbacks.t) to exclude a key from the encoded body.

kvp2json_each(%options) / kvp2str_each(%options)

The per-value helpers kvp2json()/kvp2str() recurse into for each key via %options's value (and, for a top-level field, key - the field's own key, available to a CODE value's callback via %options in both encoders alike). Resolves a CODE value by calling it, unwraps HTTP::API::DataTypeMarker markers (xCSV/xBOOLEAN and friends), and recurses into plain array/hash values. There is normally no need to call these directly - they exist as public methods so a data/value callback can recursively re-invoke them on itself (see kvp2json()/kvp2str() above).

These two are not symmetric on invalid input: kvp2json_each dies if a raw-bytes (non-UTF8-flagged) value is not valid UTF-8, rather than silently corrupting it into U+FFFD replacement characters - JSON has no way to represent arbitrary bytes. kvp2str_each has no such restriction; a query string can carry any byte sequence unescaped-safe via percent-encoding, so the same input that dies in the JSON path passes through the form-urlencoded path unchanged.

LICENSE AND COPYRIGHT

This software is Copyright (c) 2026 by Michael Vu.

This is free software, licensed under:

The MIT (X11) License

The MIT License

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.