gastownhall/beads · error

%w: issue %s

Error message

%w: issue %s

What it means

After ClaimIssue succeeds but returns a nil issue, the claimer normalizes the miss to a wrapped publicops.ErrNotFound. This covers the shape where the use case signals 'not found' by returning (nil, nil) instead of an error, preventing a nil dereference downstream. The wrapped message is `issue <id>`, joined to the ErrNotFound sentinel with %w so callers can errors.Is against it.

Source

Thrown at internal/storage/uow/issue_claimer.go:71

	if request.Actor == "" || request.IssueID == "" {
		return publicops.ClaimResult{}, validationError(fmt.Errorf("claim: actor and issue ID must not be empty"))
	}
	return RunTxResult(ctx, c.provider, func(ctx context.Context, uw UnitOfWork) (publicops.ClaimResult, string, error) {
		uc := uw.IssueUseCase()
		claimed, err := uc.ClaimIssue(ctx, request.IssueID, request.Actor)
		if err != nil {
			return publicops.ClaimResult{}, "", classifyClaimError(ctx, uc, request.IssueID, err)
		}
		// Read back INSIDE this transaction, so the result describes the row
		// this CAS wrote and not a later writer's.
		issue, err := uc.GetIssue(ctx, request.IssueID)
		if err != nil {
			return publicops.ClaimResult{}, "", err
		}
		if issue == nil {
			// A miss with a nil error is the other shape a not-found takes at
			// this seam; normalize it rather than dereferencing nil.
			return publicops.ClaimResult{}, "", fmt.Errorf("%w: issue %s", publicops.ErrNotFound, request.IssueID)
		}
		if claimed.AlreadyClaimed {
			// The idempotent re-claim: the CAS matched no row because there
			// was nothing to change. An empty commit message tells
			// RunTxResult to skip the commit, so a polling caller cannot mint
			// an empty storage commit per call.
			return publicops.ClaimResult{Issue: issue}, "", nil
		}
		return publicops.ClaimResult{Issue: issue, Changed: true}, storageissueops.ClaimCommitMessage(request.IssueID, request.Actor), nil
	})
}

// classifyClaimError normalizes what a lost or impossible claim reports.
//
// A refusal gains the assignee and status read in THIS transaction, so a
// caller classifies the conflict from typed fields instead of matching
// substrings in the message; when that read fails the refusal stands
// unadorned, because reporting the read's failure would replace a precise

View on GitHub (pinned to 71377f2769)

Solutions

  1. Confirm the issue ID exists (e.g. `bd show <id>`) before claiming.
  2. Handle the error with errors.Is(err, publicops.ErrNotFound) and treat it as a benign miss.
  3. If polling, remove the issue from the work set when ErrNotFound is returned.

Example fix

// before
res, err := c.Claim(ctx, req)
if err != nil { return err } // assumes all errors are fatal
// after
if errors.Is(err, publicops.ErrNotFound) { return nil // issue gone, skip }
return err
Defensive patterns

Strategy: type-guard

Validate before calling

if _, err := uow.GetIssue(ctx, id); errors.Is(err, publicops.ErrNotFound) { return nil }

Type guard

func isNotFound(err error) bool { return errors.Is(err, publicops.ErrNotFound) }

Try / catch

if err != nil {
    if errors.Is(err, publicops.ErrNotFound) { return nil // benign miss }
    return err
}

Prevention

When it happens

Trigger: Claiming (or checking) an issue ID that does not exist, where the underlying use case returns a nil issue with a nil error.

Common situations: Stale ID cached from another machine after the issue was deleted; typo in the issue ID; polling loop running after the issue was closed and reaped.

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 gastownhall/beads@71377f2769 (2026-08-30). Data as JSON: /api/errors/02dcb2f4825c9643. Report an issue: GitHub.