Skip to content

HTTP client

HttpClient is the native C++ client for unary calls and typed bidirectional exchanges over VGI-RPC's HTTP transport.

#include <vgi_rpc/http_client.h>

auto client = vgi_rpc::HttpClient::builder("https://rpc.example.com")
                  .prefix("/vgi")
                  .custom_ca_file("/etc/my-company/ca.pem")
                  .auth_callback([](const vgi_rpc::HttpAuthRequest&) {
                      return std::map<std::string, std::string>{
                          {"Authorization", "Bearer " + current_access_token()}};
                  })
                  .build();
auto session = client.open_exchange("transform", init_request,
                                    input_schema, output_schema);
auto output = session.exchange(input);
session.close();

The builder is the primary API. It is copyable and owns its implementation, so a configured builder can be copied and specialized without sharing mutable configuration. The former HttpClient(base_url, config) constructor remains as a deprecated source-compatible adapter.

describe() invokes __describe__ and returns a validated ServiceDescription. Malformed schemas, duplicate method names, and unsupported describe/request versions fail closed rather than producing a partial model.

HTTPS and authentication

https:// uses OpenSSL with certificate and hostname verification enabled. With no TLS options, the platform/system trust store is used. custom_ca_file adds an explicit trust anchor, and client_certificate(cert, key) configures mutual TLS. A client certificate without its private key, or any TLS option on an http:// origin, is rejected while building the client.

dangerous_disable_tls_verification_for_testing() disables both chain and hostname verification. It is deliberately test-named and must not be used in production. RPC redirects are never followed, including HTTPS-to-HTTPS redirects; a redirect is surfaced as a structured HTTP status error.

Static headers can be set with header(). An auth_callback is invoked once per logical operation before the client's serialized transport lock is taken, so refreshing credentials may block or re-enter the client without deadlock. Per-call headers override callback headers, which override static headers. CR/LF is rejected everywhere, and transport-owned headers such as Content-Type, Content-Encoding, Host, and X-Request-ID cannot be overridden. Credential-bearing headers over cleartext HTTP still require the explicit allow_insecure_credentials compatibility opt-in.

Per-call controls and retry

Every operation accepts CallOptions. deadline is an absolute steady_clock time; CallOptions::with_timeout() creates one relative to now. A std::stop_token interrupts lock acquisition, retry backoff, and in-flight HTTP I/O. Deadline and cancellation failures are distinguished. The underlying connection is retired when an in-flight operation is stopped, and the same client can establish a fresh connection afterward.

CallOptions::request_id optionally supplies the logical request ID. The value must be 1–256 characters without CR/LF. Otherwise the client generates one. The client overwrites caller Arrow metadata and writes the same value into both Arrow vgi_rpc.request_id and HTTP X-Request-ID; it remains unchanged across connection retries and the safe 415 codec fallback.

RetryPolicy carries a three-attempt schedule with 100 ms initial backoff, a 10 second maximum backoff, multiplier 2, and 20% jitter. RPC POSTs—including producer continuations—still make one attempt by default because a transport failure after dispatch is ambiguous. Set CallOptions::idempotent = true only after the application has made that logical operation safe to replay; its transport and configured status retries then use the policy. Exchange turns are never retried and their session is poisoned on ambiguous failure. HTTP status retry is off until retryable_status_codes is populated. RetryPolicy::disabled() makes exactly one attempt in every case.

Exchange contract

open_exchange sends exactly one caller-provided init batch and requires the worker to return both the opaque call token and continuation cursor. The typed conformance contract uses a zero-row, empty-schema init batch. Each exchange sends exactly one batch and returns exactly one batch. A zero-row batch is data, not end-of-stream. Input and output schemas are compared with field and schema metadata enabled, so dictionary index widths, timestamp timezones, decimal precision and scale, nested fields, and nullability are preserved exactly.

An exchange session is poisoned as soon as an exchange request starts. If transport or response parsing fails, the old cursor is never retried because the worker may already have advanced it. Start a new session after such a failure.

close() and the destructor only release local state and perform no network I/O. cancel() performs one best-effort cancellation request and then closes the local session. All three operations are idempotent.

Producers and resumable streams

open_producer() returns a move-only HttpStreamSession. tick() drains every application batch emitted during init before it sends a continuation request; a metadata-free zero-row batch is application data, not end-of-stream. open_stream_exchange() provides the same generalized lifecycle for an exchange that may terminate without a final batch, while open_exchange() remains the strict one-input/one-output compatibility API.

next_with_token() pairs a producer batch with the opaque cursor/call-state blob that resumes after that batch. resume_stream() starts directly from a persisted token without replaying /init, and seek_to_token() repositions a fresh producer session. Responses that buffer more than one batch cannot expose a truthful per-batch resume point, so next_with_token() rejects them; consume such responses with tick() instead. Producer continuations use the CallOptions passed to tick() / next_with_token() and are retried only when the caller marks the turn idempotent. Exchange turns are never retried after dispatch ambiguity.

Optional stream headers are parsed as their own IPC substream and returned by header(). Externalized headers and data are resolved before cursor metadata is interpreted. Local close() is non-networking; explicit cancel() sends one best-effort cancellation request.

External locations

Secure external-location resolution is enabled by default. The production policy accepts HTTPS only, rejects URL credentials/fragments and non-public DNS answers, validates every address, pins a validated address while retaining the original TLS hostname, and manually revalidates every redirect. Encoded, decoded, upload, and upload-response bytes have independent hard caps; zstd decoding also has a bounded history window. SHA-256 covers the decoded Arrow IPC payload, nested pointers are rejected, and signed URL query strings are removed from diagnostics.

Use external_http_options() to lower limits. The LOOPBACK_HTTP_TEST policy is an explicit local-test escape hatch and accepts only loopback HTTP targets. disable_external_locations() restores fail-closed pointer rejection without fetching.

Oversized requests use the server's VGI-Max-Request-Bytes and upload-URL capabilities. A known-oversized request is externalized proactively; an initial pre-dispatch 413 warms capabilities and is retried once as a pointer. The client requests a method-bound PUT/GET pair, validates both URLs, uploads the complete original IPC body without RPC credentials or redirects, and sends a pointer whose SHA-256 covers that body. The external upload cap, rather than the smaller inline cap, bounds the one required serialization allocation.

Sticky sessions

with_session_token() creates an independent, move-only HttpSessionView over the client's shared transport. Calls through the view send VGI-Session-Accept: true, capture VGI-Session plus every VGI-Echo-* response header, and replay the stripped routing headers on subsequent unary, producer, exchange, capability, and upload-URL control requests. Session routing headers override per-call values so a caller cannot accidentally route a live token to a different worker. Separate views do not share token state.

current_session_token() and current_echo_headers() return snapshots for persistence. Pass both back to with_session_token(token, echo_headers) when resuming because some deployments require the echoed routing header to reach the worker that owns the token. detach() transfers the token without deleting it. close() and destruction are idempotent and attempt a bounded, best-effort DELETE {prefix}/__session__ for a live token. Teardown never calls the potentially blocking authentication callback and gives up promptly if the session or shared transport lock is busy; static authentication headers still apply. Server TTL eviction is the fallback. A response carrying VGI-Session-Close: true clears the token and routing echoes without another teardown request.

Captured tokens and echo headers are validated and bounded before being committed. Credential-bearing echo headers are always forbidden, including on HTTPS. Invalid, transport-reserved, or oversized echo metadata fails as a protocol error without partially replacing the last usable routing state. A malformed response body likewise cannot commit staged session headers. A valid Arrow exception envelope whose remote type is SessionLostError throws the dedicated C++ HttpSessionLostError class.

Limits and transport scope

Request and response caps are mandatory positive byte counts. Requests are measured with an Arrow IPC counting pass before the output buffer is allocated. max_response_bytes remains the compatibility default for two independent limits: max_encoded_response_bytes bounds bytes accumulated from the network, and max_decoded_response_bytes bounds the body after HTTP content decoding. Leave either specific limit at zero to inherit max_response_bytes. Both limits apply to fixed-length and chunked responses.

Request bodies use zstd level 3 by default and carry both the standard Accept-Encoding header and VGI's zstd-first preference header. Set compression_level = std::nullopt to send identity request bodies. The client streams compression into a capped buffer, so an incompressible request cannot expand past max_request_bytes or reserve compressBound bytes speculatively. The client decodes zstd responses with both an output ceiling and a decoder-window ceiling, so a small compressed response cannot trigger unbounded output or history-window allocation. An unsupported request codec response (415) disables request compression and is retried once with the same logical request ID. A present-but-empty VGI-Supported-Encodings advertisement likewise disables compression for later requests; an absent header retains the legacy assumption that the peer supports zstd.

capabilities() performs and caches OPTIONS {prefix}/health. It exposes sticky, externalization, byte-limit, upload-URL, and supported-encoding advertisements. Capability headers on ordinary responses refine the same internal model and the server request limit is enforced on later calls.

request_upload_urls(count) exposes the __upload_url__ control method for counts from 1 through 100 and returns HttpUploadUrl values containing upload, download, and optional microsecond expiry fields. The response schema, row count, non-null URLs, and configured external URL policy are validated before anything is returned. The operation fails closed when external resolution and its URL policy are disabled. Automatic request externalization uses this same parser.

Shared-memory pointer resolution and code generation are not provided. Disabled or policy-rejected external locations remain fail-closed rather than returning an unresolved pointer as application data.

Header names and values containing CR/LF are rejected, and transport-reserved headers cannot be overridden. Application metadata using reserved method, version, request-ID, state, call-state, or cancellation keys is stripped before the client installs its own protocol metadata. A single generated request ID is written into both Arrow vgi_rpc.request_id metadata and X-Request-ID; the same ID is preserved across the safe 415 identity retry.

Errors and ownership

HttpClientError::kind() distinguishes transport, TLS, timeout, cancellation, HTTP status, authentication, protocol, limit, and remote failures. Structured fields include status, method, request ID, a response-body excerpt, Retry-After, and VGI-Auth-Reason. The body excerpt is capped at 4096 bytes and diagnostic headers at 1024 bytes; credentials from requests are never included. HttpAuthenticationError additionally exposes the bounded WWW-Authenticate challenge. http_status() is zero when no response status was available. Arrow exception envelopes are surfaced as RpcRemoteError, which also exposes the remote exception type, error kind, server ID, and request ID.

Returned batches own their Arrow IPC backing buffer and remain valid after a later call and after the client or parser that produced them is destroyed. Log and exception envelopes are protocol control only when their batch has zero rows. Log-looking metadata on a non-empty application batch is preserved as application data. External-location and shared-memory pointer metadata remains fail-closed over this client.