HTTP::API::Core

A small, dependency-light foundation for building JSON HTTP API clients in Perl.

HTTP::API::Core sits above your HTTP transport and handles the application-level plumbing that API clients repeatedly need: JSON, query parameters, structured errors, safe retries, pagination, rate limits, authentication hooks, observability, idempotency, and transport adapters.

It does not replace HTTP::Tiny, LWP, Mojo::UserAgent, Furl, or another HTTP stack. Use the transport you prefer and keep the repetitive API-client policy in one place.

Why HTTP::API::Core?

A small API wrapper often starts simple:

my $response = $http->get($url);

Then production requirements arrive:

HTTP::API::Core provides those pieces without becoming a service-specific SDK or a new HTTP stack.

You still write the small resource methods that make your client useful:

sub get_user {
    my ($self, $id) = @_;
    return $self->{api}->get("/users/$id")->json;
}

The core handles the policy around that request.

Quick start

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;

What you get

Real API examples

Small, tested example clients show how the same core maps onto APIs with different conventions:

See docs/REAL_API_EXAMPLES.md.

These are integration recipes, not official SDKs for those services.

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

HTTP::API::Core::Auth provides small authentication helpers implemented as before_request hooks:

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),
    },
);

Supported helpers include:

Explicit request headers take precedence over helper-provided values.

OAuth token acquisition and refresh flows deliberately remain outside the core.

See docs/AUTHENTICATION.md.

Hooks

Client-level and per-request hooks let you 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 current retry attempt. It runs immediately before each transport attempt.

after_response runs after a successful response. on_error runs before retry handling decides whether another attempt should be made.

Each hook may be a coderef or an arrayref of coderefs. Request-local hooks are appended after client-level hooks.

Hook failures become structured, non-retryable hook errors.

Observability

Responses expose transport elapsed time and common request IDs without forcing 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:

Lifecycle hooks receive the same per-attempt metadata through 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.

HTTP and transport errors also expose elapsed time. HTTP errors retain the normalized request ID.

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 is preferred. When it is absent, retry handling can fall back to reset metadata.

A 403 is treated as a rate-limit retry only when the response explicitly reports remaining == 0; ordinary authorization failures are not retried.

Pagination

paginate returns an iterator with next and all. Different pagination styles use the same interface.

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 default parameter names are page and per_page. 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 may also be supplied when an API needs custom extraction logic.

Repeated next URLs or cursors are detected and rejected instead of looping forever.

Response API

Response body handling is 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 lowercase.

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 with machine-readable categories:

HTTP errors retain their response and expose body, text, json, headers, and header helpers.

Application code can inspect structured fields such as:

instead of parsing human-readable error messages.

See docs/ERRORS.md.

Retry policy

Retries are intentionally conservative.

By default, only these methods are retried:

POST and PATCH are not automatically repeated because doing so can duplicate side effects.

Retryable failures include:

Delays use exponential backoff with jitter.

A numeric Retry-After header takes precedence. Exhausted rate-limit reset metadata is used as a fallback.

Retries can be disabled per request:

$api->get('/status', retry => 0);

Unsafe methods may be opted in explicitly:

$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.

Supplying an idempotency key also does not automatically make an unsafe method retryable. Retry behavior remains explicit.

An explicit request header with the same case-insensitive name takes precedence.

See docs/IDEMPOTENCY.md.

Transport adapters

The transport constructor option is the supported extension point for integrating another HTTP implementation.

It accepts either a code reference or an object with a request method:

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 => 200

with optional reason, headers, and content.

Transport exceptions and malformed results become structured transport errors.

This keeps HTTP-library-specific integration outside the core.

See docs/TRANSPORT.md.

Scope and project direction

HTTP::API::Core aims to stay:

The following intentionally remain outside the core:

See DESIGN.md for the full project direction and criteria for 1.0.

License

Same terms as Perl itself.