Documentation
¶
Overview ¶
Package client is the Go SDK for AtlasCache.
It is a separate Go module from the server (ADR-0015). Importing it pulls in nothing but the standard library: the server's configuration, logging and test dependencies are not part of what a consumer inherits, and the empty require block in its go.mod is the guarantee.
Getting started ¶
Build a client with New, use it, and close it when the process is done. The runnable examples on this page are the shortest path: one under New for connecting, one under Get for a round trip, one under each of the options that need explaining. The examples/ directory in the repository holds complete programs, run against a live server in CI, and docs/sdk-go.md is the task-oriented guide — how to connect over TLS, what happens when the server restarts, how to size the pool. This page is the reference.
New opens no connections. It validates its options and returns; the first call dials. Client.Ping is how a caller asks whether the server is reachable before it depends on the answer.
One client per server address per process is the intended arrangement. It owns a connection pool and is safe for concurrent use, so a second client to the same server multiplies connections without multiplying throughput.
Contexts ¶
Every method takes a context first, and every blocking step honors it: the dial, the wait for a pooled connection, the write, and the read. A call canceled mid-flight returns promptly and costs the connection it was using, which is discarded rather than handed to the next caller.
Give every call a deadline. A cache call that has not answered in a few milliseconds has already failed at its job, and a context with no deadline turns a busy pool into an unbounded wait.
Values are bytes ¶
Values are []byte, and keys are strings that may hold arbitrary bytes. RESP is binary safe and so is this SDK; Client.GetString and Client.SetString are conveniences over the byte primitives, not replacements for them.
A found value and an empty value are different answers, which is why Client.Get returns three results rather than two. A cache miss is not an error — it is the most ordinary outcome there is — and an empty string is a value the server stores and returns like any other (ADR-0022).
The slice Client.Get returns is read-only: do not modify it, and copy it before keeping it past the call. The method documents why.
Errors ¶
Every failure wraps one of six category sentinels — ErrNetwork, ErrTimeout, ErrProtocol, ErrServer, ErrAuth, ErrClosed — so a caller decides what to do with errors.Is rather than by matching strings. Retryable is the network/timeout half of that table in one call, and Error carries the detail — which command, which server, which error kind the server used — for errors.As. A call canceled by its caller belongs to no category and wraps context.Canceled instead.
The example under Retryable is that table as code, and the one under Get shows a caller treating a degraded cache differently from a broken one.
Retries ¶
Retries are opt-in per call site, because only the caller knows whether repeating a command is safe:
value, found, err := c.WithRetries(2).Get(ctx, "greeting")
Get, GetString, Set, SetString, Exists, Expire, TTL, Keys, Ping, Echo, Info, DBSize and Stats may be retried. SetNX, Del, Scan and Client.Do never are — a retried DEL answers 0 for a key it deleted on the attempt whose reply was lost, a retried SETNX reports losing a race it won, a scan cursor belongs to the connection that issued it, and Do carries a command the SDK cannot classify at all. What makes a command unsafe to retry is its reply, not its effect.
Connections ¶
The client holds a fixed-size pool, ten connections by default. A call that arrives when all of them are busy waits for one rather than opening an eleventh, and an expired context surfaces as ErrTimeout with Op "pool". Blocking there is the design and not a hang; WithPoolSize explains the trade and how to size it.
A connection dropped by a server restart is replaced inside the pool, not by a background loop. A call that arrives while the server is coming back retries the dial with full-jitter exponential backoff for as long as WithReconnectWindow and the caller's context both allow, so a restart is a pause inside one call rather than a run of failures across many.
Protocol ¶
The SDK sends HELLO 3 on every new connection and falls back silently when the server answers -NOPROTO, which is what a v0.1.0 server does (ADR-0028). RESP3 reply types are decoded already, so the SDK needs no change when the server starts accepting the negotiation.
Client.Do reaches any command the typed methods do not wrap, so a server release is never what stands between a caller and a new command (ADR-0022).
Index ¶
- Constants
- Variables
- func Retryable(err error) bool
- type Client
- type Error
- type Option
- func WithAddr(addr string) Option
- func WithAuth(token string) Option
- func WithBackoff(base, maximum time.Duration) Option
- func WithDialTimeout(d time.Duration) Option
- func WithMaxIdleTime(d time.Duration) Option
- func WithPoolSize(size int) Option
- func WithReadTimeout(d time.Duration) Option
- func WithReconnectWindow(d time.Duration) Option
- func WithTLS(config *tls.Config) Option
- func WithUserAuth(username, token string) Option
- func WithWriteTimeout(d time.Duration) Option
- type Reply
- func (r Reply) Bool() (bool, error)
- func (r Reply) ByteSlices() ([][]byte, error)
- func (r Reply) Bytes() ([]byte, error)
- func (r Reply) Float64() (float64, error)
- func (r Reply) Int64() (int64, error)
- func (r Reply) IsNil() bool
- func (r Reply) Map() (map[string]Reply, error)
- func (r Reply) Slice() ([]Reply, error)
- func (r Reply) Strings() ([]string, error)
- func (r Reply) Text() (string, error)
- type ReplyType
- type ScanResult
- type Stats
Examples ¶
Constants ¶
const ( // TTLNoExpiry is returned for a key that exists and will not expire. TTLNoExpiry = time.Duration(-1) // TTLNoKey is returned when there is no such key. It is not an error: a key // that is not there is the ordinary answer for a cache. TTLNoKey = time.Duration(-2) )
TTL sentinels, matching what the server answers and what every Redis client reports for the same two cases.
const ScanStart = "0"
ScanStart is the cursor a scan begins at, and the cursor returned once one has finished. A scan is over when, and only when, the cursor comes back equal to this — an empty page says nothing, because COUNT bounds the work a page does rather than the keys it finds.
Variables ¶
var ( // ErrNetwork is a dial, read or write failure. Retryable. ErrNetwork = errors.New("network failure") // ErrTimeout is a deadline that expired — the caller's context, or one of // the configured dial, read and write timeouts. Retryable. ErrTimeout = errors.New("deadline exceeded") // ErrProtocol is a reply the SDK could not decode, or one whose shape did // not match the conversion asked of it. Not retryable: a connection that // has seen one is at an unknown stream position and is discarded. ErrProtocol = errors.New("protocol error") // ErrServer is an error reply from the server — `-ERR`, `-OOM`, and every // other kind but the authentication ones. Not retryable: the command // reached the server and the server refused it. ErrServer = errors.New("server error") // ErrAuth is `-NOAUTH` or `-WRONGPASS`, wherever it surfaces: the handshake // on a new connection, or a command on a connection that never // authenticated. Not retryable. ErrAuth = errors.New("authentication failed") // ErrClosed is a call on a client whose Close has already run. Not // retryable, and never transient: it is a bug in the caller's lifecycle. ErrClosed = errors.New("client is closed") )
The six error categories every failure belongs to (P3 §4.2).
They are sentinels rather than types so that `errors.Is` answers the question a caller actually has — "may I retry this?" — without matching on strings or on a type that would have to be exported and then kept stable forever.
Every error the SDK returns wraps exactly one of them, except cancellation: a call canceled by its own caller belongs to no category and is reported by wrapping context.Canceled, because the caller already knows what happened.
Functions ¶
func Retryable ¶
Retryable reports whether an error is one a retry could plausibly fix.
It is the table in P3 §4.2 in one function:
[ErrNetwork] retryable the dial, the write or the read failed [ErrTimeout] retryable a deadline expired, the caller's or a configured one [ErrProtocol] no the reply could not be decoded; the connection is discarded [ErrServer] no the command reached the server and was refused [ErrAuth] no the token is wrong, or the server wants one [ErrClosed] no Close has already run; a caller lifecycle bug cancellation no the caller canceled it, so the caller decides
It exists so that a caller never has to rebuild that table by matching on error strings, which is the failure mode a single opaque error type produces.
It says nothing about whether the *command* is safe to retry — that is the caller's judgment, and the reason retries are opt-in per call rather than automatic (FEAT-0028). Client.WithRetries applies both tests: this one, and whether the command's reply survives being asked for twice.
Example ¶
Retryable is the category table in one call: a network failure and a timeout may be tried again, and nothing else may. It says nothing about whether the command is safe to repeat — that is the caller's call, and the reason WithRetries is opt-in.
package main
import (
"fmt"
"github.com/b3vet/atlascache/pkg/client"
)
func main() {
categories := []error{
client.ErrNetwork,
client.ErrTimeout,
client.ErrProtocol,
client.ErrServer,
client.ErrAuth,
client.ErrClosed,
}
for _, category := range categories {
err := &client.Error{Category: category, Op: "GET", Addr: "localhost:6379"}
fmt.Printf("%-22s %v\n", category, client.Retryable(err))
}
}
Output: network failure true deadline exceeded true protocol error false server error false authentication failed false client is closed false
Types ¶
type Client ¶
type Client interface {
// Get returns the value held at key. found distinguishes a missing key from
// a key holding an empty value: both are legal, the server answers them
// differently, and a client that conflates them is wrong in a way its
// caller cannot detect.
//
// The returned slice is read-only: do not modify it, and copy it before
// keeping it past the call that produced it. Nothing enforces that, and the
// current implementation allocates a fresh slice per reply, so breaking it
// costs nothing today. It is stated as a contract because it is the one the
// storage engine on the other end of the wire already keeps — handing a
// reader an internal view rather than a copy is what makes GET
// allocation-free there (ADR-0013) — and because a caller who honors it is
// a caller this SDK can later stop copying for.
//
// Retried when the client was built with WithRetries.
Get(ctx context.Context, key string) (value []byte, found bool, err error)
// GetString is Get for callers whose values are text. The string is a copy,
// so the read-only contract on Get's slice does not apply to it.
GetString(ctx context.Context, key string) (value string, found bool, err error)
// Set stores a value, with an expiry when ttl is positive and none when it
// is zero. A negative ttl is an error rather than an immediate expiry.
//
// A ttl that is a whole number of seconds goes out as SET ... EX, and
// anything finer as SET ... PX, so a 500ms entry is a 500ms entry rather
// than a rounded one.
//
// The value is copied onto the wire before Set returns, so the caller may
// reuse its buffer immediately.
//
// Retried when the client was built with WithRetries: repeating SET with a
// fixed value leaves the same key holding the same bytes. Only the expiry
// drifts, by however long the retry took.
Set(ctx context.Context, key string, value []byte, ttl time.Duration) error
// SetString is Set for callers whose values are text.
SetString(ctx context.Context, key, value string, ttl time.Duration) error
// SetNX stores a value only if the key is free, reporting whether it did.
//
// Never retried. A retry that answers 0 cannot be told apart from a key
// that was already there, so a caller who won the race would be told it
// lost — which is the one answer SETNX exists to give.
SetNX(ctx context.Context, key string, value []byte) (stored bool, err error)
// Del removes keys and reports how many it removed. Removing a key that is
// not there is not an error; it is a zero in the count.
//
// Never retried. Deleting twice is harmless, but the second reply is 0, so
// a retried DEL reports that it removed nothing when it removed the key on
// the attempt whose reply was lost.
Del(ctx context.Context, keys ...string) (removed int64, err error)
// Exists counts how many of the given keys are present, counting a repeated
// key once per mention — which is what the server does, and what callers
// written against Redis expect.
Exists(ctx context.Context, keys ...string) (count int64, err error)
// Expire attaches an expiry to an existing key, reporting whether it found
// one. A ttl that is zero or negative deletes the key, which is what the
// server does with EXPIRE and what a caller written against Redis expects;
// a positive ttl under a second is rounded up to one second rather than
// down to zero, so it expires shortly rather than immediately.
Expire(ctx context.Context, key string, ttl time.Duration) (applied bool, err error)
// TTL reports the remaining life of a key, rounded to whole seconds by the
// server, or [TTLNoExpiry] for a key that will not expire, or [TTLNoKey]
// for a key that is not there. Neither sentinel is an error.
TTL(ctx context.Context, key string) (time.Duration, error)
// Keys returns every key matching a glob. It walks the whole keyspace and
// holds a connection for the length of the walk, blocking the server for
// the same period; Scan is what production code should use.
Keys(ctx context.Context, pattern string) ([]string, error)
// Scan returns one page of the keyspace and the cursor for the next.
// An empty match means every key; a count of zero leaves the page size to
// the server. Start at ScanStart and stop when [ScanResult.Done] is true:
// an empty page means nothing on its own, because count bounds the work a
// page does rather than the keys it finds.
//
// Never retried, and the reason is also a constraint on the caller: a scan
// cursor is scoped to the connection that issued it (ADR-0017), so a page
// fetched over a different connection is answered with an invalid-cursor
// error. The SDK does not yet pin a connection for the length of a walk,
// so a multi-page scan over a pool larger than one connection is correct
// only by luck. ISSUE-0023 tracks the iterator that will carry the
// constraint for the caller; until it lands, walk the keyspace on a client
// built with WithPoolSize(1), or use Keys on a keyspace small enough to
// block on.
Scan(ctx context.Context, cursor, match string, count int) (ScanResult, error)
// Ping checks that the server is answering.
Ping(ctx context.Context) error
// Echo returns its argument, unchanged and byte for byte.
Echo(ctx context.Context, message []byte) ([]byte, error)
// Info returns the server's INFO text, optionally narrowed to sections.
Info(ctx context.Context, sections ...string) (string, error)
// DBSize returns the number of keys in the keyspace.
DBSize(ctx context.Context) (int64, error)
// Stats returns the server's counters.
Stats(ctx context.Context) (Stats, error)
// Do sends an arbitrary command. It is the escape hatch that keeps SDK
// releases decoupled from server releases; typed methods are preferred
// wherever one exists.
//
// Arguments are rendered as wire bytes: []byte and string are passed
// through byte for byte, the integer and float types are formatted, bool
// becomes 1 or 0, and time.Duration becomes whole seconds. Anything else is
// refused rather than formatted with %v, because a struct that reached the
// wire as its Go formatting would be stored and only noticed much later.
//
// Do is never retried, whatever WithRetries was asked for. The SDK does not
// know what command it is carrying — INCRBY and SET look alike from here —
// and a silently repeated write is worse than a surfaced error (FEAT-0028).
// A caller who knows their command is safe to repeat can retry it in their
// own loop, which is the difference between a decision made and one guessed
// at.
Do(ctx context.Context, args ...any) (Reply, error)
// WithRetries returns a view of this client that retries idempotent calls
// up to n additional times on a network or timeout failure — the two
// categories [Retryable] reports true for. A protocol error, a server
// error, an authentication failure and a canceled context are surfaced on
// the first attempt. A negative n is read as zero.
//
// Attempts are spaced by the same full-jitter backoff a reconnect uses, so
// the delay before retry n is drawn from [0, min(base·2ⁿ, max)); see
// [WithBackoff]. When every attempt fails the caller hears the last
// failure, with its category intact, rather than a synthetic "gave up".
//
// The returned client shares this one's pool, so closing either closes
// both, and a single client can be used both ways at once. Retry is opt-in
// per call site rather than global because only the caller knows whether
// repeating a command is safe — and the SDK will not guess on their behalf.
WithRetries(n int) Client
// Close releases every pooled connection and makes every later call return
// [ErrClosed]. It is idempotent, and it always reports nil. Connections
// checked out when it runs are closed as they are returned.
Close() error
}
Client is the SDK's contract.
It is an interface from v0.1.0 with one implementation behind it, which is speculative generality with a date on it: P7 adds a cluster-aware client that satisfies the same interface, so callers switch by changing construction and not call sites (ADR-0022).
Every method takes a context first, including the ones where cancellation looks pointless today. Adding it later would break every signature in every language binding modeled on this one.
Values are []byte throughout. RESP is binary safe, a cached value is frequently a serialized blob, and a []byte primitive with a string convenience on top is the only arrangement that does not quietly corrupt one.
Retries ¶
A client built by New never retries. Client.WithRetries returns a view that retries the commands whose reply is idempotent — Get, GetString, Set, SetString, Exists, Expire, TTL, Keys, Ping, Echo, Info, DBSize and Stats — on a network or timeout failure and on nothing else.
SetNX, Del, Scan and Client.Do are never retried whatever WithRetries was asked for, and each method below says why. Retryability is a property of the reply rather than of the effect: deleting twice is harmless, but the second DEL answers 0 for a key the first one removed.
func New ¶
New builds a client. It validates its options and opens nothing: the first call dials, and Ping is how a caller asks whether the server is reachable.
Deferring the dial means New cannot fail because a server is briefly down, which is what a process starting up alongside its cache needs. It also means the only error New can report is a bad option — a nil option is skipped, and with no options at all the defaults apply: 127.0.0.1:6379, ten connections, a five second dial timeout and three second read and write timeouts.
An option that does not validate comes back as an *Error with Op "New" and no Category, so errors.Is against the category sentinels does not match it. That is a gap rather than a design: ISSUE-0023 tracks the ErrConfig sentinel that will classify it.
The returned client is safe for concurrent use, and one per server address per process is the intended arrangement — it owns a connection pool, so a client per call site multiplies connections without multiplying throughput. Close it when the process is done with it.
Example ¶
package main
import (
"context"
"fmt"
"time"
"github.com/b3vet/atlascache/pkg/client"
)
func main() {
c, err := client.New(
client.WithAddr("localhost:6379"),
client.WithPoolSize(10),
client.WithDialTimeout(5*time.Second),
)
if err != nil {
// The only failure New can report is a bad option.
fmt.Println("misconfigured:", err)
return
}
defer func() { _ = c.Close() }()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
// New opens nothing. Ping is how you ask whether the server is there.
if err := c.Ping(ctx); err != nil {
fmt.Println("unreachable:", err)
return
}
fmt.Println("connected")
}
Output:
type Error ¶
type Error struct {
// Category is one of the six sentinels above, or nil for a canceled call.
Category error
// Op is what was being attempted: a command name ("GET"), or a stage of
// the connection's life ("dial", "handshake", "pool").
Op string
// Addr is the server the call was made against, when it is known.
Addr string
// Kind is the error kind a server reply carried — "ERR", "WRONGPASS",
// "OOM", "NOPROTO". Empty for everything that did not come off the wire.
Kind string
// Message is the human-readable detail. For a server reply it is the
// server's own text, kind included, verbatim.
Message string
// Err is the underlying cause, when there is one: a net.Error, an
// os.ErrDeadlineExceeded, a context error. It is part of the Is/As chain.
Err error
}
Error is the single concrete error type the SDK returns.
One type rather than six means `errors.As` always yields the detail — the operation, the address, the server's error kind — while `errors.Is` against the sentinel above answers the category question. Six types would have made the category check a type switch, which is exactly the API ADR-0022 rejected.
Reach for it when the category is not enough — to log which server failed, or to read the kind the server put on a rejection:
var atlasErr *client.Error
if errors.As(err, &atlasErr) && atlasErr.Kind == "OOM" {
// the server is out of memory, not merely unhappy with the command
}
Example ¶
Error carries the detail the category leaves out: which command, which server, and which error kind the server used. Reach for it with errors.As when the category is not enough — never by matching on the message.
package main
import (
"errors"
"fmt"
"github.com/b3vet/atlascache/pkg/client"
)
func main() {
// What a server out of memory produces, built here so the example runs.
var err error = &client.Error{
Category: client.ErrServer,
Op: "SET",
Addr: "cache.internal:6379",
Kind: "OOM",
Message: "OOM command not allowed when used memory > 'maxmemory'",
}
fmt.Println(err)
var atlasErr *client.Error
if errors.As(err, &atlasErr) && atlasErr.Kind == "OOM" {
fmt.Printf("%s on %s is out of memory; shed load rather than retrying\n",
atlasErr.Op, atlasErr.Addr)
}
fmt.Println(errors.Is(err, client.ErrServer), client.Retryable(err))
}
Output: atlascache: SET cache.internal:6379: OOM command not allowed when used memory > 'maxmemory' SET on cache.internal:6379 is out of memory; shed load rather than retrying true false
func (*Error) Error ¶
Error renders the failure as "atlascache: <op> <addr>: <detail>", for example:
atlascache: GET cache.internal:6379: ERR value is not an integer atlascache: dial 127.0.0.1:6379: connect: connection refused
The text is for a human and for a log line. It is not an interface: classify with errors.Is against a category sentinel, or read the fields with errors.As, and never by matching on this string.
type Option ¶
type Option func(*options) error
Option configures a client. Options are applied in order, so a later one wins over an earlier one, and a nil option is skipped — which makes conditional configuration a plain expression rather than a slice to build up:
client.New( client.WithAddr(addr), tlsOption(), // returns nil when TLS is off )
An option that does not validate is reported by New, before anything is dialed.
func WithAuth ¶
WithAuth authenticates every connection with a token, using the single argument form of AUTH.
The token is sent on every new connection, including ones opened later to replace a dropped one, so a caller never has to re-authenticate by hand.
Read it from the environment or a secret store rather than writing it into the source or passing it on a command line: an argument list is visible in `ps` to every other user on the host, which is why atlasctl prefers ATLASCACHE_AUTH to its own --auth flag.
Authenticating against a server that has auth disabled fails with ErrAuth even though the token is not the problem — the server answers "no password is set" and the SDK classifies every AUTH failure the same way. ISSUE-0023 tracks telling those two apart.
Example ¶
A token belongs in the environment rather than in the source or on a command line: an argument list is visible in `ps` to every other user on the host.
package main
import (
"context"
"errors"
"fmt"
"os"
"github.com/b3vet/atlascache/pkg/client"
)
func main() {
c, err := client.New(
client.WithAddr("cache.internal:6379"),
client.WithAuth(os.Getenv("ATLASCACHE_AUTH")),
)
if err != nil {
fmt.Println(err)
return
}
defer func() { _ = c.Close() }()
if err := c.Ping(context.Background()); errors.Is(err, client.ErrAuth) {
// Not retryable, and not transient: the token or the server is wrong.
fmt.Println("check ATLASCACHE_AUTH:", err)
}
}
Output:
func WithBackoff ¶
WithBackoff sets the exponential backoff the reconnect window spends.
The delay before attempt n is drawn uniformly from [0, min(base·2ⁿ, max)) — full jitter. The jitter is not a refinement: without it every client that lost its connection to the same server retries in lockstep, and the restart they are all waiting for is held down by their synchronized retries.
func WithDialTimeout ¶
WithDialTimeout bounds one connection attempt, including the TLS handshake and the HELLO/AUTH exchange. Zero means no bound beyond the caller's context.
func WithMaxIdleTime ¶
WithMaxIdleTime sets how long a connection may sit unused in the pool before it is closed rather than reused. It defaults to five minutes, and zero disables the check.
It exists because the far end has its own idea: a server with client_idle_timeout set, or any load balancer in between, will drop an idle connection without telling anyone. Closing first turns a surfaced error into a dial.
func WithPoolSize ¶
WithPoolSize sets how many connections the client may hold open at once. It defaults to 10, and must be at least 1.
It is a ceiling and not a target: connections are opened on demand, and an idle client holds none.
Exhaustion ¶
A call that arrives when every connection is busy waits for one. It does not open an eleventh, and it does not fail fast: it blocks until a connection is returned or its own context expires, and an expired context surfaces as ErrTimeout with Op "pool". That is the designed behavior and not a hang — a pool that grew under pressure would turn one leaked connection into exhausted memory, while one that blocks turns the same leak into timeouts that name the callers waiting on it (FEAT-0027).
Two consequences worth designing around. Always give a call a context with a deadline, or an exhausted pool is an unbounded wait. And size the pool for concurrent in-flight calls rather than for goroutines: a request path that makes one cache call at a time needs far fewer connections than it has workers, because each connection is held only for one round trip.
A pool of one serializes every call through a single connection, which is what atlasctl uses — a CLI runs one command — and what a multi-page Client.Scan currently needs, since a cursor belongs to the connection that issued it.
Example ¶
The pool is a ceiling, and a call that arrives when every connection is busy waits for one rather than opening another. Give every call a deadline, or that wait has no bound.
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/b3vet/atlascache/pkg/client"
)
func main() {
c, err := client.New(
client.WithAddr("localhost:6379"),
// Sized for concurrent in-flight calls, not for goroutines: a
// connection is held for one round trip, not for the request that
// borrowed it.
client.WithPoolSize(32),
)
if err != nil {
fmt.Println(err)
return
}
defer func() { _ = c.Close() }()
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
_, _, err = c.Get(ctx, "session:abc")
var atlasErr *client.Error
if errors.As(err, &atlasErr) && atlasErr.Op == "pool" {
// Every connection was busy for the whole 50ms. Raise the pool size,
// or find what is holding connections that long.
fmt.Println("the pool is saturated")
}
}
Output:
func WithReadTimeout ¶
WithReadTimeout bounds how long one reply may take to arrive. Zero means no bound beyond the caller's context.
func WithReconnectWindow ¶
WithReconnectWindow sets how long a failed connection attempt keeps being retried, with backoff, before the failure is surfaced to the caller. It defaults to five seconds.
This is what makes a server restart a pause rather than an outage: a call that arrives while the server is coming back waits and then succeeds. Zero surfaces the first dial failure immediately, which is the right setting for a caller that would rather serve a cache miss than wait.
The caller's own context always wins: the window never extends a deadline, it only bounds the wait for a caller who set none.
Example ¶
A long reconnect window rides out a server restart inside one call. Zero surfaces the first dial failure at once, which is what a caller who would rather serve a miss than wait wants.
package main
import (
"context"
"fmt"
"time"
"github.com/b3vet/atlascache/pkg/client"
)
func main() {
c, err := client.New(
client.WithAddr("localhost:6379"),
client.WithReconnectWindow(30*time.Second),
client.WithBackoff(50*time.Millisecond, 2*time.Second),
)
if err != nil {
fmt.Println(err)
return
}
defer func() { _ = c.Close() }()
// The caller's own deadline still wins: the window bounds the wait, it
// never extends one.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
fmt.Println(c.Ping(ctx))
}
Output:
func WithTLS ¶
WithTLS makes every connection TLS, using the given configuration.
The configuration is cloned, so the caller may keep using its own copy. When it names no ServerName, the host from the address is used — without that, a certificate check against an IP-less config fails for a reason that reads like a certificate problem and is not.
A config of &tls.Config{MinVersion: tls.VersionTLS13} verifies the server against the host's trust store, which is what a real certificate wants. A private CA means loading the PEM into an x509.CertPool and setting RootCAs; see the TLS example. That is fifteen lines every caller writes identically, and ISSUE-0023 tracks the WithTLSCAFile that will replace them.
InsecureSkipVerify turns the connection into encryption without authentication — anything on the path can present its own certificate — so it belongs in a test and nowhere else.
Example ¶
TLS is a tls.Config like any other. A private CA means loading the PEM into a pool; a certificate from a public CA needs neither of those lines.
package main
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"os"
"github.com/b3vet/atlascache/pkg/client"
)
func main() {
pemBytes, err := os.ReadFile("/etc/atlascache/ca.crt")
if err != nil {
fmt.Println(err)
return
}
roots := x509.NewCertPool()
if !roots.AppendCertsFromPEM(pemBytes) {
fmt.Println("no certificates in the CA file")
return
}
c, err := client.New(
client.WithAddr("cache.internal:6379"),
client.WithTLS(&tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS13}),
client.WithAuth(os.Getenv("ATLASCACHE_AUTH")),
)
if err != nil {
fmt.Println(err)
return
}
defer func() { _ = c.Close() }()
fmt.Println(c.Ping(context.Background()))
}
Output:
func WithUserAuth ¶
WithUserAuth authenticates with the two argument form, `AUTH <user> <token>`.
v0.1.0 has one shared token and one user, "default" (ADR-0020); both forms check the same secret. The two argument form exists because clients written against Redis 6 ACLs send it unconditionally, and because P6 may grow real users — a caller that already sends a username will not have to change.
func WithWriteTimeout ¶
WithWriteTimeout bounds how long one request may take to write. Zero means no bound beyond the caller's context.
type Reply ¶
type Reply struct {
// Type is the shape this reply arrived in, and it decides which of the
// fields below carry anything.
Type ReplyType
// Str carries the bytes of a bulk string, the text of a status or error
// reply, and the digits of a big number.
Str []byte
// Int carries an integer reply, and a boolean as 1 or 0.
Int int64
// Float carries a RESP3 double. Str holds the same value as the server
// wrote it, for callers that must not round-trip through float64.
Float float64
// Kind is the error kind of an error reply — the first word, "ERR" or
// "WRONGPASS" or "OOM" — with Str holding the whole line.
Kind string
// Arr holds the elements of an array, or the keys and values of a map
// flattened into alternating pairs. Flattening is deliberate: RESP2 has no
// map frame and renders one as exactly that array, so a caller reading
// STATS sees the same thing from a RESP2 and a RESP3 server.
Arr []Reply
}
Reply is one decoded server reply, and what Do hands back.
The payload is []byte rather than string throughout, because RESP is binary safe and a value containing a null byte or invalid UTF-8 is a value like any other. The conversion helpers below are where a caller opts into a string.
func (Reply) Bool ¶
Bool reports the reply as a truth value: a non-zero integer, a RESP3 boolean, or the +OK every write answers with.
func (Reply) ByteSlices ¶
ByteSlices returns an array reply as its elements' bytes.
func (Reply) Bytes ¶
Bytes returns the reply as bytes, without copying.
A nil reply returns (nil, nil), because "no value" is not an error. A caller that must tell a missing key from an empty one — both are legal, and the server distinguishes them — checks IsNil rather than the length.
The returned slice is read-only, under the same contract Client.Get states: do not modify it, and copy it before keeping it past the call that produced it. Reply.Text is the copying version.
An integer, boolean or double reply is rendered as the digits the server would have sent for it; an error reply is returned as an *Error rather than as its text; any other shape is a mismatch and an ErrProtocol.
Example ¶
package main
import (
"fmt"
"github.com/b3vet/atlascache/pkg/client"
)
func main() {
value := client.Reply{Type: client.TypeBulk, Str: []byte("payload")}
missing := client.Reply{Type: client.TypeNil}
// A nil reply is not an error — it is a cache miss — so the length of the
// result cannot tell a missing key from an empty one. IsNil can.
got, err := value.Bytes()
absent, err2 := missing.Bytes()
fmt.Printf("%q %v | %q %v %v\n", got, err, absent, err2, missing.IsNil())
}
Output: "payload" <nil> | "" <nil> true
func (Reply) Int64 ¶
Int64 returns the reply as an integer, parsing a bulk string if that is what arrived — the server answers some numeric questions with one.
Example ¶
package main
import (
"fmt"
"github.com/b3vet/atlascache/pkg/client"
)
func main() {
// The server answers some numeric questions with an integer and some with
// a bulk string. Int64 takes either, so a caller need not know which.
integer := client.Reply{Type: client.TypeInteger, Int: 42}
bulk := client.Reply{Type: client.TypeBulk, Str: []byte("42")}
a, err := integer.Int64()
b, err2 := bulk.Int64()
if err != nil || err2 != nil {
fmt.Println(err, err2)
return
}
fmt.Println(a, b)
}
Output: 42 42
func (Reply) IsNil ¶
IsNil reports whether the server answered with a null — a missing key, or an operation that produced nothing.
func (Reply) Map ¶
Map reads a reply as key/value pairs.
It accepts a RESP3 map and the flat RESP2 array a v0.1.0 server sends for the same reply, so a caller reading STATS or HELLO need not know which encoding the connection negotiated.
func (Reply) Slice ¶
Slice returns the elements of an array reply. A nil reply is an empty slice: RESP2 renders an empty array and a null array differently, and no caller iterating one cares which it got.
type ReplyType ¶
type ReplyType uint8
ReplyType is the shape of a decoded reply.
The RESP3 types are decoded even though v0.1.0 servers only speak RESP2 (ADR-0028), and the ones with a RESP2 equivalent are normalized onto it: a verbatim string reads as a bulk string, a set reads as an array. A caller that wrote its type switch against a RESP2 server keeps working when the server it talks to starts answering HELLO 3.
const ( // TypeNil is the null bulk string or null array. It is not an error: a // cache miss is the most ordinary outcome there is. TypeNil ReplyType = iota // TypeStatus is a simple status line, "+OK" being the one every write // answers with. Str holds the text without the leading plus. TypeStatus // TypeError is an error reply. Kind holds its first word and Str the whole // line. A command sent through the SDK never returns one of these as a // Reply — it is converted into an [*Error] first — so seeing one means it // arrived nested inside an array. TypeError // TypeInteger is a ":" reply, carried in Int. TypeInteger // TypeBulk is a length-prefixed string of arbitrary bytes, carried in Str. // It is what a value comes back as. TypeBulk // TypeArray is a multi-element reply, carried in Arr. A RESP3 set is // normalized onto it, because a caller iterating one does not care. TypeArray // TypeMap is a RESP3 map, carried in Arr as alternating keys and values — // the same layout a RESP2 server sends for the same reply, so // [Reply.Map] reads either without knowing which arrived. TypeMap // TypeBool is a RESP3 boolean, carried in Int as 1 or 0. TypeBool // TypeDouble is a RESP3 double, carried in Float, with Str holding the // text exactly as the server wrote it. TypeDouble // TypeBigNumber is a RESP3 big number, carried in Str as its digits // because it may not fit in an int64. TypeBigNumber // TypePush is a RESP3 out-of-band message. Nothing in v0.1.0 produces one, // and one arriving unsolicited would mean the stream is not where the SDK // thinks it is — so conn.exchange refuses it rather than returning it. TypePush )
The reply shapes a caller may see. Which one a command answers with is the server's choice, so a caller reading a Reply from Client.Do uses the conversion helpers rather than switching on this — they accept every shape that can sensibly carry the value asked for.
type ScanResult ¶
type ScanResult struct {
// Cursor is what to pass to the next Scan. The scan is finished when it is
// ScanStart, and only then.
Cursor string
// Keys is this page. It may be empty on a page that is not the last.
Keys []string
}
ScanResult is one page of a scan, as returned by Client.Scan.
func (ScanResult) Done ¶
func (r ScanResult) Done() bool
Done reports whether the scan has finished, which is true when and only when the cursor has come back to ScanStart. A page with no keys in it is not a finished scan: count bounds the work the server does for one page, not the keys that page finds, so an empty page in the middle of a sparse keyspace is ordinary.
Example ¶
package main
import (
"fmt"
"github.com/b3vet/atlascache/pkg/client"
)
func main() {
// A page in the middle of a sparse keyspace can be empty and still not be
// the end: count bounds the work the server does, not the keys it finds.
middle := client.ScanResult{Cursor: "4096", Keys: nil}
last := client.ScanResult{Cursor: client.ScanStart, Keys: []string{"session:abc"}}
fmt.Println(middle.Done(), last.Done())
}
Output: false true
type Stats ¶
Stats is the server's counters, keyed as STATS names them.
It is a map rather than a struct so that a counter added by a later server reaches callers without an SDK release — the same reasoning that gives the SDK a Do at all.
func (Stats) Value ¶
Value returns one counter, reporting whether the server sent it.
The second result is what distinguishes a counter this server does not publish from one that is genuinely zero — a distinction an older server talking to a newer caller will produce, and one that plain map indexing loses.
Example ¶
package main
import (
"fmt"
"github.com/b3vet/atlascache/pkg/client"
)
func main() {
stats := client.Stats{"hits": 1024, "misses": 12}
// The second result separates a counter this server does not publish from
// one that is genuinely zero.
hits, ok := stats.Value("hits")
evictions, published := stats.Value("evictions")
fmt.Println(hits, ok, evictions, published)
}
Output: 1024 true 0 false