HTTP::API::Core
A small, dependency-light foundation for building JSON HTTP API clients in Perl.
The goal is not to replace HTTP::Tiny, LWP, Mojo::UserAgent, Furl, or another
HTTP transport. It provides the API-client layer applications repeatedly rebuild:
base URLs, JSON handling, query parameters, structured errors, retries,
pagination, rate limits, lifecycle hooks, authentication helpers, observability,
idempotency, and a transport adapter contract.
Basic usage
use HTTP::API::Core;
my $api = HTTP::API::Core->new(
base_url => 'https://api.example.com',
headers => {
Authorization => "Bearer $ENV{API_TOKEN}",
},
timeout => 10,
retry => {
attempts => 3,
base_delay => 0.25,
max_delay => 5,
jitter => 1,
},
);
my $response = $api->get('/users');
my $data = $response->json;
Query parameters
Pass a hash reference as query instead of building query strings by hand:
my $response = $api->get('/users',
query => {
state => 'active',
tag => ['admin', 'staff'],
after => undef,
},
);
Values are percent-encoded. Array references generate repeated keys, undefined
values are omitted, existing query strings are preserved, and parameters are
inserted before URL fragments. before_request hooks see the final encoded URL.
Authentication helpers
HTTP::API::Core::Auth provides small authentication helpers implemented as
before_request hooks rather than service-specific client state.
use HTTP::API::Core::Auth qw(bearer_auth basic_auth api_key_auth);
my $api = HTTP::API::Core->new(
base_url => 'https://api.example.com',
hooks => {
before_request => bearer_auth($token),
},
);
Bearer tokens, HTTP Basic authentication, API-key headers, and API-key query parameters are supported. Explicit request headers take precedence over helper values. OAuth token acquisition and refresh flows remain outside the core.
See docs/AUTHENTICATION.md.
Observability
Responses expose transport elapsed time and common request IDs without choosing a logging, metrics, tracing, or telemetry framework:
my $response = $api->get('/users');
say $response->elapsed;
say $response->request_id if defined $response->request_id;
request_id recognizes X-Request-Id, Request-Id, and X-Correlation-Id.
Lifecycle hooks receive the same per-attempt metadata in their context.
started_at is captured immediately before transport begins, elapsed measures
transport time, and request_id is populated before after_response or
on_error runs.
hooks => {
after_response => sub {
my ($response, $ctx) = @_;
$metrics->observe($ctx->{elapsed});
},
}
HTTP and transport errors also expose elapsed time; HTTP errors retain the normalized request ID.
Hooks
Client-level and per-request hooks make it possible to add authentication, logging, metrics, tracing, or other cross-cutting behavior without subclassing.
my $api = HTTP::API::Core->new(
base_url => 'https://api.example.com',
hooks => {
before_request => sub {
my ($ctx) = @_;
$ctx->{headers}{Authorization} = "Bearer $token";
},
after_response => sub {
my ($response, $ctx) = @_;
log_status($response->status);
},
on_error => sub {
my ($error, $ctx) = @_;
record_failure($error->category);
},
},
);
before_request receives a mutable context containing method, url,
headers, content, and the retry attempt. It runs immediately before each
transport attempt. after_response runs after a successful response is
received. on_error runs before retry is considered.
Each hook can be a coderef or an arrayref of coderefs. Request-local hooks are appended after client-level hooks:
$api->get('/users',
hooks => {
before_request => sub {
my ($ctx) = @_;
$ctx->{headers}{'X-Request-Tag'} = 'users';
},
},
);
Hook failures are surfaced as structured, non-retryable hook errors.
Rate limits
Responses expose normalized rate-limit metadata:
my $response = $api->get('/users');
my $rate = $response->rate_limit;
say $rate->limit if defined $rate->limit;
say $rate->remaining if defined $rate->remaining;
say $rate->resource if defined $rate->resource;
say $rate->wait_seconds if $rate->exhausted;
HTTP::API::Core::RateLimit understands numeric RateLimit-Limit,
RateLimit-Remaining, and RateLimit-Reset fields as well as the widely used
X-RateLimit-* family and Retry-After. X-RateLimit-Reset is treated as a UTC
epoch timestamp; RateLimit-Reset is treated as a delay in seconds.
HTTP errors expose the same object through $error->rate_limit.
For exhausted quotas, Retry-After remains the first choice. When it is absent,
retry handling can fall back to reset metadata. A 403 is only treated as a
rate-limit retry when the response explicitly reports remaining == 0; ordinary
authorization failures are not retried.
Pagination
paginate returns an iterator with next and all. All pagination styles use
the same API.
Next URL
my $pager = $api->paginate(
'/users',
mode => 'next_url',
items => 'data.users',
next => 'links.next',
);
while (my $user = $pager->next) {
...
}
The next value may be an absolute URL or a path relative to base_url.
Page number
my $pager = $api->paginate(
'/users',
mode => 'page',
items => 'users',
page_size => 100,
);
my @users = $pager->all;
The defaults are page for the page parameter and per_page for the page-size
parameter. Override them with page_param and page_size_param. If the response
exposes an explicit boolean, use has_more => 'meta.has_more'.
Cursor
my $pager = $api->paginate(
'/users',
mode => 'cursor',
items => 'data.users',
next => 'meta.next_cursor',
query => { limit => 100 },
);
The default cursor parameter is cursor; override it with cursor_param.
Extractor values such as data.users and meta.next_cursor are dotted paths. A
coderef can also be supplied when an API needs custom extraction logic.
Repeated next URLs or cursors are detected and rejected rather than looping forever.
Response API
Responses keep body handling explicit and predictable:
my $response = $api->get('/users');
$response->status;
$response->headers;
$response->header('content-type');
$response->content;
$response->text;
$response->content_type;
$response->is_json;
$response->json;
content_type strips parameters such as charset and normalizes the media type
to lower case. is_json recognizes application/json and structured syntax
suffix media types such as application/problem+json.
Calling json is explicit and does not require a JSON Content-Type header. Empty
or whitespace-only bodies return undef; invalid non-empty JSON raises a
structured decode error. text performs no charset decoding.
The headers method returns a copy so callers cannot accidentally mutate
response state.
See docs/RESPONSE.md.
Error model
Failures use HTTP::API::Core::Error. Machine-readable categories are:
encodedecodetransporthttphook
HTTP errors retain their response and expose body, text, json, headers,
and header helpers. Code should use structured fields such as category,
status, retryable, and request_id instead of parsing human-readable error
messages.
See docs/ERRORS.md.
Retry policy
Retries are intentionally conservative. By default only GET, HEAD, PUT,
DELETE, and OPTIONS are retried. POST and PATCH are not automatically
repeated because doing so can duplicate side effects.
Retryable failures include transport errors, HTTP 408, 425, 429, 5xx,
and exhausted-quota 403 responses. Delays use exponential backoff with jitter.
A numeric Retry-After header takes precedence; exhausted rate-limit reset
metadata is used as a fallback.
$api->get('/status', retry => 0);
$api->post('/jobs',
json => { task => 'sync' },
retry => {
attempts => 2,
methods => ['POST'],
},
);
Idempotency
Idempotency keys can be supplied without assuming a service-specific header name:
my $response = $api->post(
'/payments',
json => { amount => 1000 },
idempotency => {
key => $key,
header => 'Idempotency-Key',
},
);
The core does not generate keys automatically and does not automatically make unsafe methods retryable merely because an idempotency key is present. An explicit request header with the same case-insensitive name takes precedence.
See docs/IDEMPOTENCY.md.
Transport adapters
The transport constructor option is a supported extension point. It accepts
either a code reference or an object with a request method using the same
small contract:
my $api = HTTP::API::Core->new(
base_url => 'https://api.example.com',
transport => My::Transport->new(...),
);
Adapters receive ($method, $url, \%options) and return a hash containing at
least status, with optional reason, headers, and content. Transport
exceptions and malformed results become structured transport errors.
This keeps HTTP-library-specific integrations outside the core. See
docs/TRANSPORT.md.
Features
- base URL handling
- default and per-request headers
- first-class query parameter encoding
- JSON request encoding and response decoding
- configurable timeout
- structured transport, HTTP, encode, decode, and hook errors
- request ID extraction
- response and error elapsed-time observability
- per-attempt observability metadata in lifecycle hook contexts
- automatic retry with exponential backoff and jitter
- normalized rate-limit metadata and reset-aware retry fallback
- next-URL, page-number, and cursor pagination
- client-level and per-request lifecycle hooks
- Bearer, Basic, and API-key authentication helpers
- explicit response body and Content-Type helpers
- idempotency-key support
- coderef and object transport adapters
Project direction
The core aims to stay small, predictable, dependency-light, transport-independent, and safe for production use. Service-specific SDK behavior, complete OAuth flows, OpenAPI generation, GraphQL-specific clients, WebSockets, HTTP server functionality, and async runtime concerns remain outside the core.
See DESIGN.md for the full project direction and criteria for 1.0.
Migration from HTTP::API::Client
HTTP::API::Core continues the pre-release HTTP::API::Client project under a
new CPAN namespace. See docs/MIGRATION_FROM_HTTP_API_CLIENT.md for details.
License
Same terms as Perl itself.