fix(rate_limit): reject negative cost and clamp remaining to the limit

Description

Two changes to the rate-limit evaluator so a negative counter can no longer produce headers the RateLimit spec disallows.

Negative cost fails open. Evaluator#check refuses cost < 0 before touching any rule. It raises into the existing fail-open rescue, so the request is allowed with error? true, to_response_headers is {}, nothing is written to Redis, checks_total{error="true"} is emitted once, and one WARN line is logged with message: rate_limit_error, class_name: ArgumentError, and rule: null since no rule is in flight. Previously cost: -5 on a limit: 2 rule credited quota back and reported RateLimit-Remaining: 7, and a computed cost that went negative permanently opened the window.

Remaining is clamped to the limit. A new remaining_of(limit, count) clamps headroom to [0, max(limit, 0)] and is used by both build_evaluation and build_ban_evaluation. A ban still forces 0. The ceiling is floored at zero because a callable limit can resolve to a non-positive value, which means "deny all" and must report nothing remaining rather than a negative header. Without the floor, clamp(0, -1) raises.

Both match the Go SDK since gitlab-org/labkit!668 (merged) (errNegativeCost and remainingOf), so the same input now gets the same answer from each SDK. The only difference is the error_message text for fractional costs, where Ruby prints -5.0 and Go prints -5.

Regression specs cover a negative cost at evaluator and limiter level, a counter seeded below zero on plain and ban_for rules, and a callable limit resolving below zero.

The labkit-spec scenario that sends cost=-1 is a separate MR and must merge after this one, since the conformance job here runs against labkit-spec main.

Closes gitlab-com/gl-infra/production-engineering#29761 (closed)

🤖 Generated with Claude Code

Merge request reports

Loading
Loading