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:

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

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.