Skip to content

feat: fix errors.Is chain, bounded NotifyAsync, Newf/Wrapf#11

Merged
ankurs merged 5 commits intomainfrom
feat/improvements
Mar 22, 2026
Merged

feat: fix errors.Is chain, bounded NotifyAsync, Newf/Wrapf#11
ankurs merged 5 commits intomainfrom
feat/improvements

Conversation

@ankurs
Copy link
Copy Markdown
Member

@ankurs ankurs commented Mar 22, 2026

Summary

  • Fix errors.Is/errors.As chain: Unwrap() now returns the immediate parent error instead of jumping to root cause. Cause() still returns root for backward compatibility. This fixes errors.Is(doubleWrapped, singleWrapped) returning false.
  • Bounded async notifications: Added NotifyAsync() with a semaphore (default cap 1000) to prevent goroutine explosion under sustained error bursts. SetMaxAsyncNotifications(n) for tuning.
  • Stack depth cap: generateStack now caps at 64 frames (configurable via SetMaxStackDepth) to reduce GC pressure.
  • Convenience functions: Added Newf(format, args...) and Wrapf(err, format, args...).

Test plan

  • Existing tests pass (make test -race)
  • New tests: TestErrorsIs, TestCauseStillReturnsRoot, TestNewf, TestWrapf, TestStackDepthCapped
  • Lint clean

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Stack capture is capped at 64 frames by default (configurable via SetMaxStackDepth).
    • Added formatted error helpers for creating and wrapping errors (Newf, Wrapf).
    • Introduced bounded asynchronous notifications with a default concurrency limit of 1,000 (configurable via SetMaxAsyncNotifications / NotifyAsync).
  • Behavior Changes

    • Error unwrapping now follows the immediate wrapper, enabling standard errors.Is/errors.As traversal through wrap layers.

- Fix Unwrap() to return immediate parent instead of root cause,
  so errors.Is/errors.As traverses the full wrap chain. Cause()
  still returns root for backward compatibility.
- Cap stack depth at 64 frames (configurable via SetMaxStackDepth)
  to reduce GC pressure under high error rates.
- Add NotifyAsync with bounded semaphore (default 1000) to prevent
  goroutine explosion under sustained error bursts.
- Add SetMaxAsyncNotifications for tuning the async pool size.
- Add Newf and Wrapf convenience functions for formatted errors.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 22, 2026

Warning

Rate limit exceeded

@ankurs has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 12 minutes and 55 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e8c59a5e-4883-447c-9606-f8dda22a6afc

📥 Commits

Reviewing files that changed from the base of the PR and between feaef65 and 9bc73c2.

📒 Files selected for processing (3)
  • errors.go
  • errors_test.go
  • notifier/notifier.go
📝 Walkthrough

Walkthrough

Adds configurable stack-depth and formatted helpers to the error package, changes Unwrap semantics to follow immediate wrappers, and introduces a bounded, drop-on-full async notification mechanism with a semaphore and helper to configure its capacity.

Changes

Cohort / File(s) Summary
Error Chain & Stack Management
errors.go
Added wrapped field to customError and made Unwrap() return it. Introduced maxStackDepth (default 64) and capped stack capture; added SetMaxStackDepth(int), Newf(format string, ...any), and Wrapf(err error, format string, ...any).
Error Tests
errors_test.go
Updated imports to alias std errors, removed loop shadowing, and added tests for errors.Is propagation, Cause() backward-compatibility, Newf/Wrapf formatting and matching, and default stack-depth capping.
Async Notification Control
notifier/notifier.go
Added package semaphore asyncSem (buffered channel, default cap 1000) and SetMaxAsyncNotifications(int). Added NotifyAsync(err, ...) which returns immediately and schedules Notify only when a semaphore slot is acquired; updated NotifyWithExclude to use this bounded/drop-on-full behavior.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant NotifyAsync
    participant Semaphore as Semaphore<br/>(capacity: 1000)
    participant Notify

    rect rgba(100, 200, 100, 0.5)
    Note over Caller,Notify: Success Path — slot available
    Caller->>NotifyAsync: NotifyAsync(err, rawData...)
    NotifyAsync->>Semaphore: try acquire slot (non-blocking)
    Semaphore-->>NotifyAsync: slot granted
    NotifyAsync->>Notify: call Notify(err, rawData...)
    Notify-->>NotifyAsync: completes
    NotifyAsync->>Semaphore: release slot
    NotifyAsync-->>Caller: return err
    end

    rect rgba(200, 100, 100, 0.5)
    Note over Caller,Semaphore: Failure Path — semaphore full
    Caller->>NotifyAsync: NotifyAsync(err, rawData...)
    NotifyAsync->>Semaphore: try acquire slot (non-blocking)
    Semaphore-->>NotifyAsync: no slot available
    NotifyAsync-->>Caller: silently drop, return err
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I stitched the stacks and wrapped each thread with care,
A cap at sixty-four keeps hops light in the air.
Async carrots queued, but dropped when the bin is full,
Semaphores stand watch — patient, calm, and pluck-it-true.
Newf and Wrapf sing tidy lines, neat as a burrowed tune.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 56.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and accurately summarizes the three main features added in this PR: fixing errors.Is chain traversal, implementing bounded async notifications, and adding Newf/Wrapf helpers.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/improvements

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (4)
errors_test.go (1)

107-113: Test doesn't stress the stack depth cap.

This test verifies the invariant (<= 64) but doesn't actually exercise the capping logic since a typical call stack is far shallower. Consider adding a test with recursive calls to exceed the default limit and confirm truncation occurs.

💡 Optional: Add a stress test for deep stacks
func TestStackDepthCappedDeep(t *testing.T) {
	var createDeepError func(depth int) ErrorExt
	createDeepError = func(depth int) ErrorExt {
		if depth == 0 {
			return New("deep error")
		}
		return createDeepError(depth - 1)
	}
	
	// Create error from a stack deeper than 64
	err := createDeepError(100)
	if len(err.StackFrame()) > 64 {
		t.Errorf("stack depth %d exceeds max 64", len(err.StackFrame()))
	}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@errors_test.go` around lines 107 - 113, The current TestStackDepthCapped only
checks the <=64 invariant but doesn't trigger the capping logic; modify or add a
new test (e.g., TestStackDepthCappedDeep) that uses a recursive helper to call
New("...") from a stack deeper than 64 and then assert that
len(err.StackFrame()) <= 64 (and optionally that frames were truncated rather
than omitted) so the capping behavior in New and StackFrame is exercised;
reference the existing TestStackDepthCapped, New, and StackFrame symbols when
locating where to add the recursive test.
notifier/notifier.go (2)

55-69: Consider logging or emitting a metric when notifications are dropped.

When the semaphore is full (lines 65-67), notifications are silently dropped. This could make it difficult to diagnose why certain errors weren't reported during sustained bursts. A debug-level log or a counter metric would aid observability without impacting performance.

💡 Suggested improvement
 	select {
 	case asyncSem <- struct{}{}:
 		go func() {
 			defer func() { <-asyncSem }()
 			_ = Notify(err, rawData...)
 		}()
 	default:
 		// drop notification to prevent goroutine explosion
+		log.Debug(context.Background(), "msg", "async notification dropped due to capacity", "err", err)
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@notifier/notifier.go` around lines 55 - 69, NotifyAsync currently drops
notifications silently when the semaphore asyncSem is full; modify the default
branch in NotifyAsync to record the drop (e.g., emit a non-blocking metric
counter and/or write a debug-level log) so lost notifications are observable
without blocking or changing behavior of asyncSem. Locate the NotifyAsync
function and update the default case to increment a drop counter (e.g.,
notifyDropCounter.Inc()) and/or call a debug logger (e.g., logger.Debugf or
processLogger.Debug) in a non-blocking way, ensuring the added instrumentation
cannot block or cause errors if metrics/logging systems are unavailable.

41-49: Potential race if called after initialization.

Replacing asyncSem while goroutines may be operating on the old channel is unsafe. The docstring warns "Must be called during initialization," but there's no enforcement. If a caller ignores this, goroutines holding slots on the old channel will never release them to the new one.

Consider documenting more explicitly or using sync.Once to prevent re-initialization.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@notifier/notifier.go` around lines 41 - 49, The SetMaxAsyncNotifications
function currently replaces the package-level asyncSem channel which is unsafe
if called after goroutines have started; change it so the semaphore is only
initialized once (e.g., use a package-level sync.Once like asyncSemInit.Do(...)
or return/panic if already initialized) and document that behavior; reference
the SetMaxAsyncNotifications function and asyncSem symbol when making the change
so callers cannot reinitialize asyncSem at runtime and running goroutines won’t
be left referencing a replaced channel.
errors.go (1)

231-237: Same init-time-only concern as SetMaxAsyncNotifications.

Modifying maxStackDepth is not thread-safe. The docstring warning is present, but concurrent error creation during reconfiguration could read partially updated values or cause inconsistent behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@errors.go` around lines 231 - 237, SetMaxStackDepth currently mutates
maxStackDepth unsafely; make the update thread-safe by either (A) changing
maxStackDepth to an int32/int64 and using atomic.StoreInt32/StoreInt64 in
SetMaxStackDepth (and corresponding atomic loads where it's read), or (B)
enforce init-only semantics by adding an initialization guard (e.g., a sync.Once
or an "initialized" boolean set during startup) and have SetMaxStackDepth panic
or return an error if called after initialization. Update references to
maxStackDepth accordingly and adjust reads to use atomic loads if you choose the
atomic approach; locate the function SetMaxStackDepth and the global
maxStackDepth variable to apply the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@errors_test.go`:
- Around line 107-113: The current TestStackDepthCapped only checks the <=64
invariant but doesn't trigger the capping logic; modify or add a new test (e.g.,
TestStackDepthCappedDeep) that uses a recursive helper to call New("...") from a
stack deeper than 64 and then assert that len(err.StackFrame()) <= 64 (and
optionally that frames were truncated rather than omitted) so the capping
behavior in New and StackFrame is exercised; reference the existing
TestStackDepthCapped, New, and StackFrame symbols when locating where to add the
recursive test.

In `@errors.go`:
- Around line 231-237: SetMaxStackDepth currently mutates maxStackDepth
unsafely; make the update thread-safe by either (A) changing maxStackDepth to an
int32/int64 and using atomic.StoreInt32/StoreInt64 in SetMaxStackDepth (and
corresponding atomic loads where it's read), or (B) enforce init-only semantics
by adding an initialization guard (e.g., a sync.Once or an "initialized" boolean
set during startup) and have SetMaxStackDepth panic or return an error if called
after initialization. Update references to maxStackDepth accordingly and adjust
reads to use atomic loads if you choose the atomic approach; locate the function
SetMaxStackDepth and the global maxStackDepth variable to apply the change.

In `@notifier/notifier.go`:
- Around line 55-69: NotifyAsync currently drops notifications silently when the
semaphore asyncSem is full; modify the default branch in NotifyAsync to record
the drop (e.g., emit a non-blocking metric counter and/or write a debug-level
log) so lost notifications are observable without blocking or changing behavior
of asyncSem. Locate the NotifyAsync function and update the default case to
increment a drop counter (e.g., notifyDropCounter.Inc()) and/or call a debug
logger (e.g., logger.Debugf or processLogger.Debug) in a non-blocking way,
ensuring the added instrumentation cannot block or cause errors if
metrics/logging systems are unavailable.
- Around line 41-49: The SetMaxAsyncNotifications function currently replaces
the package-level asyncSem channel which is unsafe if called after goroutines
have started; change it so the semaphore is only initialized once (e.g., use a
package-level sync.Once like asyncSemInit.Do(...) or return/panic if already
initialized) and document that behavior; reference the SetMaxAsyncNotifications
function and asyncSem symbol when making the change so callers cannot
reinitialize asyncSem at runtime and running goroutines won’t be left
referencing a replaced channel.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 440c9ea7-8c1a-40d0-8466-7d8729a9c598

📥 Commits

Reviewing files that changed from the base of the PR and between 66ae03b and 308d818.

📒 Files selected for processing (3)
  • errors.go
  • errors_test.go
  • notifier/notifier.go

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the library’s error wrapping and notification behavior to better integrate with Go’s standard errors.Is/errors.As semantics while improving operational safety under high error volume.

Changes:

  • Fix errors.Is/errors.As traversal by making Unwrap() return the immediate wrapped error while keeping Cause() as the root error.
  • Add bounded async error notification via NotifyAsync() with configurable concurrency (SetMaxAsyncNotifications).
  • Cap captured stack depth (default 64) via SetMaxStackDepth, and add formatted helpers Newf/Wrapf.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
notifier/notifier.go Adds bounded async notification API and routes NotifyWithExclude through it.
errors.go Adjusts unwrap chain semantics, adds stack depth cap + setters, introduces Newf/Wrapf.
errors_test.go Adds regression tests for errors.Is chain, Cause() behavior, formatting helpers, and stack cap.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread notifier/notifier.go Outdated
If SetMaxAsyncNotifications reassigns asyncSem while goroutines are
in-flight, the deferred release would target the new channel. Snapshot
the semaphore into a local variable so acquire and release always use
the same channel instance.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread errors.go Outdated
When wrapping an ErrorExt that doesn't implement NotifyExt,
shouldNotify was left at zero value (false), silently skipping
error notifications. Default to true and override from NotifyExt
if present.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
notifier/notifier.go (1)

66-68: Surface dropped notifications somehow.

Once the semaphore fills, notifications vanish silently. A counter or hook here would make saturation visible and let operators tune the cap before incidents start disappearing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@notifier/notifier.go` around lines 66 - 68, The select default branch in
notifier.go currently drops notifications silently; modify the notifier's drop
path (the select default in the send/notify routine) to surface dropped
notifications by incrementing a concurrent-safe counter/metric and/or invoking a
configurable hook (e.g., an OnDrop callback or emit to an existing metrics
client) so operators can detect saturation; update the notifier struct (add a
dropCounter or OnDrop func) and use it inside the default branch so every
dropped notification either increments the metric or calls the hook (ensure the
counter is safe for concurrent access or use the project's metrics API).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@notifier/notifier.go`:
- Around line 45-48: SetMaxAsyncNotifications currently assigns to asyncSem
unsafely while NotifyAsync reads it; prevent races by making
SetMaxAsyncNotifications idempotent and write-once: add a package-level
sync.Once (e.g., setMaxAsyncOnce) and wrap the asyncSem = make(chan struct{}, n)
assignment inside setMaxAsyncOnce.Do(func(){...}), returning immediately if Do
has already run; keep NotifyAsync's read unchanged (it will then never race with
subsequent writes), and ensure any tests or callers treat
SetMaxAsyncNotifications as an init-only operation.
- Around line 60-65: The goroutine launched in NotifyAsync captures the rawData
slice by reference which can cause races if the caller mutates the backing array
after returning; fix by making a shallow copy of rawData in the successful
acquire path (e.g., before starting the goroutine in the select branch) and pass
that copied slice into the closure that calls Notify so the goroutine reads an
immutable snapshot; keep the semaphore handling (sem) as-is but ensure the
goroutine parameter list receives the copied slice and the semaphore channel
(e.g., pass sem and copiedRawData into the anonymous func) so no shared mutable
slice is accessed concurrently.

---

Nitpick comments:
In `@notifier/notifier.go`:
- Around line 66-68: The select default branch in notifier.go currently drops
notifications silently; modify the notifier's drop path (the select default in
the send/notify routine) to surface dropped notifications by incrementing a
concurrent-safe counter/metric and/or invoking a configurable hook (e.g., an
OnDrop callback or emit to an existing metrics client) so operators can detect
saturation; update the notifier struct (add a dropCounter or OnDrop func) and
use it inside the default branch so every dropped notification either increments
the metric or calls the hook (ensure the counter is safe for concurrent access
or use the project's metrics API).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7effed0b-c78c-440e-b7ea-852ff47b5b83

📥 Commits

Reviewing files that changed from the base of the PR and between 308d818 and feaef65.

📒 Files selected for processing (1)
  • notifier/notifier.go

Comment thread notifier/notifier.go
Comment thread notifier/notifier.go Outdated
…tifications

- Add docstrings to all customError methods for coverage threshold
- Add TestStackDepthCappedDeep with recursive calls exceeding 64 frames
  to exercise the capping logic
- Log at debug level when async notifications are dropped due to
  semaphore capacity, aiding observability during sustained bursts
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

…wData slice

- Use sync.Once to enforce write-once semantics for asyncSem,
  preventing races if SetMaxAsyncNotifications is called post-init.
- Copy rawData slice before launching goroutine in NotifyAsync to
  prevent data races if the caller mutates the backing array after
  NotifyAsync returns.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread notifier/notifier.go
Comment thread errors.go
@ankurs ankurs merged commit 36a0089 into main Mar 22, 2026
12 checks passed
@ankurs ankurs deleted the feat/improvements branch March 22, 2026 14:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants