An async/await Redis client for Perfect, built on swift-server/RediStack.
RedisClient is an actor with an async throws API. Every command and argument goes to the server as a RESP bulk string, so keys and values can hold any bytes, CR and LF included. Perfect-Session's PerfectSessionRedis module stores its sessions with it.
- Swift 6.2 or newer (
swift-tools-version: 6.2, Swift 6 language mode). - macOS 12+, iOS 17+, or Linux. CI builds and tests on Ubuntu 24.04 with Swift 6.2 and 6.4, macOS with Xcode 26.0.1 and 26.6, and the iOS Simulator.
- A Redis server that speaks RESP2. The live tests run against Redis 7.
- RediStack (
from: "1.6.0"), plus SwiftNIO and swift-log, which RediStack also uses; SwiftPM resolves them all.
dependencies: [
.package(url: "https://github.com/PerfectlySoft/Perfect-Redis.git", from: "4.0.0"),
],
targets: [
.target(
name: "YourTarget",
dependencies: [
.product(name: "PerfectRedis", package: "Perfect-Redis"),
]
),
]import PerfectRedis
// Defaults to 127.0.0.1:6379 with no password.
let client = try await RedisClient.connect(withIdentifier: RedisClientIdentifier())
let pong = try await client.ping() // .simpleString("PONG")
_ = try await client.set(key: "greeting", value: .string("hello"), expires: 60)
let reply = try await client.get(key: "greeting")
print(reply.string ?? "missing") // "hello"
print(try await client.get(key: "nope").isNil) // true
try await client.close()Connect to another server, with a password and a timeout for each call:
let id = RedisClientIdentifier(withHost: "redis.internal", port: 6379,
password: "secret", commandTimeout: 5)
let client = try await RedisClient.connect(withIdentifier: id)Replies come back as RedisResponse: .simpleString, .bulkString([UInt8]?) (nil for a missing value, [] for an empty one), .integer, .array, or .error for an error nested inside an array (as EXEC returns). The string, integer, isNil and isSimpleOK accessors cover the common cases. RedisHash, RedisList and RedisSet wrap a client and a key for the hash, list and set commands, and sendCommandAsRESP(name:parameters:) sends any other command.
Errors from the server and the connection are RediStack's own types, passed through unwrapped:
- An error reply to a command (such as
WRONGTYPE) is thrown as RediStack'sRedisError, whosemessagestarts with(Redis). - A closed or dropped connection makes commands throw RediStack's
RedisClientError(.connectionClosed). A client doesn't reconnect; connect a new one.
Problems the client catches itself are thrown as RedisClient.CommandError: arguments it can't send (a non-finite duration, say), commands sendCommandAsRESP refuses, commands sent while the client has subscriptions, a reply with impossible framing, or a commandTimeout running out.
Messages arrive on the subscribing client's messages stream:
let subscriber = try await RedisClient.connect()
let publisher = try await RedisClient.connect()
try await subscriber.subscribe(channels: ["news"])
try await subscriber.subscribe(patterns: ["weather.*"])
Task {
for await message in subscriber.messages {
print(message.channel, message.message.string ?? "")
}
}
_ = try await publisher.publish(channel: "news", message: .string("Hello!"))subscribe first waits for commands the client has already sent to get their replies. From then until its last subscription ends, the client throws CommandError for every other command, so publish and run other commands from a second client. Subscription changes run one at a time, in the order they're made. unsubscribe(channels: []) drops every channel subscription and unsubscribe(patterns: []) every pattern one; names the client isn't subscribed to are ignored, so unsubscribing from nothing returns at once. Once both kinds are gone, the client accepts ordinary commands again.
If the server refuses a subscription (for example an ACL NOPERM), RediStack drops the error reply, so subscribe waits until commandTimeout (or until the connection closes). Meanwhile the client refuses every command and later subscription changes queue behind it. Set a commandTimeout if your server restricts channels. On a closed connection, subscribe and unsubscribe both throw RedisClientError.connectionClosed. The stream ends when the connection closes for any reason: close(), a timeout, a cancellation, or the server dropping it.
let results = try await client.multi {
_ = try await client.set(key: "a", value: .string("1"))
_ = try await client.increment(key: "a")
} // .array([.simpleString("OK"), .integer(2)])While multi runs, commands that other tasks send on the same client wait until EXEC or DISCARD, so they aren't queued into the transaction. If the body throws, the transaction is discarded and the error rethrown. Commands from the body's child tasks are part of the transaction; a task started outside it (or detached) that the body waits for will deadlock. So will two tasks that take transactions on two clients in opposite orders (c.multi { d… } while another runs d.multi { c… }). If the task is cancelled while the body runs, the client closes its connection rather than leave a MULTI open. multiBegin(), multiExec() and multiDiscard() are also available, but called by hand they have no such protection.
commandTimeout is one budget for each call, covering everything the call waits for: another task's transaction, earlier subscription changes, commands in flight (for subscribe), and then the reply. If it runs out before anything is sent, the call throws CommandError and the connection stays open; if it runs out while waiting for the reply, the call throws CommandError and the connection is closed. It also bounds connect from the TCP connection on, including AUTH (but not the host name lookup). The default is no timeout. multi(body:) isn't one budget: waiting for another task's transaction, MULTI, each command in the body and EXEC each get their own. Cancelling a task while its command is waiting for a reply also closes the connection, and the command throws CancellationError; a task that is already cancelled, or cancelled while waiting its turn, throws without sending anything. The client closes the connection in both cases because the command has already gone to the server, and abandoning just its reply would hand the next reply to the wrong command. Every other command waiting on that client then fails too.
close() gives outstanding replies up to a second, then closes the connection outright. Releasing a client without close() closes it immediately.
Commands share one connection and are pipelined in order, so a blocking command such as listPopFirstBlocking(keys:timeout:) holds up everything sent after it on the same client. Give blocking commands their own client, and keep their server-side timeout shorter than commandTimeout.
- RESP2 only.
sendCommandAsRESPrefusesHELLOwith any protocol version except 2. It also refuses commands that would break the one-reply-per-command pairing or the client's own bookkeeping: theSUBSCRIBEfamily (usesubscribe),MONITOR,SYNC,PSYNC,CLIENT REPLY OFF/SKIP, andRESET. - No TLS. The connection is plain TCP. Use a TLS proxy or tunnel to reach a TLS-only server.
- Password only.
AUTHis sent with the password alone, so it authenticates as thedefaultACL user; there is no username option. - No reconnection or pooling. Each
RedisClientis one connection. - Replies of more than 2²⁴ elements are refused. To keep a hostile server from making RediStack reserve unbounded memory, a reply with more than 16,777,216 array elements outstanding (an
LRANGE,KEYS,SMEMBERSorHGETALLof a bigger collection) fails withCommandErrorand closes the connection. Page through large collections withSCAN-style commands or ranges. - Host name lookup isn't bounded.
connectresolves the host with a blocking system call beforecommandTimeoutstarts; a hung DNS server holdsconnectuntil the system gives up. - RediStack is deprecated upstream: its maintainers point new users to valkey-swift. RediStack 1.6 still builds and works on Swift 6.2 and 6.4, and this release stays on it.
By default the client gives RediStack a logger that discards everything. To see RediStack's logs, pass a Logger (from swift-log) as RedisClientIdentifier(…, logger:). Be careful with its level: at debug, RediStack logs every command with its arguments, including the password sent with AUTH, and every reply; at error, every failed command.
swift test # reply, framing, argument and connection tests; live suites skip
# The live suites FLUSHALL, so point them at a throwaway server.
docker run -d --rm -p 6379:6379 redis:7
REDIS_TESTS=1 swift test
REDIS_TESTS=1 REDIS_HOST=redis.test REDIS_PORT=6380 REDIS_REQUIRE_LIVE=1 swift testREDIS_HOST defaults to 127.0.0.1 and REDIS_PORT to 6379. With REDIS_REQUIRE_LIVE=1, a missing or unreachable server fails the run instead of skipping the live suites.
4.0.0 is a rewrite. The API keeps the 3.x command names and RedisResponse shape, but everything is async:
RedisClient.getClient(withIdentifier:)(callback or throwing form) andreleaseClient(_:)→try await RedisClient.connect(withIdentifier:)andtry await client.close().- Completion handlers and the blocking
throwsmethods →try await. For example,client.ping { response in … }ortry client.ping()becomestry await client.ping(). - Error replies → thrown. 3.x returned them as
.error(type:msg:); 4.0 throwsRedisError(see Errors)..errornow appears only inside arrays, such asEXECresults. sendCommand(name:parameters:callback:)→sendCommandAsRESP(name:parameters:).readPublished(timeoutSeconds:callback:)→for await message in client.messages.subscribeandunsubscribenow return nothing.netTimeout→RedisClientIdentifier(…, commandTimeout:).RedisClientIdentifier'snetGeneratoris gone.clientReply(type:)is removed. Turning replies off would break reply pairing.RedisHash,RedisListandRedisSetlost their collection-style API (init(_:name:), subscripts, iteration, and theexists,count,keysandvaluesproperties), as didclient.hash(named:),client.list(named:)andclient.set(named:). They're nowinit(client:key:)with async methods named after the commands, such asget(field:),append(_:)andmembers().RedisClientIdentifier.client(),RedisClient.init(net:)andsendRawCommand(bytes:)are gone; useRedisClient.connect(withIdentifier:)andsendCommandAsRESP.RedisResponse.toString()andRedisValue.toString()→ thestringproperty.RedisValue.commandStringis gone.[UInt8]is aRedisValueRepresentable; otherROctalarrays aren't.RedisClientis anactor, and one client can be shared between tasks: commands from different tasks are pipelined on its single connection. A subscribed client refuses other commands,multimakes other tasks wait, and a timeout or cancellation closes the connection for every task using it, so give subscriptions, blocking commands and cancellable work their own clients.
The pre-Swift-6 code is preserved on the legacy branch, which points at the same commit as the 3.4.1 tag. Stay on 3.4.1 if you can't move to Swift 6.2 yet.
For more information on the Perfect project, visit perfect.org.