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 preciseView on GitHub (pinned to 71377f2769)
Solutions
- Confirm the issue ID exists (e.g. `bd show <id>`) before claiming.
- Handle the error with errors.Is(err, publicops.ErrNotFound) and treat it as a benign miss.
- 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
- Always errors.Is against publicops.ErrNotFound, never string-match messages.
- Treat not-found in polling loops as skip, not failure.
- Verify IDs exist before long-running claim loops.
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.