Skip to content

Conversation

@cnlangzi
Copy link
Owner

@cnlangzi cnlangzi commented Jan 11, 2026

Summary by Sourcery

Expose rate limit decisions with machine-readable reasons for blocked requests while keeping existing behavior for allowed traffic.

New Features:

  • Add a Reason type and constants to represent why a request was rate limited or blocked.
  • Extend Limiter.Allow and Limiter.Wait to return a reason alongside the existing allow/error result.

Enhancements:

  • Update callers and tests to handle the new Allow and Wait return values and verify fake-bot vs behavior-based blocking semantics.
  • Improve verified-bot tests by using a temporary knownbots configuration for custom bot verification.

Build:

  • Bump knownbots dependency from v1.0.4 to v1.0.5.

- Allow() returns (allowed, reason) instead of bool
- Wait() returns (err, reason) instead of error
- Add Reason type: fake_bot, rate_limited
- Update knownbots to v1.0.5
@sourcery-ai
Copy link

sourcery-ai bot commented Jan 11, 2026

Reviewer's Guide

Adds structured rate-limit reasons to Limiter.Allow and Limiter.Wait while updating callers/tests to handle the new return values and upgrading the knownbots dependency.

Sequence diagram for Limiter.Allow with rate limit reasons

sequenceDiagram
    actor Client
    participant Limiter
    participant KnownBots as KnownBotsValidator
    participant Analyzer

    Client->>Limiter: Allow(ua, ip) returns (allowed, reason)
    Limiter->>KnownBots: Validate(ua, ip)
    KnownBots-->>Limiter: botResult

    alt Bot verification enabled
        alt botResult.Status == StatusVerified
            Limiter-->>Client: true, ""
        else botResult.Status == StatusPending
            Limiter-->>Client: true, ""
        else botResult.Status == StatusFailed or StatusUnknown
            Limiter-->>Client: false, ReasonFakeBot
        end
    else Bot verification disabled
        alt Analyzer.Blocked(ip) == true
            Limiter->>Limiter: allowBlocked(ip)
            alt allowBlocked returns true
                Limiter-->>Client: true, ""
            else allowBlocked returns false
                Limiter-->>Client: false, ReasonRateLimited
            end
        else Analyzer.Blocked(ip) == false
            Limiter->>Analyzer: Record(ip, ua)
            Limiter-->>Client: true, ""
        end
    end
Loading

Sequence diagram for Limiter.Wait with rate limit reasons

sequenceDiagram
    actor Client
    participant Limiter
    participant KnownBots as KnownBotsValidator
    participant Analyzer

    Client->>Limiter: Wait(ctx, ua, ip) returns (err, reason)
    Limiter->>KnownBots: Validate(ua, ip)
    KnownBots-->>Limiter: botResult

    alt Bot verification enabled
        alt botResult.Status == StatusVerified
            Limiter-->>Client: nil, ""
        else botResult.Status == StatusPending
            Limiter-->>Client: nil, ""
        else botResult.Status == StatusFailed or StatusUnknown
            Limiter-->>Client: ErrLimit, ReasonFakeBot
        end
    else Bot verification disabled
        alt Analyzer.Blocked(ip) == true
            Limiter->>Limiter: waitBlocked(ctx, ip)
            Limiter-->>Limiter: err
            alt err == nil
                Limiter-->>Client: nil, ""
            else err != nil
                Limiter-->>Client: ErrLimit, ReasonRateLimited
            end
        else Analyzer.Blocked(ip) == false
            Limiter->>Analyzer: Record(ip, ua)
            Limiter-->>Client: nil, ""
        end
    end
Loading

Updated class diagram for Limiter with Reason type

classDiagram
    class Reason {
        <<enumeration>>
        +string value
    }

    class ReasonFakeBot {
    }

    class ReasonRateLimited {
    }

    ReasonFakeBot --|> Reason
    ReasonRateLimited --|> Reason

    class Limiter {
        -Config cfg
        -KnownBotsValidator kb
        -Analyzer analyzer
        +New(opts Option) Limiter
        +Allow(ua string, ip string) (allowed bool, reason Reason)
        +Wait(ctx context.Context, ua string, ip string) (err error, reason Reason)
        -allowBlocked(ip string) bool
        -waitBlocked(ctx context.Context, ip string) error
    }

    class KnownBotsValidator {
        +Validate(ua string, ip string) BotResult
    }

    class Analyzer {
        +Blocked(ip string) bool
        +Record(ip string, ua string) void
    }

    class BotResult {
        +Status status
    }

    class status {
    }

    Limiter --> KnownBotsValidator : uses
    Limiter --> Analyzer : uses
    KnownBotsValidator --> BotResult : returns
    BotResult --> status : has
    Limiter --> Reason : returns
    ReasonFakeBot ..> Limiter : used_as_reason
    ReasonRateLimited ..> Limiter : used_as_reason
Loading

File-Level Changes

Change Details Files
Introduce structured rate-limit reasons and plumb them through Allow/Wait APIs.
  • Define Reason type and constants ReasonFakeBot and ReasonRateLimited to describe why a request is blocked.
  • Change Limiter.Allow signature to return (allowed bool, reason Reason) and set reasons for fake/unknown bots and behavior-based rate limiting while returning empty reason for allowed requests.
  • Change Limiter.Wait signature to return (err error, reason Reason), returning ErrLimit with ReasonFakeBot for failed/unknown bots and ErrLimit with ReasonRateLimited for behavior-based rate limiting, while preserving nil errors and empty reasons for allowed requests.
limiter.go
Update tests and example code to use the new Allow/Wait signatures and add coverage for rate-limit reasons with verified/fake bots.
  • Extend TestLimiter_Allow_VerifiedBot to create a temporary knownbots config, inject it via WithKnownbots, and assert both allowed flag and ReasonFakeBot for fake-bot IPs and empty reason for verified IPs.
  • Adjust all existing tests that call Allow to destructure the (allowed, reason) results, ignoring reason where not asserted, and similarly update Wait call sites in tests to handle the new (err, reason) return signature.
  • Update example HTTP handler to use the new Allow return signature while keeping behavior unchanged.
botrate_test.go
example/main.go
Upgrade knownbots dependency to latest minor version used for improved bot verification behavior.
  • Bump github.com/cnlangzi/knownbots from v1.0.4 to v1.0.5 in go.mod and sync go.sum entries.
go.mod
go.sum

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@github-actions
Copy link

Benchmark Results


Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 3 issues, and left some high level feedback:

  • In Limiter.Wait, the new (error, Reason) signature currently maps any non-nil error from waitBlocked to ErrLimit, which loses important context like context.Canceled/context.DeadlineExceeded; consider returning the original context error (with an empty reason) instead of always collapsing to ErrLimit so callers can still distinguish cancellation from rate limiting.
  • For consistency and clarity, consider introducing an explicit ReasonNone (or similar) constant instead of returning the empty string "" for the success case in Allow/Wait, so callers don't have to rely on an implicit empty-value convention.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `Limiter.Wait`, the new `(error, Reason)` signature currently maps any non-nil error from `waitBlocked` to `ErrLimit`, which loses important context like `context.Canceled`/`context.DeadlineExceeded`; consider returning the original context error (with an empty reason) instead of always collapsing to `ErrLimit` so callers can still distinguish cancellation from rate limiting.
- For consistency and clarity, consider introducing an explicit `ReasonNone` (or similar) constant instead of returning the empty string `""` for the success case in `Allow`/`Wait`, so callers don't have to rely on an implicit empty-value convention.

## Individual Comments

### Comment 1
<location> `limiter.go:120` </location>
<code_context>
+// Returns:
+//   - err: nil if allowed, ErrLimit if blocked, or context error if canceled
+//   - reason: the reason for blocking when err is ErrLimit
+func (l *Limiter) Wait(ctx context.Context, ua, ip string) (err error, reason Reason) {
 	// Layer 1: Bot verification
 	botResult := l.kb.Validate(ua, ip)
</code_context>

<issue_to_address>
**issue (bug_risk):** Context errors from waitBlocked are being masked as ErrLimit, losing the actual cancellation/timeout cause.

The updated `Wait` always wraps any non-nil `waitBlocked` error as `ErrLimit`/`ReasonRateLimited`, which conflicts with the documented behavior (“ErrLimit if blocked, or context error if canceled”) and hides whether the wait ended due to `context.Canceled` / `DeadlineExceeded`.

If `waitBlocked` can return context errors, they should still be propagated directly. For example:

```go
if l.analyzer.Blocked(ip) {
    err = l.waitBlocked(ctx, ip)
    if err == nil {
        return nil, ""
    }
    if errors.Is(err, ErrLimit) {
        return ErrLimit, ReasonRateLimited
    }
    // For context cancellation / timeout, propagate original error
    return err, ""
}
```

This keeps the previous context-error behavior while still tagging genuine rate-limit errors with a reason.
</issue_to_address>

### Comment 2
<location> `botrate_test.go:100-109` </location>
<code_context>

-	result := l.Allow("Googlebot/2.1", "66.249.66.1")
-	_ = result
+	allowed, reason := l.Allow("TestBot/1.0", "192.168.100.42")
+	if !allowed {
+		t.Error("verified bot should be allowed")
+	}
+	if reason != "" {
+		t.Errorf("reason should be empty for allowed request, got %s", reason)
+	}
+
+	allowed, reason = l.Allow("TestBot/1.0", "10.0.0.1")
+	if allowed {
+		t.Error("fake bot should be blocked")
+	}
+	if reason != ReasonFakeBot {
+		t.Errorf("expected reason %s, got %s", ReasonFakeBot, reason)
+	}
</code_context>

<issue_to_address>
**suggestion (testing):** Add complementary Wait() tests that assert `reason` for verified and fake bots

To fully exercise the new `reason` return, please add analogous `Wait()` tests:

- `TestLimiter_Wait_VerifiedBotReason`: call `Wait()` with a verified bot and assert `err == nil` and `reason == ""`.
- `TestLimiter_Wait_FakeBotReason`: call `Wait()` with a fake/invalid bot (as in your second `Allow()` case) and assert `err == ErrLimit` and `reason == ReasonFakeBot`.

Without these, `Wait()`'s new `reason` behavior isn’t covered and regressions there would go unnoticed.

Suggested implementation:

```golang
	allowed, reason := l.Allow("TestBot/1.0", "192.168.100.42")
	if !allowed {
		t.Error("verified bot should be allowed")
	}
	if reason != "" {
		t.Errorf("reason should be empty for allowed request, got %s", reason)
	}

	// Verify Wait() behavior for a verified bot
	ctx := context.Background()
	waitReason, err := l.Wait(ctx, "TestBot/1.0", "192.168.100.42")
	if err != nil {
		t.Errorf("verified bot should not be rate limited, got err: %v", err)
	}
	if waitReason != "" {
		t.Errorf("reason should be empty for allowed Wait request, got %s", waitReason)
	}

	allowed, reason = l.Allow("TestBot/1.0", "10.0.0.1")
	if allowed {
		t.Error("fake bot should be blocked")
	}
	if reason != ReasonFakeBot {
		t.Errorf("expected reason %s, got %s", ReasonFakeBot, reason)
	}

	// Verify Wait() behavior for a fake/invalid bot
	waitReason, err = l.Wait(ctx, "TestBot/1.0", "10.0.0.1")
	if !errors.Is(err, ErrLimit) {
		t.Errorf("fake bot should be rate limited with ErrLimit, got: %v", err)
	}
	if waitReason != ReasonFakeBot {
		t.Errorf("expected Wait reason %s, got %s", ReasonFakeBot, waitReason)
	}

```

1. Ensure `botrate_test.go` imports the required packages if they are not already present:
   - `context`
   - `errors`
   For example, in the import block, add:
   ```go
   import (
       "context"
       "errors"
       // existing imports...
   )
   ```
2. If the actual signature of `Wait` differs (e.g., it returns `(error, string)` instead of `(string, error)`), adjust the assignment order accordingly, e.g. `err, waitReason := l.Wait(...)` and keep the same assertions on `err` and `waitReason`.
3. If you strictly need separate top-level tests named `TestLimiter_Wait_VerifiedBotReason` and `TestLimiter_Wait_FakeBotReason`, factor the shared setup (creating `kb`, `l`, and the custom bot config) into a helper and call that helper from two new `Test...` functions that each perform the corresponding `Wait()` assertion.
</issue_to_address>

### Comment 3
<location> `botrate_test.go:102-105` </location>
<code_context>
 	ctx, cancel := context.WithCancel(context.Background())
 	cancel()

-	err = l.Wait(ctx, "Mozilla/5.0", "192.168.1.1")
+	err, _ = l.Wait(ctx, "Mozilla/5.0", "192.168.1.1")

 	if err != nil && err != context.Canceled && err != ErrLimit {
</code_context>

<issue_to_address>
**suggestion (testing):** Add a dedicated test to verify `reason` when Wait() blocks due to rate limiting

The new `ReasonRateLimited` path still isn’t covered via `Wait()`. Current tests either don’t check the reason or don’t cause the behavior-analysis layer to block. Please add a `Wait`-side test (e.g., `TestLimiter_Wait_RateLimitedReason`) that:

1. Forces the analyzer to block for a given IP.
2. Calls `Wait(ctx, ua, ip)` for that IP.
3. Asserts the blocking error (e.g., `ErrLimit`) and `reason == ReasonRateLimited`.

This will complement the `Allow`-side coverage (`TestLimiter_Allow_BlacklistedIP`) and verify both paths return the correct reason when rate limiting occurs.

Suggested implementation:

```golang
	if !allowed {
		t.Error("normal user should be allowed")
	}
}

func TestLimiter_Wait_RateLimitedReason(t *testing.T) {
	// Construct a limiter instance using the same helper / pattern as other tests.
	l := newLimiterForTest(t) // adjust to match existing test setup
	defer l.Close()

	ua := "Mozilla/5.0"
	ip := "203.0.113.10"

	// Drive the limiter into a rate-limited state for this IP via Allow(),
	// mirroring real behavior-analysis behavior rather than touching internals.
	var (
		allowed bool
		reason  Reason
	)
	for i := 0; i < 1000; i++ { // upper bound chosen to exceed any reasonable threshold
		allowed, reason = l.Allow(ua, ip)
		if !allowed {
			break
		}
	}

	if allowed {
		t.Fatalf("expected IP %s to become rate limited via Allow(), but it never did", ip)
	}
	if reason != ReasonRateLimited {
		t.Fatalf("expected ReasonRateLimited from Allow() when IP %s is blocked, got %v", ip, reason)
	}

	ctx := context.Background()
	err, waitReason := l.Wait(ctx, ua, ip)

	if err != ErrLimit {
		t.Fatalf("expected ErrLimit from Wait() for rate-limited IP %s, got %v", ip, err)
	}
	if waitReason != ReasonRateLimited {
		t.Fatalf("expected ReasonRateLimited from Wait(); got %v", waitReason)
	}

```

1. Replace `newLimiterForTest(t)` with the actual limiter-construction pattern used in the rest of `botrate_test.go` (e.g., a helper or direct call such as `NewLimiter(testConfig)`).
2. Ensure that the `Reason` type and `ReasonRateLimited` constant are imported / referenced correctly in this file (if they are defined in another package, prefix them appropriately, e.g., `botrate.ReasonRateLimited`).
3. If the existing tests already use a different user agent or IP pattern for triggering rate limiting, you may want to reuse those values instead of `"Mozilla/5.0"` / `"203.0.113.10"` to keep tests consistent.
4. If the limiter has a very high threshold and the loop bound `1000` is insufficient to trigger rate limiting in your configuration, either lower the configured threshold for tests or increase the loop bound so that the IP is reliably driven into the rate-limited state.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@codecov
Copy link

codecov bot commented Jan 11, 2026

Codecov Report

❌ Patch coverage is 47.36842% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.74%. Comparing base (875773b) to head (ea83579).
⚠️ Report is 8 commits behind head on main.

Files with missing lines Patch % Lines
limiter.go 52.94% 8 Missing ⚠️
example/main.go 0.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main       #2      +/-   ##
==========================================
- Coverage   76.41%   75.74%   -0.68%     
==========================================
  Files           6        6              
  Lines         229      235       +6     
==========================================
+ Hits          175      178       +3     
- Misses         50       53       +3     
  Partials        4        4              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@cnlangzi cnlangzi merged commit be3aa2a into main Jan 11, 2026
3 of 5 checks passed
@cnlangzi cnlangzi deleted the fix/reason branch January 11, 2026 08:40
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