#1166·asynq

[BUG] EnqueueContext loses underlying Redis errors returned by Lua scripts

Author: IceLockeCreated Jul 13, 2026Updated Jul 13, 2026
Labelsbug

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 timeout

The 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:

go
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
  • asynq package version: v0.26.0
  • go-redis package 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).

  1. Create the following go.mod:
go
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
)
  1. Create main.go:
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))
}
  1. Run:
bash
go mod tidy
go run .
  1. The program prints:
returned error: UNKNOWN: redis eval error: i/o timeout
errors.Is(err, cause): false
errors.As(err, net.Error): false

The 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): true

The 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 :)