| title | Client SDK |
|---|---|
| description | Go SDK for hypercache-server clusters — multi-endpoint HA, typed errors, and four authentication modes. |
Go client for hypercache-server clusters. Closes the three operational gaps the OIDC example surfaced: every
consumer used to hand-roll HTTP, single-endpoint clients had no high availability, and there was no
username/password auth path for Redis-shop muscle memory. Wire-protocol unchanged — the SDK speaks the same
REST API that every node serves at /v1/openapi.yaml.
import (
"context"
"log"
"os"
"time"
"github.com/hyp3rd/hypercache/pkg/client"
)
func main() {
c, err := client.New(
[]string{"https://cache-0.example.com:8080", "https://cache-1.example.com:8080"},
client.WithBearerAuth(os.Getenv("HYPERCACHE_TOKEN")),
client.WithTopologyRefresh(30 * time.Second),
)
if err != nil {
log.Fatal(err)
}
defer c.Close()
ctx := context.Background()
err = c.Set(ctx, "session:user-42", []byte("payload"), 5*time.Minute)
if err != nil {
log.Fatal(err)
}
value, err := c.Get(ctx, "session:user-42")
if err != nil {
log.Fatal(err)
}
log.Println(string(value))
}That's the canonical shape. Any cluster reachable at one of the seed endpoints will accept this; topology refresh discovers peers the seed list doesn't mention; bearer/Basic/OIDC are swap-in alternatives below.
Four auth modes coexist on the server (pkg/httpauth/policy.go resolves them in the order bearer → Basic →
mTLS → OIDC). The SDK exposes three of them as Option helpers; mTLS users supply a pre-configured
*http.Client via WithHTTPClient.
Applying multiple auth options keeps the last one applied — the underlying http.Client.Transport is
replaced wholesale on each call.
client.WithBearerAuth(os.Getenv("HYPERCACHE_TOKEN"))For tokens served from HYPERCACHE_AUTH_CONFIG's tokens: block. Static — the SDK does not refresh; use OIDC
for short-lived tokens.
client.WithBasicAuth("svc-billing", os.Getenv("CACHE_PASSWORD"))For credentials served from HYPERCACHE_AUTH_CONFIG's users: block (bcrypted server-side; see the
server README for the YAML shape).
The server refuses Basic over plaintext by default — make sure your endpoint URLs are https://, or set
allow_basic_without_tls: true in the auth config for dev stacks. The SDK does not enforce TLS client-side;
the server does.
import "golang.org/x/oauth2/clientcredentials"
client.WithOIDCClientCredentials(clientcredentials.Config{
ClientID: os.Getenv("OIDC_CLIENT_ID"),
ClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
TokenURL: tokenURL, // resolve from .well-known/openid-configuration
Scopes: []string{"openid"},
EndpointParams: url.Values{
"audience": {os.Getenv("OIDC_AUDIENCE")},
},
})Wraps the standard oauth2/clientcredentials flow. Tokens are cached in memory and transparently refreshed
before expiry.
The audience parameter is non-obvious. Most IdPs (Auth0, Okta, Keycloak with the audience mapper)
require it at token-exchange time for the resulting JWT's aud claim to populate to a value the cache's
verifier will accept. Set it via EndpointParams, not Scopes. See the
OIDC example for the full discovery flow that produces tokenURL.
tlsConfig := &tls.Config{...} // your CAs, cert, key
client.WithHTTPClient(&http.Client{
Transport: &http.Transport{
TLSClientConfig: tlsConfig,
MaxIdleConnsPerHost: 10,
},
Timeout: 10 * time.Second,
})The escape hatch for everything the dedicated auth options don't cover. Apply this before any other auth option if you want both mTLS and bearer/Basic/OIDC layered on top — the auth options wrap the existing Transport.
Pass a slice of seed URLs to New. The SDK picks one at random for each request; on retryable failure
(network error, 5xx, 503 draining) it walks to the next. On 4xx (auth, scope, not-found, bad-request) it
returns immediately — those answers are deterministic across the cluster and retrying would only slow the
caller.
c, _ := client.New(
[]string{
"https://cache-0.example.com:8080",
"https://cache-1.example.com:8080",
"https://cache-2.example.com:8080",
},
client.WithBearerAuth(token),
)When every endpoint fails, the returned error wraps client.ErrAllEndpointsFailed and the final
*StatusError is reachable via errors.As for inspection.
| Outcome from endpoint | Action |
|---|---|
| Network error / timeout | Fail over |
| HTTP 5xx | Fail over |
| HTTP 503 (draining) | Fail over |
| HTTP 401 / 403 / 404 / 4xx | Return to caller |
| HTTP 2xx | Return success |
This is conservative by design — if a 401 propagated through failover, a misconfigured token would burn every endpoint's auth budget before surfacing.
Without refresh, the seed list is the entire view of the cluster for the Client's lifetime. New nodes added
after deploy stay invisible. WithTopologyRefresh(interval) enables a background loop that pulls
/cluster/members from any reachable endpoint and replaces the in-memory view with the alive-or-suspect
members' API addresses.
client.WithTopologyRefresh(30 * time.Second)The seed list is never lost — if a refresh produces an empty view (every known endpoint unreachable during a partition), the client falls back to the original seeds. This is the recovery anchor; without it, a partition that briefly nulled the working view would strand the client permanently.
Floor: 1 second. Refresh intervals below 1s are rejected at construction. /cluster/members serializes a
full membership snapshot; hammering it faster than 1s adds more load than the refresh saves.
For manual refresh in tests or operator-driven scenarios (post-deploy "learn the new node now" sequences),
call c.RefreshTopology(ctx) synchronously.
Every command method returns an error that satisfies errors.Is against the package's sentinel set. The
underlying *StatusError carries the cache's canonical { code, error, details } envelope for callers that
need finer discrimination via errors.As.
| Sentinel | When it matches |
|---|---|
client.ErrNotFound |
Key missing (404 / NOT_FOUND) |
client.ErrUnauthorized |
Credentials rejected (401 / UNAUTHORIZED) |
client.ErrForbidden |
Credentials valid but missing scope (403) |
client.ErrDraining |
Every endpoint reported 503 / DRAINING |
client.ErrBadRequest |
Malformed request shape (400 / BAD_REQUEST) |
client.ErrInternal |
Cluster-side 5xx (500 / INTERNAL) |
client.ErrAllEndpointsFailed |
Failover exhausted every endpoint |
client.ErrNoEndpoints |
New called with empty seed slice (construction-only) |
Most common path — sentinel match:
value, err := c.Get(ctx, key)
if errors.Is(err, client.ErrNotFound) {
// miss path
return cacheMiss(key)
}
if err != nil {
return err
}When you need .Code or .Details:
err := c.Set(ctx, key, value, ttl)
var se *client.StatusError
if errors.As(err, &se) {
log.Warnf("cache rejected write: code=%s details=%s", se.Code, se.Details)
}When failover exhausts every endpoint:
err := c.Get(ctx, key)
if errors.Is(err, client.ErrAllEndpointsFailed) {
var se *client.StatusError
if errors.As(err, &se) {
// se.Code is from the LAST endpoint we tried.
log.Errorf("cluster appears down; last status: %s", se.Code)
}
}| Method | Returns | Notable errors |
|---|---|---|
Set(ctx, key, value, ttl) |
error |
ErrForbidden, ErrBadRequest |
Get(ctx, key) |
[]byte, error |
ErrNotFound |
GetItem(ctx, key) |
*Item, error |
ErrNotFound; Item carries metadata |
Delete(ctx, key) |
error |
Idempotent — missing key is not an error |
BatchSet(ctx, items) |
[]BatchPutResult, error |
Per-item Err; outer err only on transport/4xx |
BatchGet(ctx, keys) |
[]BatchGetResult, error |
Per-key Found flag; misses are not errors |
BatchDelete(ctx, keys) |
[]BatchDeleteResult, error |
Per-item Err; idempotent |
Identity(ctx) |
*Identity, error |
ErrUnauthorized if the token is invalid |
Can(ctx, capability) |
bool, error |
ErrBadRequest on unknown capability strings |
Endpoints() |
[]string |
Current view (post-refresh) |
RefreshTopology(ctx) |
error |
Manual refresh — usually called by the loop |
Close() |
error |
Stops the refresh loop; idempotent |
*Item carries the full envelope — Value (raw bytes; base64 unwound for you), Version, Owners, Node,
ExpiresAt. Use Get when you only need bytes; GetItem when you need metadata.
*Identity carries ID, Scopes, and Capabilities. The canonical canary at startup:
id, err := c.Identity(ctx)
if err != nil {
log.Fatalf("auth doesn't work: %v", err)
}
if !id.HasCapability("cache.write") {
log.Fatal("this credential cannot write")
}Prefer HasCapability("cache.write") over slices.Contains(id.Scopes, "write") — capability strings stay
stable if a scope is later split across multiple capabilities, while raw scope checks break on the rename.
When a caller just needs "does this credential have write?" — and not the full scopes/capabilities slice —
Client.Can is the focused probe:
canWrite, err := c.Can(ctx, "cache.write")
if err != nil {
return err
}
if !canWrite {
return fmt.Errorf("this credential cannot write to the cluster")
}The method maps to GET /v1/me/can?capability=<name>. Denial (allowed=false) returns (false, nil) —
a successful probe, not an error. Spelling mistakes (an unknown capability string) come back as
errors.Is(err, ErrBadRequest) so the typo surfaces rather than silently degrading to "I guess I can't".
Use this for at-startup gating; use Identity when you need the full picture.
With WithOIDCClientCredentials, the underlying oauth2/clientcredentials source rotates tokens silently
before expiry. Without instrumentation, "why are my requests suddenly 401?" is a hard debug — by the time
the operator looks, the token's already been refreshed.
The SDK wraps the source so every rotation surfaces as an Info log via WithLogger:
{"time":"...","level":"INFO","msg":"oidc token rotated","expires_at":"2026-05-12T15:42:01Z","token_type":"Bearer"}
One line per rotation — the wrapper compares the new token's Expiry against the previous one and only
emits when they differ. Cached returns (the typical happy path between rotations) stay silent.
Apply WithLogger so the rotations are visible:
c, _ := client.New(
endpoints,
client.WithLogger(slog.Default()),
client.WithOIDCClientCredentials(cfg),
)WithLogger order doesn't matter — the wrapper reads the client's logger at rotation time, not at
construction. Late-bound WithLogger calls still reach the OIDC log surface.
The single-key methods (Set/Get/Delete) are one HTTP round-trip per call. For hot loops or fan-in
ingest paths, the Batch* methods cut the round-trip count to one per N keys. The wire endpoints are
POST /v1/cache/batch/{put,get,delete} — see api.md for the raw shapes.
Batch results carry per-item outcomes. A single batch call can succeed at the HTTP level while individual items fail (cluster draining for some shards, oversized value, etc.). The outer error fires only when the request itself failed — transport, auth, 4xx, all endpoints exhausted.
results, err := c.BatchSet(ctx, []client.BatchSetItem{
{Key: "k1", Value: []byte("v1"), TTL: 5 * time.Minute},
{Key: "k2", Value: []byte("v2"), TTL: 5 * time.Minute},
{Key: "k3", Value: []byte("v3"), TTL: 5 * time.Minute},
})
if err != nil {
return err // HTTP-level: auth, network, all endpoints failed
}
for _, r := range results {
if !r.Stored {
log.Warnf("batch item %s failed: %v", r.Key, r.Err) // *StatusError
}
}r.Err is a *StatusError so the same errors.Is(r.Err, client.ErrDraining) shortcut works inside
per-item handling that you'd write for a single-key call.
Missing keys are not errors. Every requested key gets a result; Found flags whether the key was
present. Item is populated only when Found is true.
results, _ := c.BatchGet(ctx, []string{"a", "b", "c"})
for _, r := range results {
if r.Found {
log.Printf("%s = %q (v%d)", r.Key, r.Item.Value, r.Item.Version)
} else {
log.Printf("%s missing", r.Key)
}
}Calling any batch method with an empty slice returns an empty result slice and nil error without dispatching an HTTP request. Saves a round-trip on degenerate callers that conditionally build batches.
The returned results match input order. BatchSet's BatchSetItem slice, BatchGet/BatchDelete's
keys slice — index i of the result is the outcome for index i of the input.
The SDK is intentionally a thin layer over net/http. It does NOT provide retry-with-backoff, connection
pooling beyond what http.Transport already does, or distributed-tracing instrumentation. Those concerns live
in the caller:
- Pool HTTP connections by passing a tuned
*http.TransportviaWithHTTPClient. Defaults are fine for low-throughput workloads; high-throughput callers will wantMaxIdleConnsPerHostandIdleConnTimeoutset explicitly. - Retry policy. The SDK fails over across endpoints for one request; it does NOT retry the request itself
after exhausting them. Wrap the call in a bounded exponential-backoff helper if you want retry semantics
across
ErrAllEndpointsFailed. - Observability. Propagate trace context by setting your tracing middleware on the request context —
context.WithValue(ctx, ...)flows into thehttp.Request.Context()and the cache server's OTel tracer picks up thetraceparentheader if your transport adds one. The SDK itself does not add OTel instrumentation. - Token-refresh visibility.
WithOIDCClientCredentialsrefreshes silently — there's no log when a token rotates. If you're debugging "why are my requests suddenly 401?", setWithLogger(logger)and watch the Debug-level lines for refresh activity.
__examples/distributed-oidc-client/— the SDK in action.__examples/distributed-oidc-client-raw/— the hand-rolled HTTP version. Useful when you need to understand what wire bytes the SDK is sending.- API reference — the OpenAPI spec the SDK implements.
- On-call cheatsheet — auth failures — debugging 401/403s.
- RFC 0003 — the design decisions behind the SDK shape.
- Package source:
pkg/client/.