hibiken/asynq · warning

skip retry for the task

Error message

skip retry for the task

What it means

SkipRetry is a sentinel returned by a task Handler to tell asynq the task should NOT be retried. In processor.handleFailedMessage, when errors.Is(err, SkipRetry) matches (or retries are exhausted), the processor archives the task instead of re-scheduling it, so the failure is final.

Solutions

  1. Ensure the handler returns SkipRetry wrapped with %w so errors.Is detects it (or return the sentinel unwrapped).
  2. If you want the task retained for inspection, know that it goes to the archive — retrieve it via Inspector.ListArchivedTasks rather than expecting it in pending.
  3. For failures that are transient, return a plain error instead of SkipRetry so asynq's retry policy applies.
  4. Implement an ErrorHandler (via asynq.Config ErrorHandler) to log/track tasks that hit SkipRetry.

Example fix

// before
if resp.StatusCode >= 500 {
    return fmt.Errorf("upstream failed: %v", asynq.SkipRetry) // wrong wrapping; retries anyway
}
// after
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
    return fmt.Errorf("permanent client error %d: %w", resp.StatusCode, asynq.SkipRetry) // archived, no retry
}
Defensive patterns

Strategy: try-catch

Validate before calling

// validate input before returning errors in the handler
if !json.Valid(task.Payload()) { return fmt.Errorf("invalid payload: %w", asynq.SkipRetry) }

Type guard

func isSkipRetry(err error) bool { return errors.Is(err, asynq.SkipRetry) }

Try / catch

if err := handler.ProcessTask(ctx, task); err != nil {
    if errors.Is(err, asynq.SkipRetry) {
        log.Printf("task %s archived (no retry): %v", task.ID(), err)
    }
    return err
}

Prevention

When it happens

Trigger: A Handler.ProcessTask implementation returns SkipRetry (optionally wrapped, e.g. fmt.Errorf("...: %w", SkipRetry)) after a failure that retrying cannot fix, and the processor's handleFailedMessage then archives the task.

Common situations: Handlers encountering permanent/input errors (malformed payload, 4xx upstream responses, validation failures) where retries would only burn cycles; developers who return it but then can't find the task in the queue and don't realize it landed in the archived/dead state; wrapping it incorrectly with %v instead of %w, which silently disables the errors.Is check and causes normal retries instead.

Related errors


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

Appendix: source

Thrown at processor.go:329

	ctx, cancel := context.WithDeadline(context.Background(), l.Deadline())
	defer cancel()
	err := p.broker.Done(ctx, msg)
	if err != nil {
		errMsg := fmt.Sprintf("Could not remove task id=%s type=%q from %q err: %+v", msg.ID, msg.Type, base.ActiveKey(msg.Queue), err)
		p.logger.Warnf("%s; Will retry syncing", errMsg)
		p.syncRequestCh <- &syncRequest{
			fn: func() error {
				return p.broker.Done(ctx, msg)
			},
			errMsg:   errMsg,
			deadline: l.Deadline(),
		}
	}
}

// SkipRetry is used as a return value from Handler.ProcessTask to indicate that
// the task should not be retried and should be archived instead.
var SkipRetry = errors.New("skip retry for the task")

// RevokeTask is used as a return value from Handler.ProcessTask to indicate that
// the task should not be retried or archived.
var RevokeTask = errors.New("revoke task")

func (p *processor) handleFailedMessage(ctx context.Context, l *base.Lease, msg *base.TaskMessage, err error) {
	if p.errHandler != nil {
		p.errHandler.HandleError(ctx, NewTaskWithHeaders(msg.Type, msg.Payload, msg.Headers), err)
	}
	switch {
	case errors.Is(err, RevokeTask):
		p.logger.Warnf("revoke task id=%s", msg.ID)
		p.markAsDone(l, msg)
	case msg.Retried >= msg.Retry || errors.Is(err, SkipRetry):
		p.logger.Warnf("Retry exhausted for task id=%s", msg.ID)
		p.archive(l, msg, err)
	default:
		p.retry(l, msg, err, p.isFailureFunc(err))

View on GitHub (pinned to d135f1439b)