hibiken/asynq · error

no token found for task

Error message

no token found for task %q

What it means

This error is returned by Semaphore.Release when the Redis ZREM operation against the semaphore's sorted set removes zero members, meaning no token/lease was recorded in Redis for the given taskID. The library treats releasing a task that never acquired (or already released / was evicted from) the semaphore as a caller error rather than silently succeeding, so the caller knows its bookkeeping is off.

Solutions

  1. Ensure Release is called exactly once per successful Acquire, guarding with a bool/sync.Once or a 'released' flag in deferred cleanup
  2. Verify the taskID passed to Release is byte-identical to the one returned by/used in Acquire
  3. Treat the error as idempotent-safe if double-release is expected in your design: log and continue instead of failing the worker
  4. Check for lease expiry or external cleanup of the semaphore key that removes tokens before Release runs

Example fix

// before
defer s.Release(ctx, taskID)

// after
var released bool
defer func() {
    if !released {
        released = true
        if err := s.Release(ctx, taskID); err != nil {
            log.Printf("semaphore release skipped: %v", err)
        }
    }
}()
Defensive patterns

Strategy: try-catch

Validate before calling

// Go has no pre-call check without a round trip; optionally verify the token exists first:
n, _ := s.rc.ZScore(ctx, semaphoreKey(s.scope), taskID).Result()
if errors.Is(n, redis.Nil) { return nil /* already released */ }

Try / catch

if err := sem.Release(ctx, taskID); err != nil {
    if strings.Contains(err.Error(), "no token found for task") {
        log.Printf("task %s already released or expired, ignoring", taskID)
        return nil
    }
    return err
}

Prevention

When it happens

Trigger: Calling Release(taskID) with a taskID that never called Acquire, calling Release twice for the same taskID (the second ZREM deletes nothing), the token expiring or being removed from the sorted set (e.g. lease expiry/cleanup) before Release runs, or using a taskID string that differs from the one passed to Acquire (case/format mismatch).

Common situations: Worker code that releases in a deferred cleanup path which can run more than once; retries that re-release an already-released task after a failed job; passing a job ID instead of the acquire-time task ID; Redis flush/restart wiping the semaphore key between Acquire and Release.

Understand the failure class

Background: 'Could not be found', 'does not exist', 'not found in database': the resource-not-found family when an ID, slug, key, or URI lookup comes back empty — this error's family across 20 libraries.

Related errors


AI-assisted analysis of hibiken/asynq@d135f1439b (2026-09-07). Data as JSON: /api/errors/d3c7b5b20a458a25. Report an issue: GitHub.

Appendix: source

Thrown at x/rate/semaphore.go:101

		d.Unix(),
		taskID,
	).Bool()
}

// Release will release the token on the counting semaphore.
func (s *Semaphore) Release(ctx context.Context) error {
	taskID, ok := asynqcontext.GetTaskID(ctx)
	if !ok {
		return fmt.Errorf("provided context is missing task ID value")
	}

	n, err := s.rc.ZRem(ctx, semaphoreKey(s.scope), taskID).Result()
	if err != nil {
		return fmt.Errorf("redis command failed: %w", err)
	}

	if n == 0 {
		return fmt.Errorf("no token found for task %q", taskID)
	}

	return nil
}

// Close closes the connection to redis.
func (s *Semaphore) Close() error {
	return s.rc.Close()
}

func semaphoreKey(scope string) string {
	return "asynq:sema:" + scope
}

View on GitHub (pinned to d135f1439b)