2026-08-19 - v1.24
+ docs: added POD ATTRIBUTES entries for "retry" and "debug_flags" -
both are public read-write constructor attributes that had no
documentation at all, the same gap class HAC-052 already fixed
for retry_config/json. retry's entry carries the same warning
already present in its in-code comment (it is a one-time snapshot,
not a live view of send()'s actual retry behavior, despite the
naming symmetry with retry_config). A new regression test
(t/79) cross-checks every declared attribute against the POD
ATTRIBUTES section so this can't silently recur (HAC-101)
+ fix: kvp2str_each's CSV branch used the s///r non-destructive
substitution modifier, which requires Perl 5.14+ - cpanfile
declares a 5.010 minimum (and ships that floor to CPAN/
cpantesters via META.json), so any install/test on a genuinely
5.10-5.13.x Perl would hit a hard compile-time syntax error.
Rewritten to the same copy-then-substitute idiom using only
Perl-5.10-compatible syntax; output confirmed byte-identical
(HAC-102)
+ cleanup: Client.pm's @EXPORT is now derived from
DataTypeMarker.pm's @EXPORT instead of being a hand-copied,
independently-maintained duplicate of it - DataTypeMarker.pm's
SYNOPSIS promises "use HTTP::API::Client" re-exports everything
it defines, but nothing previously enforced the two lists staying
in sync. Behavior-neutral (both lists were already identical); a
new regression test (t/80) guards against future drift (HAC-104)
+ fix: convert_data() and new_request()'s GET-restriction check
required an EXACT match for a form-urlencoded content_type, while
the JSON branch already matched leniently (any content_type
containing "json", including a charset-qualified value - this
module's own default). A form-urlencoded content_type with a
trailing parameter, e.g. "application/x-www-form-urlencoded;
charset=utf-8" (a valid Content-Type value, and the same pattern
the JSON default uses), died instead of working. Both sites now
accept an anchored prefix match (HAC-107)
+ fix: an xBOOLEAN()/xCSV() marker used as a header value (rather
than in body data) silently stringified to Perl's default
blessed-reference form ("BOOL=ARRAY(0x...)") instead of being
unwrapped or dying - the same silent-corruption failure class
HAC-020/HAC-093/HAC-096 already established this project must
never do. new_request()'s header-application loop now dies
naming the offending header key (HAC-109)
+ fix: _execute_callbacks() resolved sibling CODE values within the
same %data (or %headers) hash in Perl's randomized hash key
order - a callback reading another key's already-resolved value
got either the resolved value or the raw, unresolved CODE ref,
purely depending on the process's hash seed. The identical
script, run repeatedly, produced two different outcomes with no
error either way. Now sorts keys before iterating, so the same
data structure resolves identically on every run; documented the
resulting order in send()'s POD (HAC-110)
+ test: extracted the capture_stderr test helper (identically
duplicated across t/16, t/66, t/77) into t/lib/CaptureStderr.pm,
matching t/lib/FakeUA.pm's existing style. Test-only, zero
behavior change (HAC-111)
+ test: added regression coverage for the documented
before_sorting_keys-mutation-discarded-by-events->{keys}
interaction - already correct, never previously exercised
together in one test (HAC-112)
+ cleanup: 4 older die() messages ("Unsupported engine", "Unable to
create a get request", "Unable to convert data for content_type",
"Unable to convert nested hash value") now end with a trailing
newline, suppressing Perl's redundant "at FILE line N" suffix -
matching every other die() in the file. Message-text-only;
behavior-neutral for every existing test (HAC-114)
+ meta: added [MetaResources] to dist.ini - META.json shipped with
no repository, bugtracker, or homepage URL at all, so MetaCPAN
had nothing to link back to this distribution's real GitHub repo
with. Metadata-only, no runtime code affected (HAC-115)
+ fix: xBOOLEAN()/xCSV() blessed markers into bare, unqualified
package names "BOOL"/"CSV" instead of namespaced ones - a real
Perl class-name collision risk, since kvp2json_each/kvp2str_each
identify these markers purely by a ref() string check. Verified
live: an unrelated object merely blessed into a class also named
"BOOL" crashed kvp2json with a confusing "Not an ARRAY reference"
error instead of a clear diagnosis. Now bless into
HTTP::API::DataTypeMarker::BOOL/::CSV, eliminating the collision
risk - a deliberate, visible change to what ref() returns for
objects from xBOOLEAN()/xCSV() (HAC-108)
+ docs: documented that kvp2json_each's undef handling differs by
nesting depth - a top-level undef value is omitted, but a nested
hash's undef sub-value stays as an empty JSON string. Already
correct, deliberately kept as-is; only the POD was missing (HAC-105)
+ docs: narrowed send()'s POD claim that "none of %data/%headers/
%events are mutated" - it's a shallow copy (deliberately, so
xBOOLEAN(\$flag)'s live-tracking keeps working), so a callback that
mutates a nested value in one of these still mutates the caller's
original data structure. The POD now says so explicitly instead of
overclaiming (HAC-106)
+ fix: prepare_request()'s username/password/auth_token checks used
plain Perl truthiness ("if ($u || $p)"), which silently treats the
valid, non-empty string "0" as unset - inconsistent with this
module's own established length()-based emptiness convention
(_defor). Verified live: username=>"0", password=>"0" (both
explicitly set) sent no Authorization header at all; auth_token=>
"0" had the identical bug. Both now check length() instead of
truthiness (HAC-118)
+ meta: added MIME::Base64 to cpanfile's test-phase requires - used
by 4 test files (since t/02, predating this session) but never
declared (HAC-119)
+ meta: also added Test::More (used by all 87 test files),
FindBin (10 files), and File::Temp (t/83) to cpanfile's
test-phase requires - a fuller follow-up check after HAC-119
caught only MIME::Base64 (HAC-120)
2026-08-19 - v1.23
+ cleanup: removed the trailing "return $v" (and its now-redundant
"if ref $v" guard) from kvp2json_each and kvp2str_each - a direct
follow-up to HAC-096's die guard, which left both lines provably
unreachable (every non-reference value already returns earlier,
and any remaining reference is now always caught by the die).
Zero behavior change; confirmed by an identical full-suite run
before and after, and by Devel::Cover no longer listing either
line as permanently-0%-executed dead weight (HAC-098)
+ test: added regression coverage for send()'s "-- RETRY N of M"
debug line, only reachable when a debug flag is on AND an actual
retry attempt happens - no existing test combined both. Already
correct behavior; Client.pm now reaches 100% statement coverage
(HAC-099)
+ test: added regression coverage for new_request() skipping
_tune_utf8 when charset is not "utf8" - already correct behavior,
never exercised through a real send()/post() call before this
(HAC-100)
2026-08-19 - v1.22
+ fix: kvp2str_each silently stringified a bare (not xBOOLEAN-wrapped)
SCALAR ref data value as "SCALAR(0x...)" instead of dying - the
same silent-corruption failure class HAC-020 (nested hash) and
HAC-093 (xBOOLEAN's own non-scalar-ref guard) already established
this project must never do. kvp2json_each's equivalent fallback
technically died too, but only by accident of JSON::XS itself
refusing to encode an arbitrary scalar ref, with a message that
never mentioned this module's own xBOOLEAN() vocabulary. Both
encoders now die with the same clear message, naming the ref
type and pointing at xBOOLEAN() (HAC-096)
+ test: added regression coverage for new_request()'s GET branch
when the target URL already contains a '?' - the behavior was
already correct but had zero test executions before this
(HAC-097)
2026-08-19 - v1.21
+ fix: kvp2json/kvp2json_each silently emitted invalid JSON for a
string data value that Scalar::Util::looks_like_number() recognizes
as a non-finite number - exact casings "NaN", "Inf", "-Inf" (whose
Perl NV-to-string roundtrip matches the original string
byte-for-byte, satisfying the existing lossless-numify check meant
for ordinary numeric strings like "5") were numified into a real
Perl NaN/Infinity NV, which this module's utf8-mode JSON::XS
encoder then wrote as the bareword token nan/-nan/inf/-inf instead
of a quoted string - not valid JSON, undecodable by any standard
JSON parser including JSON::XS itself, with no error or warning.
_numify_if_lossless() now leaves such a value as its original
string via a POSIX::isnan/isinf guard, the same way any other
non-lossless numeric string already stays a string (HAC-095)
2026-08-19 - v1.20
+ fix: prepare_request() unconditionally wrote an Authorization
header from auth_token whenever set (and username/password
weren't), even when the caller had already explicitly passed
their own Authorization header (any casing) for that specific
call - the explicit per-call value was silently discarded with no
error or warning, reachable regardless of the header's casing
since HTTP::Headers normalizes names case-insensitively while the
underlying Perl hash keys don't. auth_token now only supplies a
default when no Authorization header is already present (HAC-094)
+ fix: xBOOLEAN() wrapping a reference other than a scalar ref (an
ARRAY or HASH ref) silently stringified to "ARRAY(0x...)"/
"HASH(0x...)" in both kvp2json_each and kvp2str_each instead of
dying with a clear message - the same failure class HAC-020
already established this project must never do silently. Both
encoders now die naming the offending ref type; a plain scalar
and a genuine scalar ref (xBOOLEAN's two documented input shapes)
are unaffected (HAC-093)
+ fix: kvp2json_each's BOOL branch applied HAC-055/HAC-070/HAC-084's
invalid-UTF-8-die and lossless-numify protection only to the LIVE
scalar-ref case (xBOOLEAN(\$var)) - a PLAIN (non-ref) value, e.g.
xBOOLEAN($value) called with a bare scalar, fell straight through
to the raw unprotected value. A plain xBOOLEAN() holding invalid
UTF-8 silently produced mojibake instead of dying, and a plain
numeric-looking xBOOLEAN() value was never numified. Both are
fully public, documented input shapes per xBOOLEAN's own POD ("a
plain scalar or a scalar ref") - the same gap HAC-084 fixed for
the live-ref case, missed for the plain-value case. The
native-JSON-boolean behavior for a live ref holding exactly
'0'/'1' (xTRUE/xFALSE's shape) is unaffected (HAC-092)
+ fix: kvp2json_each's BOOL branch decided whether to pass a live
xBOOLEAN(\$var) scalar ref through unchanged to JSON::XS using
NUMERIC equality ($$inner == 0 || == 1), but JSON::XS itself only
accepts the exact canonical string/number 0 or 1. A live ref
holding "01", "1.0", " 1", or "1e0" (all numerically 1, none
canonically formatted) crashed with JSON::XS's raw "cannot encode
reference to scalar ... unless the scalar is 0 or 1" instead of
going through the numify/validate path every other live value
already uses. Fixed by comparing with eq against '0'/'1' instead
of == (HAC-090)
+ fix: kvp2json_each crashed on a live xBOOLEAN(\$var) reference
holding undef - a regression introduced by HAC-084/HAC-090
combining without an undef guard: $$inner eq '0'/'1' warned on
undef, and since neither matched, the undef value reached
_json_validate_utf8()/numify unguarded and died with a confusing
"value is not valid UTF-8" error. Now defaults the live value to
'' before the comparison, matching kvp2str_each's own undef
handling (HAC-082) (HAC-091)
+ fix: send() merged pre_defined_data/pre_defined_headers/
pre_defined_events directly into the caller's own
\%data/\%headers/\%events hashrefs instead of copying them first,
and new_request()/kvp2json()/kvp2str() autovivified
before_header/after_header/not_include keys into the caller's
actual \%events hashref even when never set. A caller reusing a
hashref across multiple calls (a natural pattern) got silently
corrupted state back. send() now shallow-copies all three
immediately, before any merging or event processing happens
(HAC-083)
+ fix: kvp2json_each's BOOL branch bypassed HAC-055's invalid-UTF-8
protection for a live xBOOLEAN(\$var) reference holding raw
non-UTF8-valid bytes - it went straight to numification without
the UTF-8 validate-or-die step the main scalar branch does,
silently producing mojibake instead of dying with a clear error.
Both paths now share a _json_validate_utf8() helper (HAC-084)
+ fix: DEBUG_RESPONSE_IF_FAIL's own POD says it narrows DEBUG_IN_OUT
to only print on a failed response, but only the RESPONSE half of
DEBUG_IN_OUT's output was actually narrowed - the REQUEST dump
printed unconditionally whenever DEBUG_IN_OUT was set, regardless
of whether the response succeeded. DEBUG_SEND_OUT (a separate
flag, not mentioned in DEBUG_RESPONSE_IF_FAIL's POD) is
deliberately left unnarrowed (HAC-085)
+ fix: RETRY_FAIL_STATUS (and the equivalent retry_config->{
fail_status}) with an empty or whitespace-only entry (a doubled
comma, a lone whitespace segment) created a bogus '' key in the
retry-status match set. Harmless in practice, but inconsistent
with how empty segments are now skipped elsewhere in this module
(kvp_response, HAC-075). Empty entries are now skipped entirely
(HAC-086)
+ test: xCSV() with zero elements emits "key=" (present, empty
value) rather than being omitted like a top-level empty plain
array is (HAC-065) - this was correct and intentional (no
join-corruption risk to avoid the way a nested empty array has)
but previously undocumented and untested (HAC-087)
+ docs: the retry attribute (and its _build_retry builder) has been
disconnected from send()'s actual behavior since HAC-068 - send()
calls _build_retry directly on every call rather than reading the
memoized attribute, so retry_config changes take effect live, but
$api->retry itself is just a one-time snapshot. Added a code
comment to prevent this being mistaken for dead/reintroducible
logic (HAC-088)
+ docs: refreshed README's coverage baseline, stale since v1.15 -
Client.pm 98.9%/93.1%/80.9% (was 98.8%/92.8%/76.6%),
DataTypeMarker.pm still 100% (HAC-089)
2026-08-18 - v1.19
+ fix: kvp2str_each's BOOL branch produced a spurious "Use of
uninitialized value" warning when a live xBOOLEAN(\$var) reference
currently held undef - the undef value reached string
interpolation undefaulted. kvp2json_each already handled the
identical input silently (encodes JSON null). kvp2str_each now
defaults an undef live value to '' before escaping, matching the
scalar branch's existing convention (HAC-082)
2026-08-18 - v1.18
+ fix: kvp2json_each's BOOL branch unconditionally returned the
marker's inner value, which is only safe when that value is
exactly \1 or \0 (xTRUE()/xFALSE()'s shape) - JSON::XS's own
convention for a native boolean via a scalar ref. xBOOLEAN(\$flag)
is documented as a general live-tracking mechanism, not restricted
to 0/1 - for any other live value (e.g. xBOOLEAN(\$status) where
$status holds "active"), kvp2json_each handed JSON::XS a raw
scalar ref it cannot encode at all, dying with "cannot encode
reference to scalar ... unless the scalar is 0 or 1". kvp2str_each
already dereferenced this exact case correctly. kvp2json_each now
matches it: a live ref currently holding exactly 0/1 still gets
JSON::XS's native boolean treatment, any other live value
dereferences and encodes as its own actual contents (HAC-081)
2026-08-18 - v1.17
+ docs: kvp2str's before_sorting_keys/after_sorting_keys events are
real, working, and tested (t/21_before_events.t,
t/33_before_sorting_keys_mutation.t) but were never mentioned in
the METHODS POD, which only documented the shared events->{keys}
callback both encoders use. Documented both hooks: kvp2str-only,
genuinely live (a callback mutating @$keys changes the actual
output), before_sorting_keys' mutation only survives if
events->{keys} isn't also set (HAC-079)
+ refactor: send()'s outer $retry (a settings hashref: count/status/
delay) was immediately shadowed by the RETRY loop's own "foreach
my $retry (0..$retry_count)" loop counter. Currently harmless -
the hashref is fully consumed into separate lexicals before the
loop starts - but a real trap for any future edit inside the loop
expecting $retry->{...}. Renamed the outer variable to
$retry_settings; no behavior change (HAC-080)
2026-08-18 - v1.16
+ fix: kvp2str_each's CSV branch always prefixed "$k=" regardless of
%options' no_key flag, unlike the scalar/BOOL/ARRAY branches which
all respect it. An xCSV value nested inside another xCSV is
recursed into with no_key => 1 (the same mechanism HAC-071 fixed
for a BOOL nested in CSV), but the CSV branch ignored it -
xCSV(6, 7, xCSV(13, 14), 15) encoded to "e=6,7,e=13,14,15" (a
bogus embedded "e=") instead of "e=6,7,13,14,15". CSV nested in
CSV was the one nesting combination HAC-069/071 didn't cover
(HAC-078)
2026-08-18 - v1.15
+ fix: kvp_response() only decoded %XX percent-encoding, never the
application/x-www-form-urlencoded convention of a literal +
meaning a space - decoding "q=hello+world" returned
{ q => "hello+world" } instead of { q => "hello world" }.
kvp2str_each's own encoder never emits a raw + (it uses %20), so
this only bit when kvp_response decoded a response body from any
other API using the + convention, which is extremely common. A
genuinely percent-encoded literal + (%2B) still decodes correctly,
distinct from a raw + meaning space (HAC-074)
+ fix: kvp_response() produced a bogus '' => undef entry plus two
uninitialized-value warnings whenever the response body had an
empty &-separated segment (a leading, trailing, or doubled &,
e.g. "a=1&&a=2") - split /&/ yields an empty string for that
segment, and splitting *that* on = leaves both key and value
undef. Same failure signature HAC-060/061/065 already fixed on
the encode side, now closed on the decode side too - an empty
segment is skipped entirely (HAC-075)
+ fix: json_response() never re-applied charset before decoding,
unlike kvp2json() (HAC-067) which does on the encode side - json
is lazy and memoized, built once in whatever charset mode was set
at the time. Changing charset afterward and calling
json_response() again on the same response body still decoded
through the stale mode, silently producing mojibake for a
UTF-8-encoded body if json had first been built in a non-utf8
mode. json_response() now re-applies charset on every call,
matching kvp2json()'s existing behavior (HAC-076)
+ fix: xCSV()/xBOOLEAN() both blessed \@_ directly - a reference to
Perl's actual arguments array, which aliases the caller's
variables rather than copying their values. A marker built inside
a loop from a shared, reused scalar variable (a natural pattern)
silently ended up holding whatever value that variable had LAST,
not the value at the time each marker was created - e.g.
xCSV($x, $y, 3) then reassigning $x/$y afterward changed the
already-created marker's contents. Both constructors now bless a
fresh array (bless [ @_ ], ...), copying each value at call time;
explicitly passing a scalar ref (xBOOLEAN(\$flag)) still tracks
live, which remains the documented way to opt into that (HAC-077)
2026-08-18 - v1.14
+ fix: an empty ARRAY nested inside an xCSV(...) list left a stray
blank comma segment instead of being omitted - xCSV(6, 7, [], 15)
encoded to "e=6,7,,15" (a double comma) rather than "e=6,7,15".
kvp2str_each's ARRAY branch already returns '' for an empty array,
the same string a genuine empty-string scalar CSV element also
produces, so the CSV branch's loop couldn't tell them apart and
pushed both into the comma-joined list the same way. HAC-065
already established that an empty array is omitted entirely at
the top level of kvp2str - this extends the same treatment to an
empty array nested inside xCSV, without touching the genuinely
different case of a real empty-string element (HAC-069)
+ fix: kvp2json_each/kvp2str_each both unconditionally coerced any
numeric-looking string value via looks_like_number($v) ? $v+0 : $v
- a leading-zero value like a US zip code "00501" silently became
501 in both JSON and form-urlencoded output. kvp2json_each now
only numifies when the round-trip is lossless (stringifying the
numified value reproduces the original exactly), so "5" still
becomes a real JSON number but "00501"/"5.0"/"+5" stay strings.
kvp2str_each's numification is removed entirely - a query string
has no separate number/string type, so it only ever risked
corrupting the original representation for no benefit (HAC-070)
+ fix: kvp2str_each's BOOL branch ignored %options' no_key flag
entirely and always prefixed "$k=" onto its output, unlike the
scalar and ARRAY branches which respect it. A BOOL marker
(xTRUE/xFALSE) nested inside an xCSV(...) list is recursed into
with no_key => 1 like any other CSV element, but the leaked
prefix corrupted the comma-joined string - xCSV(6, 7, xTRUE(), 15)
encoded to "e=6,7,e=1,15" (a bogus embedded "e=") instead of
"e=6,7,1,15". Now respects no_key the same way the scalar branch
does (HAC-071)
+ fix: kvp2json_each's HASH branch never passed the nested hash's
own key into %o, so a CODE-valued callback nested inside a hash
saw its *outer* field's key instead of its own - the same class
of bug HAC-064 fixed at the top level, one recursion level deeper,
in a case HAC-064's own test never reached (HAC-072)
+ test: kvp2str_each's HASH branch already dies with a clear message
instead of recursing (a query string has no standard convention
for a nested hash, unlike kvp2json_each which recurses since JSON
has a native object type) - this was correct but undocumented and
untested, a third asymmetry alongside the two the METHODS POD
already called out (HAC-073)
2026-08-18 - v1.13
+ fix: kvp2str_each returned an empty string for a top-level
empty-arrayref-valued field instead of omitting it, and kvp2str's
caller loop pushed that empty string in among the other encoded
parts - { a => 1, tags => [], z => 9 } encoded to "a=1&&z=9" (a
stray double ampersand). Decoding that back through this module's
own kvp_response() produced a bogus '' => undef key and an
uninitialized-value warning - the same failure signature HAC-061
already fixed for a different trigger (an ARRAY nested inside
xCSV). An empty array is now omitted entirely, the same treatment
a key mapped to undef/missing already gets (HAC-065)
+ fix: browser_id/timeout/ssl_verify are documented as plain
read-write attributes, but ua is lazy and memoized - built once,
on first use, from whatever those attributes were at the time.
Changing any of them after the first request was a silent no-op:
the underlying LWP::UserAgent's agent/timeout/ssl_opts never
updated, with no error or warning. send() now re-applies all
three to the existing ua object at the start of every call, each
guarded by a can() check so FakeUA and other minimal test doubles
aren't broken (HAC-066)
+ fix: same class of bug as HAC-066, in the json attribute - lazy
and memoized, built once from whatever charset was set at the
time. Changing charset after the first JSON encode was a silent
no-op: an invalid charset no longer triggered HAC-046's documented
immediate die, and switching between two valid charsets had no
effect on the actual output bytes. kvp2json() now re-applies
charset to the existing json object at the start of every call
(HAC-067)
+ fix: same class of bug as HAC-066/HAC-067, in retry_config - retry
is lazy and memoized from _build_retry, built once from whatever
retry_config was at the time send() first ran. Changing
retry_config after that first call was a silent no-op: send() kept
using the stale memoized retry count/delay/status on every later
call. send() now recomputes retry fresh from the current
retry_config at the start of every call (HAC-068)
2026-08-18 - v1.12
+ docs: DataTypeMarker.pm's DESCRIPTION and Client.pm's kvp2json POD
both claimed kvp2json_each special-cases xCSV the same way
kvp2str_each does. It doesn't - a CSV-blessed arrayref satisfies
Perl's reftype-based ARRAY check regardless of blessing, so it
falls through to the generic array branch and JSON-encodes as a
plain array (xCSV(1,2,3) becomes [1,2,3]), which is the natural
JSON representation anyway - form-urlencoded's one-key-per-element
problem that CSV's comma-join solves doesn't exist in JSON. Only
BOOL markers are genuinely special-cased by both encoders. Fixed
the docs to match the (correct) actual behavior rather than
changing working code to match an overclaim (HAC-063)
+ fix: kvp2json() never passed a top-level field's own key into %o
for a CODE-valued field's callback, unlike kvp2str() which passes
key => $k - a callback nested inside JSON-encoded data couldn't
see its own key even though the identical callback could via the
form-urlencoded path. Same class of two-encoders-drift as
HAC-059/060/061, here in the callback mechanism rather than
array/CSV encoding (HAC-064)
2026-08-18 - v1.11
+ fix: kvp2json_each silently substituted U+FFFD replacement
characters into the JSON body for any data value that was raw
bytes but NOT valid UTF-8 (e.g. Latin-1 data from a legacy
file/DB), instead of erroring - now dies with a clear message
naming the raw bytes involved rather than silently corrupting the
value. The form-urlencoded encoder was never affected - only the
JSON path assumed all non-UTF8-flagged byte strings were valid
UTF-8 (HAC-055)
+ docs: fixed 'enviornment' typo in ENVIRONMENT VARIABLES POD
(HAC-056)
+ docs: documented that kvp2json_each's HAC-055 invalid-UTF8 die is
asymmetric with kvp2str_each, which has no such restriction for
the same input (HAC-057)
+ test: added coverage for new_request()'s GET-with-wrong-content_type
die, previously reachable but never exercised by the suite. The
sibling die in convert_data() was already covered by
t/10_unsupported_content_type.t (HAC-058)
+ fix: kvp2str_each double-escaped the key of any ARRAY-valued field
whose key contained a character uri_escape() touches (space, &, =,
%, non-ASCII) - { "a b" => ["x","y"] } produced "a%2520b=..."
instead of "a%20b=...", corrupting the outgoing query string. Caused
by the ARRAY (and CSV, though masked there by no_key short-
circuiting before it could surface) branch recursing with its own
already-escaped key instead of the raw one, so the next call's
unconditional escape ran twice. Existing tests never caught it
because none of their array/CSV-valued keys contained an escapable
character (HAC-059)
+ fix: kvp_response() silently collapsed a repeated query-string key
to its last value - decoding "tags=a&tags=b&tags=c" (exactly the
shape kvp2str_each's ARRAY branch produces when encoding an
array-valued field) returned { tags => 'c' }, losing 'a' and 'b'
with no warning. Repeated keys now decode to an arrayref of every
value seen, in order; a singleton key still decodes to a plain
scalar, unchanged (HAC-060)
+ fix: kvp2str_each double-ampersanded an ARRAY value nested inside
an xCSV(...) list - xCSV(6, 7, [13, 14], 15) encoded to
"e=6,7,15&&e=13&e=14" (double &). Decoding that back through this
module's own kvp_response() produced a bogus '' => undef key and
uninitialized-value warnings - a round-trip data-integrity bug.
t/05_kvp.t's existing expected-string literal had the double-&
baked in as "expected" rather than catching it (HAC-061)
+ fix: retry_config set directly with only some keys (e.g. { delay =>
10 }) left the omitted keys undef instead of falling back to their
documented defaults - an omitted fail_response then hit send()'s
own safety-net default of 1, silently turning "0 retries" into 1,
and an omitted fail_status produced a spurious "Use of
uninitialized value" warning from split(). _build_retry now
defaults each key the same way the env-var path already did
(HAC-062)
2026-08-18 - v1.10
+ docs: rewrote the USAGE section - the signed-request example (the
module's stated reason to exist per DESCRIPTION) now leads instead
of being buried after three generic examples, leftover casual-era
phrasing from the original docs was removed, and the section was
split into labeled subsections instead of one run-on block mixing
old and new writing styles (HAC-054)
2026-08-18 - v1.09
+ docs: added ATTRIBUTES entries for retry_config and json - real,
working programmatic alternatives to the env-var-only retry and
JSON encoding config, previously undocumented (HAC-052)
+ docs: added a DESCRIPTION section explaining why this module
exists (repetitive signed-API-request boilerplate) and when
LWP::UserAgent/HTTP::Tiny are simpler choices instead, plus a
verified-working signed-request USAGE example showing the event
system computing a signature header while keeping the secret out
of the body. Both README.md files gained a matching purpose
paragraph before their setup instructions (HAC-051)
2026-08-18 - v1.08
+ docs: fixed 5 confirmed POD/README defects found by an explicit
audit - the post() USAGE example said 'same as send(GET,...)'
instead of POST, HTTP_TIMEOUT still had an unfilled '???'
placeholder, browser_id/ua were undocumented in ATTRIBUTES, and
src/README.md directly contradicted itself ("Docker only... no
other supported dev setup" immediately followed by full local Perl
instructions) with a stale coverage-baseline date (HAC-050)
+ fix: browser_id's version fallback was -1, producing the
nonsensical User-Agent "HTTP API Client v-1" - $VERSION is only
set by Dist::Zilla's [PkgVersion] plugin at build time, so this
fallback fired for every non-CPAN-installed usage (a git checkout,
including this project's own dev/test environment). Now falls back
to "dev" instead (HAC-049)
+ docs: RETRY VARIABLES POD had a leftover "???" placeholder never
filled in for RETRY_DELAY's default and a typo ("resposne") on
RETRY_FAIL_RESPONSE - fixed, and documented the HAC-044/045
negative-value clamping (HAC-048)
+ docs: charset attribute POD now names valid values and the
HAC-046 invalid-value error behavior (HAC-047)
+ fix: an invalid charset value (e.g. a typo in HTTP_CHARSET) was
silently swallowed by _build_json's eval, leaving JSON encoding
without byte-encoding forced - a JSON request with any non-ASCII
data then crashed much later with the confusing, unrelated
"HTTP::Message content must be bytes" instead of a clear error
naming the actual bad charset. Now dies immediately and clearly
(HAC-046)
+ fix: a negative RETRY_DELAY reached sleep() as-is, producing
"sleep() with negative argument" on every retry - same class of
missing-validation gap as HAC-044, just in the delay rather than
the count. Now clamped to a minimum of 0 in send() (HAC-045)
+ fix: a negative retry count (e.g. RETRY_FAIL_RESPONSE=-1) made
send()/get()/etc silently return undef - Perl's 0..N range is empty
for a negative N, so the retry loop's body (which builds and sends
the request) never ran at all, with no error or warning. Now
clamped to a minimum of 0 in _build_retry, matching the documented
"default 0 retry" floor (HAC-044)
+ test: t/08_retry.t also switched to the shared t/lib/FakeUA.pm
fixture (HAC-042 follow-up) - it was a strict subset of the shared
fixture's behavior. No behavior change (HAC-043)
+ test: t/11_retry_fail_status.t and t/24_retry_fail_status_whitespace.t
duplicated an identical FakeUA fixture verbatim - extracted into a
shared t/lib/FakeUA.pm. No behavior change (HAC-042)
+ fix: send() defaulted $data/$headers/$events when omitted but not
$path - calling get()/post()/etc with no path argument (a plausible
pattern when base_url is meant to be the whole target URL) produced
a spurious "Use of uninitialized value $path" warning. Now defaults
$path via _defor(), matching the other three optional args (HAC-041)
+ refactor: kvp2json and kvp2str duplicated the exact same skip-key
guard verbatim - the same shape of duplication that caused HAC-020
(the two encoders silently drifting out of sync). Extracted into a
shared _should_skip_key() helper. No behavior change (HAC-040)
+ test: coverage added for json_response()'s documented "no request
made yet" behavior (never exercised before) - confirms it already
matches the POD (HAC-039)
2026-08-17 - v1.07
+ docs: added POD for get_content_type/kvp2json_each/kvp2str_each,
the only three public methods in Client.pm with none (Pod::Coverage
82.3% -> 100%) (HAC-038)
+ fix: before_sorting_keys' keys parameter was always empty on entry,
and any mutation a callback made to it (add/remove a key before
sorting) was silently discarded a moment later when @keys got
unconditionally reassigned from keys %data. Unlike after_sorting_keys,
whose keys mutations are genuinely live, before_sorting_keys was
functionally inert for this purpose (HAC-037)
+ fix: CPANTS Core Kwalitee was 93.75% (6 failing metrics) - added a
LICENSE AND COPYRIGHT POD section to Client.pm, declared
HTTP::Headers/HTTP::Request as runtime prereqs and HTTP::Request/
HTTP::Response as test-phase prereqs in cpanfile (both used but
previously undeclared), declared a minimum perl version, and added
[MetaJSON] to dist.ini so the release includes META.json (HAC-036)
+ fix: Basic Auth (username/password) crashed outright on a wide
Unicode username or password - authorization_basic()'s internal
base64 encoding dies on a UTF8-flagged string, and unlike
auth_token (fixed by HAC-034) username/password never went
through any UTF-8 handling. Reuses _encode_if_utf8_flagged(),
completing the sweep across every credential/header/body path
(HAC-035)
+ fix: header values got no UTF-8 handling at all, unlike body values
(fixed for body content just below by HAC-029/031/032) - a wide
Unicode header value stayed UTF8-flagged all the way through,
producing "Wide character in print" warnings and incorrect bytes
when the request was serialized. New _encode_if_utf8_flagged()
helper, shared with the body-encoding fixes, applied to header
values before they reach HTTP::Request::header() (HAC-034)
+ fix: kvp2str_each's BOOL branch (xTRUE/xFALSE/xTrue/xFalse/xtrue/
xfalse/xt__e/xf___e) interpolated its value directly into the
query string with no percent-escaping at all, unlike every other
branch. A value containing '&' or '=' - reachable since xBOOLEAN's
own POD documents it as accepting any plain scalar, not just
boolean-safe strings - corrupted the query string by introducing
extra params (HAC-033)
+ fix: the JSON path (kvp2json/kvp2json_each) had the same class of
bug as HAC-031, just below - JSON::XS's utf8 mode unconditionally
re-encodes string values, which double-encodes a value that is
already raw UTF-8 bytes, producing mojibake in the JSON body. Now
decodes any non-UTF8-flagged string value before it reaches
JSON::XS (HAC-032)
+ fix: HAC-029's uri_escape_utf8() fix (below, v1.06) double-encoded a
value that was already raw UTF-8 bytes (utf8::is_utf8 false - the
common shape for data read from a file/DB/API without being
explicitly Encode::decode'd), producing mojibake instead of correct
percent-encoding. A genuine Unicode character string still encodes
correctly. New _uri_escape_bytes_or_chars() helper only encodes
when the input is actually UTF8-flagged, mirroring _tune_utf8's own
detect-then-encode approach (HAC-031)
2026-08-17 - v1.06
+ fix: any form-urlencoded request (the GET default, or content_type
set explicitly) containing a genuinely wide Unicode character in a
key or value - any CJK character, Cyrillic, Greek, emoji - crashed
outright instead of encoding. kvp2str_each used URI::Escape's
uri_escape(), which only handles codepoints up to 0xFF; switched to
uri_escape_utf8(). JSON requests were unaffected (HAC-029)
+ test: coverage added for _tune_utf8's UTF-8 encoding path, tested
directly as it's unreachable through the public API - convert_data()
always hands it already byte-encoded content (HAC-028)
+ fix: RETRY_FAIL_STATUS silently dropped any status code after a
comma-space separator (e.g. "500, 404") - the split didn't trim
whitespace, so the leading space left on every status but the first
never matched the response code and that status silently never
retried (HAC-027)
+ test: coverage added for skip_headers/skip_key (new_request/
kvp2json/kvp2str), previously undocumented and, for skip_headers,
untested (HAC-026)
+ test: coverage added for before_headers and before_sorting_keys/
after_sorting_keys events (never exercised before) (HAC-024)
+ fix: add_headers_keys, following its own documented usage (mutate
%headers as a side effect, then return the key), caused that key to
be double-counted in new_request()'s @keys - before_header/
after_header for that key fired twice instead of once (HAC-025)
+ test: coverage added for headers_keys/add_headers_keys/before_header/
after_header events (never exercised before), and for auth_token
including the documented username/password-wins precedence rule
(HAC-021, HAC-023)
+ fix: the not_include event was silently ignored in form-urlencoded
mode (kvp2str) - it only worked for JSON (kvp2json). A key explicitly
excluded via not_include still leaked into a form-urlencoded request
body (HAC-022)
+ fix: kvp2str_each() silently stringified a nested hash value as
'HASH(0x...)' in the query string - now dies with a clear message
naming the key, mirroring the same fix already applied to
convert_data() (HAC-020)
2026-08-17 - v1.05
+ test: coverage added for the DEBUG_* env vars (never exercised before);
clarified DEBUG_RESPONSE_IF_FAIL's POD - it only narrows DEBUG_IN_OUT/
DEBUG_RESPONSE, it does nothing by itself (HAC-017, HAC-018)
+ fix: _execute_callbacks() used each() on the data/headers hash while
callbacks could mutate that same hash - confirmed via Perl's own
"each() after insertion" undefined-behavior warning. Now iterates a
keys() snapshot instead (HAC-016)
+ fix: root Dockerfile never put lib/ on PERL5LIB, so docker run always
failed at 'use HTTP::API::Client' - verified with a real build+run,
all tests now pass in the container (HAC-013)
+ test: coverage added for put()/head()/delete() (never exercised before)
and for json_response()/kvp_response()'s actual decode logic (only
their empty-input guards had coverage) - no behavior changed, all
confirmed already correct (HAC-014, HAC-015)
+ fix: a client configured with an engine other than LWP::UserAgent now
dies with a clear message instead of crashing later with a confusing
"is_success on an undefined value" - real custom-engine dispatch is
still an open design question, not decided here (HAC-010)
+ fix: RETRY_FAIL_STATUS crashed (wrong method name, decode_content vs
decoded_content) any time it was actually used - the body-pattern-match
it was trying to do was never wired up either, retry now happens purely
on status-code match as the POD has always documented (HAC-009)
+ fix: kvp_response() crashed if called before any request was made -
now returns {} like json_response() already did (HAC-007)
+ fix: convert_data() silently stringified a data hashref as 'HASH(0x...)'
for any content_type other than json/form-urlencoded - now returns an
empty body for empty data, dies with a clear message otherwise (HAC-008)
+ fix: a non-GET request (POST/PUT/DELETE) with application/x-www-form-urlencoded
content-type and empty data never built an HTTP::Request object and crashed
in send() - now builds an empty-content request correctly (HAC-004)
+ fix: send() slept RETRY_DELAY seconds on every failed request even with
RETRY_FAIL_RESPONSE=0 (the default, no retries) - now returns immediately
when no retry attempt is left (HAC-006)
+ license changed to MIT
+ Devel::Cover wired up as a develop-phase dependency, coverage documented in README
2021-03-31 - v1.03
+ use lazy builder, so the sub classes can just overwrite the _build_ sub instead of using default => sub {}
2021-03-31 - v1.02
+ convert number in the request
+ added data type markers
+ json true = xTRUE()
+ json false = xFALSE()
+ cgi param true = xTRUE() => "1"
+ cgi param false = xFALSE() => "0"
+ cgi param true = xTrue() => "True"
+ cgi param false = xFalse() => "False"
+ cgi param true = xtrue() => "true"
+ cgi param false = xfalse() => "false"
+ cgi param true = xt__e() => "t"
+ cgi param false = xf___e() => "f"
+ cgi param csv list = %a = (a => xCSV(1,2,3,4)) => "a=1,2,3,4"
otherwise = %b = (b => [1,2,3,4]) => "b=1&b=2&b=3&b=4"
2021-03-31 - v1.01
+ Enchance key value pairs representing on the cgi params
2021-03-31 - v1.0
+ improve readibility
+ improve the logic path
+ adding events to manipulate the logic flow
+ change some private methods to public methods
+ Change OOP Framework to Moo
2021-02-25 - v0.09
+ the data and header can be using callback function to make it more dynamic
2018-09-24 - v0.08 / v0.07
+ Bugfix pre defined headers and parameters
2017-08-10 - v0.06
+ You can pre defined parameters during object construction
+ You can pre defined headers during object construction
2015-01-20 - v0.04
+ Update POD
+ Remove unwanted perltidy message
2015-01-20 - v0.04
+ Fix test
2015-01-19 - 0.03
+ Add ENVIRONMENT VARIABLE usage
2015-01-18 - 0.02
+ Cleanup
2015-01-18 - 0.01
+ First version
2021-04-27 - v1.04
+ New event to not include keys that is defined in the request
+ Simplified the cpan module dep
+ Refresh the tests