client

package
v0.8.7 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 13, 2026 License: MPL-2.0 Imports: 21 Imported by: 0

Documentation

Overview

Package client provides a high-level Go client for hypercache-server clusters. It wraps the REST API with a typed surface, handles authentication (bearer, Basic, OIDC client-credentials), tolerates node failures via multi-endpoint failover, and optionally tracks cluster membership so new nodes become reachable without redeploying consumers.

Quickstart

Construct a client with one or more seed endpoints and an auth option, then dispatch commands:

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()

err = c.Set(ctx, "session:user-42", payload, 5*time.Minute)
value, err := c.Get(ctx, "session:user-42")

Authentication

Four auth modes coexist on the server. The SDK exposes three of them as Option helpers (mTLS users supply a pre-configured *http.Client via WithHTTPClient):

  • WithBearerAuth(token) — static token, e.g. from HYPERCACHE_AUTH_CONFIG's tokens: block.
  • WithBasicAuth(user, password) — HTTP Basic auth against the server's users: block.
  • WithOIDCClientCredentials(cfg) — full OAuth2 client-credentials flow with auto-refresh.
  • WithHTTPClient(c) — supply your own *http.Client (mTLS, custom transport, etc.).

Applying multiple auth options keeps the LAST one; the underlying transport is replaced wholesale on each.

Failover and topology

The client takes a slice of seed endpoints at construction. Each command picks an endpoint at random; on transport error, 5xx, or 503 draining it walks to the next. On 4xx (auth, scope, not-found, bad-request) it returns immediately — those answers are deterministic.

WithTopologyRefresh enables a background loop that pulls /cluster/members on the configured interval and replaces the working endpoint list with the cluster's live view. New nodes become reachable without a client redeploy; the original seeds remain as a fallback if the working view ever empties (e.g. during a partition).

Errors

Every command method returns an error that satisfies errors.Is against the package's sentinel set:

  • client.ErrNotFound (key missing or /v1/me on a misrouted request)
  • client.ErrUnauthorized (auth rejected)
  • client.ErrForbidden (auth resolved but missing scope)
  • client.ErrDraining (every endpoint reported 503)
  • client.ErrBadRequest (malformed request shape)
  • client.ErrInternal (cluster-side 5xx)
  • client.ErrAllEndpointsFailed (failover exhausted)

Use errors.As against *client.StatusError to extract the canonical Code string and Details. Sentinels are the recommended path for control flow; StatusError is for surfacing details to callers or logs.

See also

Package backend hosts the cache nodes the client talks to. The wire protocol is the OpenAPI spec at /v1/openapi.yaml on every node; the client implements it but is not the contract.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrNotFound is returned when a key is missing from the cache,
	// or when a node-scoped resource (e.g. /v1/me on a misrouted
	// request) doesn't exist on the selected endpoint.
	ErrNotFound = ewrap.New("hypercache: key not found")
	// ErrUnauthorized means the credentials presented (bearer,
	// Basic, OIDC token, or cert) were not accepted by the cluster.
	// Caller must rotate credentials or re-authenticate; retrying
	// the same call against another endpoint won't help.
	ErrUnauthorized = ewrap.New("hypercache: unauthorized")
	// ErrForbidden means the credentials resolved to an identity
	// but the identity's scopes don't satisfy the route's scope
	// requirement. Caller needs a credential with the missing scope.
	ErrForbidden = ewrap.New("hypercache: forbidden")
	// ErrDraining means the targeted node is draining (preparing
	// for shutdown / rolling deploy). The client retries on the
	// next endpoint automatically; this sentinel surfaces only
	// when EVERY endpoint reports draining at once — i.e. the
	// entire cluster is mid-rolling-deploy.
	ErrDraining = ewrap.New("hypercache: cluster draining")
	// ErrBadRequest means the server rejected the request shape
	// (malformed key, invalid TTL string, unparseable body). Not
	// retryable — the caller has a bug to fix.
	ErrBadRequest = ewrap.New("hypercache: bad request")
	// ErrInternal wraps the cluster's INTERNAL/500 error class. The
	// client surfaces it as a sentinel so retry-with-backoff
	// helpers can distinguish "server is broken, try later" from
	// auth/scope/not-found.
	ErrInternal = ewrap.New("hypercache: internal server error")
	// ErrAllEndpointsFailed is returned when failover exhausted
	// every known endpoint without a successful response. The
	// underlying causes (network errors, 5xx, draining) are
	// preserved via fmt.Errorf wrapping; use errors.As against
	// *StatusError or net.Error if you need the original.
	ErrAllEndpointsFailed = ewrap.New("hypercache: all endpoints failed")
	// ErrNoEndpoints is returned when New is called with an empty
	// endpoint slice. The constructor catches this; runtime paths
	// fall back to the original seed list, so this only surfaces
	// at construction time.
	ErrNoEndpoints = ewrap.New("hypercache: at least one endpoint required")
)

Sentinel errors returned by the client. Use errors.Is to match them — every command method wraps its underlying *StatusError so `errors.Is(err, client.ErrNotFound)` is the canonical detection shape regardless of HTTP status mapping changes upstream.

The sentinel set is intentionally small and stable. New conditions either map to an existing sentinel (the recommended path) or to the typed StatusError below for cases that need finer discrimination.

Functions

This section is empty.

Types

type BatchDeleteResult

type BatchDeleteResult struct {
	Key     string
	Deleted bool
	Owners  []string
	Err     *StatusError
}

BatchDeleteResult is the per-key result of a BatchDelete call. Deleted flags whether the item was removed; on the cache's idempotent-delete semantics, deleting a missing key is still reported as Deleted=true (it's idempotent in REST terms — the post-state is "key does not exist"). Err is non-nil only when the cluster could not service the delete at all.

type BatchGetResult

type BatchGetResult struct {
	Key   string
	Found bool
	Item  *Item
}

BatchGetResult is the per-key result of a BatchGet call. Found flags whether the key existed; Item carries the full envelope when Found is true and is nil otherwise. The HTTP request itself can succeed even when some keys are missing — partial misses are the normal shape of a bulk-read call.

type BatchPutResult

type BatchPutResult struct {
	Key    string
	Stored bool
	Bytes  int
	Owners []string
	Err    *StatusError
}

BatchPutResult is the per-key result of a BatchSet call. Stored flags whether the item was written; Bytes / Owners are populated on success. Err is non-nil when the per-item write failed (e.g. the cluster was draining for that key's primary) and matches the SDK's standard *StatusError shape so callers can errors.Is / errors.As against the failure mode.

type BatchSetItem

type BatchSetItem struct {
	Key   string
	Value []byte
	TTL   time.Duration
}

BatchSetItem is one entry in a BatchSet call. Each item carries its own TTL so callers can mix expiring and non-expiring writes in a single batch — TTL <= 0 means no expiry, matching Set's single-key semantics.

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client speaks the hypercache-server REST API. Construct via New with at least one seed endpoint; use the command methods to dispatch operations against the cluster. Close cleanly when done to stop the topology-refresh loop.

Client is safe for concurrent use by multiple goroutines.

func New

func New(seeds []string, opts ...Option) (*Client, error)

New constructs a Client. seeds must contain at least one base URL (e.g. "https://cache.example.com:8080"); the client uses them in random order and falls back to them if topology refresh ever wipes its endpoint view.

Without any auth option the client makes anonymous requests — fine for dev, will 401 against any production cluster.

func (*Client) BatchDelete

func (c *Client) BatchDelete(ctx context.Context, keys []string) ([]BatchDeleteResult, error)

BatchDelete removes multiple keys in a single round-trip. Like the single-key Delete, the operation is idempotent — deleting missing keys is reported as Deleted=true. Err is non-nil only when the cluster could not service the delete (draining, internal error).

Empty input is a no-op that returns an empty slice and nil error.

func (*Client) BatchGet

func (c *Client) BatchGet(ctx context.Context, keys []string) ([]BatchGetResult, error)

BatchGet fetches multiple keys in a single round-trip. Each result carries a Found flag — true means the key was present and Item is populated; false means the key was missing and Item is nil. Missing keys are NOT errors at the call level; the HTTP call succeeds and the per-key Found flag does the discrimination.

Empty input is a no-op that returns an empty slice and nil error.

func (*Client) BatchSet

func (c *Client) BatchSet(ctx context.Context, items []BatchSetItem) ([]BatchPutResult, error)

BatchSet stores multiple key/value pairs in a single round-trip. Each item's TTL is honored independently (TTL <= 0 = no expiry).

The returned slice mirrors items in order. Per-item failures (cluster draining, oversize value, etc.) surface via the result's Err field; the method itself returns nil error as long as the HTTP call succeeded. Empty input is a no-op that returns an empty slice and nil error.

func (*Client) Can added in v0.8.4

func (c *Client) Can(ctx context.Context, capability string) (bool, error)

Can probes whether the caller's resolved identity holds the given capability. Cheaper than the speculative-write pattern (try the action, catch 403) and stable across future scope-to-capability refactors — clients key off the capability string, not the internal scope shape.

Returns (true, nil) when the cluster confirms the capability, (false, nil) when it confirms the capability is missing, and (false, err) when the probe itself failed (network, auth, unknown capability — `errors.Is(err, ErrBadRequest)` discriminates the spelling-mistake case).

Pair with the canonical at-startup canary:

can, err := c.Can(ctx, "cache.write")
if err != nil { log.Fatal(err) }
if !can { log.Fatal("this credential cannot write") }

func (*Client) Close

func (c *Client) Close() error

Close stops the topology-refresh loop. Idempotent; subsequent calls are no-ops. Pending requests are NOT cancelled; callers should cancel via their request context if needed.

func (*Client) Delete

func (c *Client) Delete(ctx context.Context, key string) error

Delete removes the key from the cluster. Returns nil on success (including the case where the key didn't exist — DELETE is idempotent in REST terms).

func (*Client) Endpoints

func (c *Client) Endpoints() []string

Endpoints returns a snapshot of the current working endpoint list — the seeds initially, replaced by /cluster/members entries once topology refresh runs.

func (*Client) Get

func (c *Client) Get(ctx context.Context, key string) ([]byte, error)

Get returns the raw bytes stored at key. Use GetItem if you need metadata (version, owners, expiry) alongside the value.

func (*Client) GetItem

func (c *Client) GetItem(ctx context.Context, key string) (*Item, error)

GetItem returns the full Item envelope (value + metadata). Internally this sends Accept: application/json so the server returns the JSON envelope instead of raw bytes.

func (*Client) Identity

func (c *Client) Identity(ctx context.Context) (*Identity, error)

Identity returns the caller's resolved identity — the response from GET /v1/me. Use at startup as a "is my token valid against this cluster?" canary, or to introspect capabilities before attempting scope-protected operations.

func (*Client) RefreshTopology

func (c *Client) RefreshTopology(ctx context.Context) error

RefreshTopology synchronously pulls /cluster/members from one of the currently-known endpoints (random pick, fail over to seeds if the working view is empty) and updates the client's endpoint view in place. Returns the error from the underlying call when every attempt failed; the in-memory view is left unchanged on failure so the next call can fall back to the same endpoints.

Exposed for tests and operator-driven refreshes (e.g. after a known node join). The background loop calls this on its own tick; manual callers usually don't need to.

func (*Client) Set

func (c *Client) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error

Set stores key=value with the given TTL. TTL <= 0 means no expiration. Returns nil on success; ErrUnauthorized / ErrForbidden / ErrBadRequest on auth/scope/shape problems; wrapped ErrAllEndpointsFailed when failover exhausted every endpoint.

type Identity

type Identity struct {
	// ID is the human-readable identifier the operator assigned
	// (e.g. "svc-billing", "ops-readonly", "anonymous"). Stable
	// across credential rotations as long as the operator keeps
	// the same `id:` mapping in the auth config.
	ID string
	// Scopes is the raw scope strings from the server: "read",
	// "write", "admin". Order matches the server's slice.
	Scopes []string
	// Capabilities is the derived stable view: prefixed with
	// "cache." (e.g. "cache.read"). Prefer this for permission
	// checks — capability strings stay stable even if a scope is
	// later split.
	Capabilities []string
}

Identity is the resolved caller — the response from GET /v1/me. Identity values are short-lived; treat them as a snapshot of "who am I right now against this cluster?" rather than a persistent principal.

Capabilities is the stable surface clients should key off. Scopes is preserved on the type for parity with the server's view but may be empty when a future server splits scopes into multiple capabilities — see the wire docs at /v1/me for the migration contract.

func (Identity) HasCapability

func (i Identity) HasCapability(name string) bool

HasCapability reports whether the identity carries the given capability string. The match is exact — capability strings are a closed taxonomy, not a hierarchy.

type Item

type Item struct {
	// Key is the cache key.
	Key string
	// Value is the cached bytes. Always raw bytes; the wire's
	// base64 envelope is decoded for callers.
	Value []byte
	// TTLMs is the time-to-live in milliseconds at the moment
	// the item was written. Zero means no expiry. Note this is
	// the TTL at write time, not the remaining lifetime — use
	// ExpiresAt for the latter.
	TTLMs int64
	// ExpiresAt is the absolute expiry timestamp as an RFC3339
	// string. Empty when the item has no TTL.
	ExpiresAt string
	// Version is the per-key Lamport version. Monotonically
	// increasing per key across all owners; useful for causality
	// reasoning and conflict detection.
	Version uint64
	// Origin is the node ID that originated this version. Stable
	// across the item's lifetime unless a conflict resolution
	// promotes a different write.
	Origin string
	// LastUpdated is the wall-clock timestamp of the last write,
	// formatted as RFC3339.
	LastUpdated string
	// Node is the node ID that served this request — useful for
	// debugging routing decisions and pinpointing flaky nodes.
	Node string
	// Owners is the ring's ownership list for this key. The first
	// entry is the primary; subsequent entries are replicas. Use
	// this to verify your direct-routing decisions (Phase 5.1
	// when the SDK ships M3) match the cluster's actual view.
	Owners []string
}

Item is the full cached entry — what GetItem returns. Mirrors the server's wire ItemEnvelope shape. Value is always the decoded bytes (the wire's base64 is unwound by the client) so callers don't have to do encoding bookkeeping.

type Option

type Option func(*Client) error

Option configures the Client at construction time. Options are applied in order; later options can override earlier ones (the last auth option wins, for example).

func WithBasicAuth

func WithBasicAuth(username, password string) Option

WithBasicAuth wires HTTP Basic auth. Every request includes `Authorization: Basic base64(username:password)`. Requires the server to be configured with a matching `users:` block in HYPERCACHE_AUTH_CONFIG.

The cache rejects Basic over plaintext by default — set `allow_basic_without_tls: true` server-side only for dev stacks. The client does NOT enforce this client-side (the server does); running this option against an http://-prefixed endpoint will silently leak the password if the server lets you.

func WithBearerAuth

func WithBearerAuth(token string) Option

WithBearerAuth wires a static bearer token. Every request includes `Authorization: Bearer <token>`. The token does NOT get refreshed — for short-lived tokens (OIDC), use WithOIDCClientCredentials instead.

Mutually exclusive with WithBasicAuth and WithOIDCClientCredentials — applying multiple auth options keeps the last one (the underlying transport is replaced wholesale).

func WithHTTPClient

func WithHTTPClient(httpClient *http.Client) Option

WithHTTPClient injects a pre-configured *http.Client. Use this to supply a custom Transport (mTLS, custom dialer, connection-pool tuning) that the rest of the client builds on. Auth options applied after this one will wrap the Transport; auth options applied before may be overwritten.

The injected client's Timeout is honored as the per-request deadline ceiling; nil Timeout means no timeout (the caller's context still applies).

func WithLogger

func WithLogger(logger *slog.Logger) Option

WithLogger sets the structured logger the client uses for background events (topology refresh outcomes, failover decisions). Defaults to a discard handler so embedded use stays silent. Passing nil resets to the default.

func WithOIDCClientCredentials

func WithOIDCClientCredentials(cfg clientcredentials.Config) Option

WithOIDCClientCredentials wires the OAuth2 client-credentials flow. The client fetches an access token from the IdP, caches it in memory, refreshes before expiry, and presents it as a bearer on every cache request — all transparent to the caller.

Required cfg fields: ClientID, ClientSecret, TokenURL. Scopes and EndpointParams are optional but typically needed (many IdPs require an `audience` request param via EndpointParams for the resulting JWT's aud claim to match the cache's expectation).

See the distributed-oidc-client example for the discovery flow that resolves TokenURL from the IdP's /.well-known/openid-configuration document.

func WithTopologyRefresh

func WithTopologyRefresh(interval time.Duration) Option

WithTopologyRefresh enables periodic refresh of the client's view of the cluster. On each tick the client GETs /cluster/members from any reachable endpoint and replaces its in-memory endpoint list with the alive-or-suspect members' API addresses.

Pass 0 (or any negative duration) to disable refresh — the client will use only the seeds supplied to New for its lifetime.

Intervals below 1 second are rejected at construction. The floor exists because /cluster/members serializes a full membership snapshot; clients hammering it faster than 1s add more load than they save.

type StatusError

type StatusError struct {
	// HTTPStatus is the response status (e.g. 404, 401, 503).
	HTTPStatus int
	// Code is the canonical machine-readable identifier from the
	// server's error envelope (e.g. "NOT_FOUND", "DRAINING",
	// "UNAUTHORIZED", "INTERNAL", "BAD_REQUEST"). Stable across
	// server versions; clients should key off this rather than
	// HTTPStatus alone.
	Code string
	// Message is the human-readable summary the server emitted.
	// Safe to log; never contains secrets.
	Message string
	// Details is an optional free-form field carrying context
	// (e.g. "key 'foo' has invalid character at offset 3"). May
	// be empty.
	Details string
}

StatusError carries the cache's canonical error envelope (the `{ code, error, details }` JSON shape every 4xx/5xx returns) plus the HTTP status. Use errors.As to extract it for fields the sentinels don't capture (Details, the original Code string).

StatusError implements `Is(target error) bool` so a wrapped StatusError still matches the family sentinels — `errors.Is(err, ErrNotFound)` returns true whether the caller got the raw *StatusError back or a wrapped error.

func (*StatusError) Error

func (e *StatusError) Error() string

Error renders the status, code, and message into a single string. Format is stable enough for log scraping but not a public API contract; structured-logging users should pull the fields off the StatusError directly.

func (*StatusError) Is

func (e *StatusError) Is(target error) bool

Is implements the errors.Is contract: a wrapped *StatusError matches the corresponding family sentinel based on the canonical Code string (preferred) or HTTPStatus (fallback for codes we don't recognize).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL