[BUG] EnqueueContext loses underlying Redis errors returned by Lua scripts
Describe the bug
Client.EnqueueContext loses the underlying Redis error when the enqueue Lua script fails.
The background is, sometimes due to network jitter, the Redis broker may become temporarily unavailable to the Asynq client. In application log we can see this error returned by EnqueueContext:
UNKNOWN: redis eval error: i/o timeoutThe error message is preserved. However, the original typed error is converted to a string, so callers cannot inspect it with errors.Is or errors.As and to retry with exponential backoff if it is a network problem.
The cause appears to be the use of fmt.Sprintf in the Lua script helpers:
return 0, errors.E(
op,
errors.Unknown,
fmt.Sprintf("redis eval error: %v", err),
)Environment (please complete the following information):
- OS: Linux
- Go version: Go 1.25.0
asynqpackage version: v0.26.0go-redispackage version: v9.18.0- Redis/Valkey version: N/A for the minimal reproduction below; the error is injected through a go-redis Hook, so no Redis server is required
The same behavior is also present on the current Asynq master branch as of commit d135f1439bee74e989b7f9b41ecd542cc87f024a.
To Reproduce
The following example uses only public Asynq and go-redis APIs. It injects a go-redis client through asynq.RedisConnOpt, makes the initial SADD succeed, and makes EVALSHA/EVAL return a typed network timeout.
No Redis server is required (like the case that Redis is not available).
- Create the following
go.mod:
module example.com/asynq-error-repro
go 1.25.0
require (
github.com/hibiken/asynq v0.26.0
github.com/redis/go-redis/v9 v9.18.0
)- Create
main.go:
package main
import (
"context"
"errors"
"fmt"
"net"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
)
// redisConnOpt lets Asynq use the go-redis client configured by this example.
// It implements asynq.RedisConnOpt using only public APIs.
type redisConnOpt struct {
client redis.UniversalClient
}
func (o redisConnOpt) MakeRedisClient() any {
return o.client
}
// timeoutError is the typed network error that should remain discoverable in
// the error chain returned by EnqueueContext.
type timeoutError struct{}
func (*timeoutError) Error() string { return "i/o timeout" }
func (*timeoutError) Timeout() bool { return true }
// evalFailureHook avoids needing a real Redis server. The first SADD performed
// by Asynq succeeds, while the enqueue Lua script fails with cause.
type evalFailureHook struct {
cause error
}
func (h evalFailureHook) DialHook(next redis.DialHook) redis.DialHook {
return next
}
func (h evalFailureHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook {
return func(ctx context.Context, cmd redis.Cmder) error {
switch cmd.Name() {
case "sadd":
return nil
case "evalsha", "eval":
return h.cause
default:
return next(ctx, cmd)
}
}
}
func (h evalFailureHook) ProcessPipelineHook(
next redis.ProcessPipelineHook,
) redis.ProcessPipelineHook {
return next
}
func main() {
cause := &timeoutError{}
rdb := redis.NewClient(&redis.Options{
Addr: "unused:6379",
MaxRetries: -1,
})
rdb.AddHook(evalFailureHook{cause: cause})
client := asynq.NewClient(redisConnOpt{client: rdb})
defer client.Close()
_, err := client.EnqueueContext(
context.Background(),
asynq.NewTask("example:task", nil),
asynq.TaskID("fixed-task-id"),
)
var netErr net.Error
fmt.Printf("returned error: %v\n", err)
fmt.Printf("errors.Is(err, cause): %v\n", errors.Is(err, cause))
fmt.Printf("errors.As(err, net.Error): %v\n", errors.As(err, &netErr))
}- Run:
go mod tidy
go run .- The program prints:
returned error: UNKNOWN: redis eval error: i/o timeout
errors.Is(err, cause): false
errors.As(err, net.Error): falseThe textual error context is preserved, but the original typed cause is no longer in the error chain.
Expected behavior
EnqueueContext should preserve the original Redis error while adding the redis eval error context.
returned error: UNKNOWN: redis eval error: i/o timeout
errors.Is(err, cause): true
errors.As(err, net.Error): trueThe simplest fix might be to replace %v in fmt.Sprintf("redis eval error: %v", err) with %w.
If this behavior is confirmed as a bug, I would be happy to submit a PR to fix this :)
Source: hibiken/asynq