rl-c-client

Event-Loop Integration

The core client (r_client.h + r_client_io.h) is deliberately I/O agnostic. It never blocks, creates sockets, or starts threads. The embedding application owns the event loop and passes network, time, timer, and DNS events into the client. (The optional public runtime layer is different: it owns UDP sockets and performs synchronous, blocking DNS — see the Public runtime section of docs/api.md.) The client contains no locks: confine each client to one thread or event loop and serialize all calls on it.

Working integrations for common loops and HTTP frameworks live in examples/. This document defines the host contract; examples show framework-specific descriptor, timer, callback, and shutdown ownership.

Responsibilities

The host application must provide:

The r_io_ops_t.log hook is reserved: the current library never invokes it. Do not rely on it for diagnostics.

r_client_create does not validate individual function pointers, and the consequences of missing ones differ: a NULL resolve_srv or resolve_addrs crashes during the first discovery (inside r_client_create when the resolver is synchronous); a NULL now_ms silently reads time zero and breaks all scheduling; a NULL udp_send surfaces later as RCLIENT_ERR_IO. Treat udp_send, now_ms, resolve_srv, and resolve_addrs as mandatory.

All callbacks may be synchronous or asynchronous. Resolver callback buffers are copied by the client during the callback and do not need to live afterward. The name argument passed to resolve_srv and resolve_addrs is borrowed only for the duration of the call — an asynchronous resolver that stores it for a later lookup must copy the string before returning. Asynchronous resolver implementations should set out_req_id to a nonzero request ID before returning so the client can call cancel during teardown. Late resolver callbacks after cancellation are allowed; the client will ignore them.

udp_send is different from a resolver callback: it must return before the host calls any r_client_* API. If a test transport or custom I/O layer obtains a response synchronously, queue that datagram and deliver it through r_client_on_datagram only after udp_send has unwound.

Request Flow

  1. Create the client with r_client_create.
  2. Submit a request with r_client_check_rate_limit_async or r_client_check_rate_limit_async_borrowed.
  3. The client calls udp_send for every resolved server address; a failing udp_send return aborts the attempt with RCLIENT_ERR_IO.
  4. The host reads UDP responses and calls r_client_on_datagram.
  5. The host schedules the deadline returned by r_client_request_deadline_ms.
  6. If the timer fires, the host calls r_client_on_timeout.
  7. The client invokes the request callback exactly once — unless the request is canceled, or the client is destroyed with the request still in flight. Both suppression paths free the request without any callback, and both act as release points for borrowed buffers.

The callback owns no result memory. Copy fields during the callback if they are needed afterward. From inside the callback, submitting, reporting, and canceling are safe; calling r_client_destroy is not — destroy the client only after the stack has unwound to the event loop.

DNS

The client discovers servers with:

_ratelimitly._udp.<configured-dns-name>

For each SRV record, the host resolver must resolve the SRV target hostname to A/AAAA addresses. The SRV target name and port are part of the server identity and routing input, under a strict naming contract:

Each SRV target hostname’s first DNS label must encode that server’s 64-bit ID in decimal as s-<server_id> — for example s-1015809.rl1.example.com. The client parses the label to learn each server’s identity. SRV targets whose first label does not match are silently skipped, and when at least one target carries an ID, responses claiming a server ID outside the SRV-derived set are silently dropped. The ID also encodes the server’s start time (start_seconds_since_2025 = server_id >> 23), which drives the HA policy’s oldest-server preference (see docs/api.md). A zone published without this convention yields no usable servers. The client ignores SRV priority and weight; record order does not matter.

Discovery is SRV-only. A failed or empty SRV lookup, or one containing no conforming targets, does not fall back to the configured tenant name on a hard-coded UDP port. Resource-request submission and latency reporting return RCLIENT_ERR_DNS until usable SRV membership is available.

If TTL values are available, pass them in r_srv_record_t.ttl_ms. The client uses TTLs to cap refresh intervals. If TTLs are unavailable, set ttl_ms to zero and the configured refresh policy is used.

Timers

The client does not schedule timers directly. After submitting a request, call:

uint64_t deadline_ms;
if (r_client_request_deadline_ms(req, &deadline_ms) == RCLIENT_OK) {
    /* schedule host timer for deadline_ms */
}

When the timer fires:

r_client_on_timeout(client, req, now_ms);

Replay rounds may update the next deadline. For a non-empty request, completion is signaled only by the request callback firing during the r_client_on_timeout (or r_client_on_datagram) call — the return value is RCLIENT_OK both when the event was nonterminal and when it completed and freed the request. Track a flag from the callback: if it fired, the request handle is invalid and must not be used again; if it did not, re-query r_client_request_deadline_ms and re-arm the host timer. Firing the timer early is a safe no-op.

An empty request (zero resources and zero guards) is the exception. Submission fires its successful callback synchronously, returns RCLIENT_OK, and leaves *out_req == NULL; it performs no DNS or UDP operation and needs no timer. Install callback state before submitting and test the returned handle before querying a deadline.

Clock domains

Client control time uses Unix-epoch milliseconds throughout:

Convert an absolute deadline into a relative host-loop delay by subtracting a fresh now_ms value and clamping expired deadlines to zero. Do not pass a raw relative timeout to r_client_on_timeout.

Protected-work latency uses a separate duration clock. Measure it with CLOCK_MONOTONIC or the event loop’s equivalent, so wall-clock correction cannot create negative or inflated samples. Convert only the elapsed duration to r_service_latency_report_t.observed_latency; never feed that monotonic clock value into r_io_ops_t.now_ms.

Borrowed Buffers

r_client_check_rate_limit_async_borrowed avoids copying resources, guards, and metrics labels. The caller must keep every borrowed buffer valid until the request callback fires, the request is canceled, or the client is destroyed. For an empty request there are no borrowed buffers and the callback fires synchronously during submission.

This is the preferred path for embedders that already have per-request memory with a lifetime that extends to callback completion.

Latency reports

r_client_report_latency serializes and sends reports during the call. It creates no in-flight request, response callback, or request timer. Event-loop integrations therefore need no new read watcher or deadline path for reports; the existing UDP send hook is sufficient.

Latency reports are independent of resource requests and guards. A client may report measured service latency without issuing any resource request. When an application deliberately pairs a report with guarded work, it should report only a real operation that ran, use the same latency-tracker ID and tracker settings, and never invent a zero sample for work the guard rejected. Log send failures, but do not change an HTTP response outcome after protected work completed.

See the example latency tracking workflow for runnable pass/report and deny/no-report behavior.

Steering Feedback

Servers can ask clients to change UDP source port — typically to redistribute load across server-side receive paths; ignoring the request costs load-balance quality, not correctness. If a completed request contains any response that requests rebinding, the client records one pending steering action. Concurrent requests coalesce into that action. The hook runs only after the completion callback has returned and the complete in-flight request set has drained:

on_steering_feedback(ctx, false)

keep_port == false is the only value the hook is ever called with; a keep-port response simply produces no call. The selected result’s steering_feedback field carries the raw wire flag (true = keep port).

r_client_steering.h provides the shared deterministic port-selection primitive. Begin with the successor of the current port, advance by exactly one through 49152 through 65535, skip occupied candidates, and scan the complete range. There is no port-zero fallback, so a source port cannot recur until the range has been traversed. A successful bind callback retains the replacement socket and receives both the selected and following ports.

Bind the replacement before closing the old socket. On Windows, set SO_EXCLUSIVEADDRUSE before binding the wildcard address so another socket bound to a specific local address cannot divert replies. The hook already means no core request remains in flight, but an event-loop integration must also activate its receive watcher for the replacement before retiring the old watcher.

Proxy Module Notes

A proxy or HTTP-server module can wire the client as follows:

Resource-request failures should be mapped by the module according to its configured fail-open/fail-close policy. Latency-report send failures should be logged but must not change the HTTP response outcome.