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 and json_response() decodes 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()/ json_response() call, not just when json is first built, so a charset change takes effect on the next encode or decode 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
                       An empty entry (a doubled comma, stray whitespace) is
                       skipped rather than becoming a bogus always-false entry.
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.

None of \%data/\%headers/\%events are mutated - send() copies each before doing anything with it, so a hashref you pass in (and may reuse across other calls) comes back exactly as you passed it, with no merged-in pre_defined_* keys or event-hook keys added by send()'s own machinery.

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. charset is re-applied to json at the start of every call, the same as kvp2json() does on the encode side, so a charset change takes effect on the next decode too.

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. Decodes both percent-encoding and the application/x-www-form-urlencoded convention of a literal + meaning a space (a genuinely percent-encoded literal +, i.e. %2B, still decodes to +) - this matters for a response body from any API, not just one built by this module's own kvp2str_each, which never emits a raw + itself. An empty &-separated segment (a leading, trailing, or doubled &) is skipped rather than decoded into a bogus '' => undef entry.

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). auth_token only supplies a default Authorization header - an explicit one already present in %headers for this call (any casing: Authorization, authorization, ...) is left alone rather than overwritten. 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 - including an empty array nested inside an xCSV(...) list, which contributes nothing to the joined string rather than leaving a blank comma segment behind. An xCSV() with no elements at all is different: it still emits key= (present, empty value) rather than being omitted - there is no join-corruption risk to avoid the way there is for a nested empty array, and key= is valid, parseable form data distinct from the key being absent. 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.

kvp2str alone also fires $events->{before_sorting_keys} and $events->{after_sorting_keys}, each called with keys => \@keys - an arrayref of the field names about to be (respectively, just were) sorted. Both are genuinely live: a callback that mutates @$keys in place (push/splice/grep out an element) changes what kvp2str actually encodes, as long as $events->{keys} isn't also set (which replaces @keys outright, discarding before_sorting_keys' mutation). See t/21_before_events.t and t/33_before_sorting_keys_mutation.t. kvp2json has no equivalent hook - its @keys is either $events->{keys}'s return value or an unsorted keys %$data, with no separate sorting step to hook into.

kvp2json encodes a numeric-looking string value as a genuine JSON number rather than a quoted string - but only when doing so loses nothing (stringifying the number back reproduces the original value exactly). A value like "5" becomes 5, but "00501" (a US zip code) or "5.0" stays a string, since numifying either would silently discard meaningful formatting. A value like "NaN", "Inf", or "-Inf" also stays a string even though it round-trips losslessly as a Perl number - JSON has no valid representation for a non-finite number, and numifying it would make kvp2json emit the bareword nan/inf/-inf, which is not valid JSON and no parser (including this module's own JSON::XS) can decode. kvp2str never numifies at all - a query string has no separate number/string type, so there was never a reason to risk altering the original representation.

kvp2json_each(%options) / kvp2str_each(%options)

The per-value helpers kvp2json()/kvp2str() recurse into for each key via %options's value (and key - the field's own key, available to a CODE value's callback via %options; kvp2json_each passes it through at every level, including a nested hash value's own key, not just the top-level field's). Resolves a CODE value by calling it, unwraps HTTP::API::DataTypeMarker markers (xCSV/xBOOLEAN and friends), and recurses into plain array 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, in two ways. First, 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. Second, a plain hash value recurses in kvp2json_each (JSON has a native object type for it) but dies in kvp2str_each - a query string has no standard convention for representing a nested hash.

Both die - with the same message, naming the offending ref type and pointing at xBOOLEAN() - on any other reference type neither of them otherwise recognizes, most commonly a bare (not xBOOLEAN-wrapped) scalar ref: a value like \$flag passed directly, typically from forgetting to call xBOOLEAN(\$flag) around it. Before this die was added, kvp2str_each silently stringified such a value as SCALAR(0x...), and kvp2json_each reached the same outcome only by accident of JSON::XS itself refusing to encode an arbitrary scalar ref - with a message that never mentioned this module's own vocabulary.

kvp2str_each's %options also accepts no_key, set internally when recursing into an ARRAY or xCSV element: when true, the returned fragment omits the leading key= prefix (a plain scalar, xCSV, and xBOOLEAN-marked value all honor this identically) so a CSV/array element joins as a bare value rather than a spurious embedded key=value pair.

Both encoders unwrap an xBOOLEAN(\$flag) live scalar ref to whatever $flag currently holds, not just the 0/1 case xTRUE/xFALSE use - a live ref that currently holds exactly (eq, not merely numerically ==) the string 0 or 1 still becomes a native JSON boolean in kvp2json_each (matching xTRUE/xFALSE; JSON::XS's own convention only accepts that exact canonical form, not e.g. "01" or "1.0"), but any other live value - including one that is numerically equal to 0/1 without being formatted exactly that way - encodes as its own actual contents in both encoders alike, the same way a plain (non-live) xBOOLEAN value already did - including kvp2json_each's invalid-UTF-8 die documented above, which a live non-canonical value goes through exactly like the plain-scalar case does. A live value that currently holds undef is treated as an empty string in both encoders, not undef itself - it never reaches the invalid-UTF-8 check (an empty string is trivially valid UTF-8) and encodes as "" in JSON, matching how kvp2str_each already handled this case.

A plain (non-live) xBOOLEAN value - anything other than the \1/\0 shape xTRUE/xFALSE use, including a value passed to xBOOLEAN() directly with no leading backslash - goes through the exact same invalid-UTF-8 die and lossless-numify treatment in kvp2json_each as the plain scalar branch: xBOOLEAN("5") encodes as the JSON number 5, and invalid UTF-8 bytes die with the same message rather than silently corrupting into JSON, just like a plain scalar value with the identical content would.

xBOOLEAN() only accepts a plain scalar or a scalar ref - wrapping anything else (an arrayref, a hashref) dies with a clear message naming the offending ref type in both encoders, rather than silently stringifying it (e.g. into "HASH(0x...)") the way an unmarked reference of the wrong shape does elsewhere in this module (see kvp2str_each's nested-hash die, above).

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.