Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions .claude/skills/update-error-reference/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,46 @@ page's example and text. If a change would remove or soften a limitation that
the page states, flag it to the user instead of changing it. Then save the new
replies with `--accept`.

## 5. Finish
## 5. Recheck the client libraries

Each error page has an "In client libraries" table, from
`data/client-exceptions.json`. A new Redis release can change what a client
raises, as can a new client release. `check` already fails when the data names a
class that the last run of a client didn't see, or when a page has no entry. This
step refreshes those runs. Skip it only if the user says so, and say in the
summary that you did.

1. Read `versions` in `data/client-exceptions.json` and find each client's latest
release on its registry. Ask the user before you move a client to a new
major version, because its API may have changed.
2. Run each client's bridge on the new Redis image, one at a time, because they
share the Docker ports 6550-6575. Run them outside the sandbox, as they need
Docker, the network and local ports. Each takes about two minutes:

```
python3 build/errors/live_clients.py <bridge> --image redis:<version> [--version <client version>]
```

The bridges are `jedis`, `lettuce_sync`, `lettuce_async`, `lettuce_reactive`,
`node_redis`, `ioredis`, `go_redis`, `se_redis_sync`, `se_redis_async`,
`predis`, `redis_rb`, `rust_sync` and `rust_async`. redis-py is covered by
step 4. `build/errors/README.md` lists the toolchain each one needs. If one
is missing, ask the user to install it rather than skipping the client.
3. Each run lists the pages where the client's reply, or the server's, differs
from `client_baselines/<bridge>.json`. For each page, update the entry in
`data/client-exceptions.json`: the class, the message, `check` and `note`.
Update the client's version in `versions`. Then save the run by running the
same command with `--accept`. It refuses a run in which a step failed.
4. For a new page in `common/`, add an entry for every client in `order`, with
the live result. Use `"class": "(none)"` for a client that raises nothing. If
the page's error names a class that the lookup should match, extend the
page's `match` list.

A few rows depend on timing or on a restricted user, such as `denied` and max
clients, and can differ between runs. Run those again before you change a page
for them.

## 6. Finish

Run `python3 build/errors/update_errors.py check` and
`python3 build/test_update_errors.py`, both of which must pass. Then tell the
Expand All @@ -123,6 +162,7 @@ user, in a few lines:
- the new error codes,
- which explanations are `inferred`, and why,
- any upstream bugs you found, such as a message with the wrong code or a typo,
- the pages that `verify` flagged, and what you changed.
- the pages that `verify` flagged, and what you changed,
- the clients whose replies changed, and the client versions you moved.

Don't commit or open a pull request unless the user asks.
31 changes: 31 additions & 0 deletions build/errors/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,35 @@ checking what Claude did.
7. **Build the site and open a pull request.** Look at the list pages that
changed, and at the error lookup on the errors page.

## Check how client libraries report the errors

The "In client libraries" table on each error page comes from
`data/client-exceptions.json`. `verify` records only the redis-py names. To check
another client library, run the same checks with that client:

```
python3 build/errors/live_clients.py jedis --image redis:8.12.0 --version 8.0.3
```

It needs Docker, like `verify`, and the client's tools: Java and Maven (`jedis`,
`lettuce_sync`, `lettuce_async`, `lettuce_reactive`), Node.js and npm
(`node_redis`, `ioredis`), Go (`go_redis`), the .NET SDK (`se_redis_sync`,
`se_redis_async`), PHP and Composer (`predis`), Ruby (`redis_rb`), and Rust
(`rust_sync`, `rust_async`). Each client is a bridge in `bridges/`: a short
Python module and a probe program in the client's language that runs one command
and prints the exception class and message. The run lists the pages where the
client's reply, or the server's, differs from `client_baselines/<client>.json`.
Copy the classes and messages that changed into `data/client-exceptions.json` by
hand, and the client's version into its `versions`. Then save the run with
`--accept`, which needs a full run, so it can't be combined with `--only`.
`update_errors.py check` fails when the data names a class that the baselines
didn't see, and when a page has no entry. Run the clients one at a time, because
they share the Docker ports.

To add a client, copy `bridges/jedis.py` and its probe, and make the probe print
`OK<TAB>value` or `EXC<TAB>class<TAB>message`. The format is in
`bridges/_common.py`.

## How the comparison works

The tool reads every string in the C and Rust source of each repository, at
Expand All @@ -132,6 +161,8 @@ builds, can't be followed. `diff` says how many.
- `live_checks.py`: the server checks that `verify` runs.
- `live_baseline.json`: the replies that the error pages show, from the last
accepted run.
- `live_clients.py`, `bridges/` and `client_baselines/`: the same checks run
with other client libraries.
- `explanation-style.md`: how to write an explanation, and the fields of a new
message.
- `../test_update_errors.py`: unit tests. Run `python3 build/test_update_errors.py`.
95 changes: 95 additions & 0 deletions build/errors/bridges/LettuceProbe.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import io.lettuce.core.*;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.api.async.RedisAsyncCommands;
import io.lettuce.core.api.reactive.RedisReactiveCommands;
import io.lettuce.core.api.sync.RedisCommands;
import io.lettuce.core.codec.StringCodec;
import io.lettuce.core.output.NestedMultiOutput;
import io.lettuce.core.protocol.CommandArgs;
import io.lettuce.core.protocol.ProtocolKeyword;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;

/** Runs one command, or one MULTI/EXEC block, through Lettuce's sync, async or reactive API and prints what it throws. */
public class LettuceProbe {
static final StringCodec C = StringCodec.UTF8;

static ProtocolKeyword kw(String name) {
byte[] raw = name.getBytes();
return new ProtocolKeyword() {
public byte[] getBytes() { return raw; }
public String name() { return name; }
};
}
static CommandArgs<String, String> args(String[] t) {
CommandArgs<String, String> a = new CommandArgs<>(C);
for (int i = 1; i < t.length; i++) {
if (t[i].startsWith("hex:")) {
String h = t[i].substring(4); byte[] b = new byte[h.length() / 2];
for (int j = 0; j < b.length; j++) b[j] = (byte) Integer.parseInt(h.substring(2 * j, 2 * j + 2), 16);
a.add(b);
} else a.add(t[i]);
}
return a;
}
static NestedMultiOutput<String, String> out() { return new NestedMultiOutput<>(C); }
static String one(Throwable e) { return String.valueOf(e.getMessage()).replace("\r", " ").replace("\n", " "); }

/** The exception the caller meant: the cause of an ExecutionException or CompletionException. */
static String describe(Throwable e) {
String wrapper = "";
if ((e instanceof ExecutionException || e instanceof CompletionException) && e.getCause() != null) {
wrapper = " [thrown as " + e.getClass().getSimpleName() + "]";
e = e.getCause();
}
String cause = e.getCause() != null ? " [cause " + e.getCause().getClass().getName() + ": " + one(e.getCause()) + "]" : "";
return "EXC\t" + e.getClass().getName() + "\t" + one(e) + cause + wrapper;
}

public static void main(String[] a) {
String api = a[0]; int port = Integer.parseInt(a[1]);
RedisURI.Builder ub = RedisURI.builder().withHost("127.0.0.1").withPort(port).withTimeout(Duration.ofSeconds(5));
if (!a[2].isEmpty() && !a[3].isEmpty()) ub.withAuthentication(a[2], a[3]);
else if (!a[3].isEmpty()) ub.withPassword(a[3].toCharArray());
RedisClient client = RedisClient.create(ub.build());
try (StatefulRedisConnection<String, String> conn = client.connect()) {
Object r;
if (a[4].equals("cmd")) {
String[] t = a[5].split("\u0001", -1);
switch (api) {
case "sync": r = conn.sync().dispatch(kw(t[0]), out(), args(t)); break;
case "async": r = conn.async().dispatch(kw(t[0]), out(), args(t)).get(); break;
default: r = conn.reactive().dispatch(kw(t[0]), out(), args(t)).collectList().block();
}
} else {
List<String[]> cmds = new ArrayList<>();
for (int i = 5; i < a.length; i++) cmds.add(a[i].split("\u0001", -1));
switch (api) {
case "sync": {
RedisCommands<String, String> c = conn.sync(); c.multi();
for (String[] t : cmds) c.dispatch(kw(t[0]), out(), args(t));
r = c.exec(); break;
}
case "async": {
RedisAsyncCommands<String, String> c = conn.async(); c.multi().get();
for (String[] t : cmds) c.dispatch(kw(t[0]), out(), args(t));
r = c.exec().get(); break;
}
default: {
RedisReactiveCommands<String, String> c = conn.reactive();
r = c.multi().flatMap(s -> {
for (String[] t : cmds) c.dispatch(kw(t[0]), out(), args(t)).subscribe(v -> {}, e -> {});
return c.exec();
}).block();
}
}
}
System.out.println("OK\t" + r);
} catch (Throwable e) {
System.out.println(describe(e));
} finally {
client.shutdown(Duration.ZERO, Duration.ofSeconds(1));
}
}
}
45 changes: 45 additions & 0 deletions build/errors/bridges/Probe.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import redis.clients.jedis.*;
import redis.clients.jedis.commands.ProtocolCommand;
import java.util.*;

/** Runs one command, or one MULTI/EXEC block, through Jedis and prints what it throws. */
public class Probe {
static byte[] arg(String s) {
if (s.startsWith("hex:")) {
String h = s.substring(4); byte[] b = new byte[h.length() / 2];
for (int i = 0; i < b.length; i++) b[i] = (byte) Integer.parseInt(h.substring(2 * i, 2 * i + 2), 16);
return b;
}
return s.getBytes();
}
static ProtocolCommand pc(String name) { byte[] raw = name.getBytes(); return () -> raw; }
static byte[][] rest(String[] t, int from) {
byte[][] a = new byte[t.length - from][];
for (int i = from; i < t.length; i++) a[i - from] = arg(t[i]);
return a;
}
static String one(Throwable e) { return String.valueOf(e.getMessage()).replace("\r", " ").replace("\n", " "); }

public static void main(String[] a) {
int port = Integer.parseInt(a[0]);
DefaultJedisClientConfig.Builder b = DefaultJedisClientConfig.builder().connectionTimeoutMillis(5000).socketTimeoutMillis(5000);
if (!a[1].isEmpty()) b.user(a[1]);
if (!a[2].isEmpty()) b.password(a[2]);
try (Jedis j = new Jedis(new HostAndPort("127.0.0.1", port), b.build())) {
if (a[3].equals("cmd")) {
String[] t = a[4].split("\u0001", -1);
Object r = j.sendCommand(pc(t[0]), rest(t, 1));
System.out.println("OK\t" + (r instanceof byte[] ? new String((byte[]) r) : r));
} else {
Transaction tx = j.multi();
for (int i = 4; i < a.length; i++) { String[] t = a[i].split("\u0001", -1); tx.sendCommand(pc(t[0]), rest(t, 1)); }
List<Object> r = tx.exec();
System.out.println("OK\t" + r);
}
} catch (Throwable e) {
StringBuilder sup = new StringBuilder();
for (Throwable t : e.getSuppressed()) sup.append(" [suppressed ").append(t.getClass().getSimpleName()).append(": ").append(one(t)).append("]");
System.out.println("EXC\t" + e.getClass().getName() + "\t" + one(e) + sup);
}
}
}
34 changes: 34 additions & 0 deletions build/errors/bridges/SeRedisProbe.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Runs one command, or one MULTI/EXEC block, through StackExchange.Redis and prints what it returns or throws.
// Arguments: api port user password mode payload... (see _common.py for the format)
using StackExchange.Redis;

string api = args[0]; int port = int.Parse(args[1]); string user = args[2], pass = args[3], mode = args[4];
string One(string s) => (s ?? "").Replace("\r", " ").Replace("\n", " ");
object[] Tokens(string p) => p.Split('\u0001').Skip(1).Select(s => s.StartsWith("hex:")
? (object)Convert.FromHexString(s.Substring(4)) : s).ToArray();
string Name(string p) => p.Split('\u0001')[0];
string Describe(Exception e) =>
$"EXC\t{e.GetType().FullName}\t{One(e.Message)}" + (e.InnerException != null ? $" [inner {e.InnerException.GetType().FullName}: {One(e.InnerException.Message)}]" : "");

var o = new ConfigurationOptions { ConnectTimeout = 5000, SyncTimeout = 5000, AsyncTimeout = 5000, AbortOnConnectFail = true };
o.EndPoints.Add("127.0.0.1", port);
if (user != "") o.User = user;
if (pass != "") o.Password = pass;
try {
using var mux = api == "sync" ? ConnectionMultiplexer.Connect(o) : await ConnectionMultiplexer.ConnectAsync(o);
var db = mux.GetDatabase();
if (mode == "cmd") {
var p = args[5];
var r = api == "sync" ? db.Execute(Name(p), Tokens(p)) : await db.ExecuteAsync(Name(p), Tokens(p));
Console.WriteLine("OK\t" + One(r.ToString()));
} else {
var tx = db.CreateTransaction();
var tasks = args.Skip(5).Select(p => tx.ExecuteAsync(Name(p), Tokens(p))).ToList();
bool committed = api == "sync" ? tx.Execute() : await tx.ExecuteAsync();
var errs = new List<string>();
foreach (var t in tasks) { try { await t; } catch (Exception e) { errs.Add(e.GetType().Name + ": " + One(e.Message)); } }
Console.WriteLine($"OK\tcommitted={committed}" + (errs.Count > 0 ? " [command errors: " + string.Join("; ", errs) + "]" : ""));
}
} catch (Exception e) {
Console.WriteLine(Describe(e));
}
86 changes: 86 additions & 0 deletions build/errors/bridges/_common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""The part of every client bridge that doesn't depend on the client library.

A bridge lets live_checks.py run its checks with another client library. The
checks call `client(...)` and `py_err(...)` as if the client were redis-py, so a
bridge offers the redis-py methods that the checks use, and turns each one into
a raw command for a small probe program written in the client's language. The
probe runs the command and prints what the client returns or raises.

A bridge module defines:

KEY the key the exceptions are recorded under, such as 'jedis'
VERSION the default client version
prepare(version, cache) installs the client and the probe (once), returns a
function run(port, user, password, mode, *payload)
that returns the probe's output, or raises ClientErr
"""
import os, subprocess

SEP = '\x01'


class ClientErr(Exception):
"""An error that the client raised: its class name and message."""
def __init__(self, cls, msg):
super().__init__(f'{cls}: {msg}')
self.cls, self.msg = cls, msg


def parse(stdout, stderr=''):
"""The probe prints one line, `OK<TAB>value` or `EXC<TAB>class<TAB>message`."""
lines = [l for l in stdout.splitlines() if l.startswith(('OK\t', 'EXC\t'))]
if not lines:
raise RuntimeError(f'the probe printed no result: {stdout!r} {stderr[-300:]!r}')
parts = lines[-1].split('\t', 2)
if parts[0] == 'EXC':
raise ClientErr(parts[1], parts[2] if len(parts) > 2 else '')
return parts[1]


def sh(*cmd, cwd=None, env=None):
r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, env={**os.environ, **(env or {})}, timeout=900)
if r.returncode:
raise SystemExit(f'{" ".join(cmd[:3])} failed:\n{r.stdout[-800:]}\n{r.stderr[-800:]}')
return r


def _a(x):
# Binary arguments, such as the output of DUMP, travel as hex.
return 'hex:' + x.hex() if isinstance(x, bytes) else str(x)


def pack(args):
return SEP.join(_a(x) for x in args)


class Bridge:
"""The redis-py methods that live_checks.py calls, as raw commands."""
run = None # set by live_clients.py: run(port, user, password, mode, *payload)

def __init__(self, port, username=None, password=None):
self.port, self.user, self.pw = port, username or '', password or ''

def execute_command(self, *args):
return self.run(self.port, self.user, self.pw, 'cmd', pack(args))

def get(self, k): return self.execute_command('GET', k)
def set(self, k, v): return self.execute_command('SET', k, v)
def ping(self): return self.execute_command('PING')
def incr(self, k): return self.execute_command('INCR', k)
def evalsha(self, sha, n): return self.execute_command('EVALSHA', sha, n)
def restore(self, name, ttl, data): return self.execute_command('RESTORE', name, ttl, data)
def xgroup_create(self, s, g, i, mkstream=False):
return self.execute_command('XGROUP', 'CREATE', s, g, i, *(['MKSTREAM'] if mkstream else []))
def xreadgroup(self, g, c, streams):
return self.execute_command('XREADGROUP', 'GROUP', g, c, 'STREAMS', *streams.keys(), *streams.values())
def mget(self, *keys): return self.execute_command('MGET', *keys)
def mset(self, d): return self.execute_command('MSET', *[x for kv in d.items() for x in kv])
def pipeline(self, transaction=True): return Tx(self)


class Tx:
"""A MULTI/EXEC block: the commands queue here, and execute() sends them."""
def __init__(self, b): self.b, self.cmds = b, []
def execute_command(self, *args): self.cmds.append(pack(args))
def set(self, k, v): self.execute_command('SET', k, v)
def execute(self): return self.b.run(self.b.port, self.b.user, self.b.pw, 'tx', *self.cmds)
23 changes: 23 additions & 0 deletions build/errors/bridges/go_redis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""go-redis bridge for live_clients.py. Needs Go 1.24+, and network access to the Go module proxy."""
import os, subprocess
from pathlib import Path
from _common import parse, sh

KEY = 'go_redis'
VERSION = '9.22.0'
HERE = Path(__file__).parent


def prepare(version, cache):
d = Path(cache) / 'bridges' / f'go-redis-{version}'
d.mkdir(parents=True, exist_ok=True)
(d / 'go.mod').write_text(f'module probe\n\ngo 1.24\n\nrequire github.com/redis/go-redis/v9 v{version}\n')
(d / 'main.go').write_text((HERE / 'go_redis_probe.go').read_text())
sh('go', 'mod', 'tidy', cwd=d)
sh('go', 'build', '-o', 'probe', '.', cwd=d)

def run(port, user, password, mode, *payload):
out = subprocess.run([str(d / 'probe'), str(port), user, password, mode, *payload],
capture_output=True, text=True, timeout=60, env=os.environ)
return parse(out.stdout, out.stderr)
return run
Loading
Loading