Skip to content

feat: KEEP-905 Rate-limit-aware load balancing with adaptive capacity estimation#37

Open
suisuss wants to merge 12 commits into
project-aethermesh:mainfrom
suisuss:feat/KEEP-905-rate-limit-aware-load-balancing
Open

feat: KEEP-905 Rate-limit-aware load balancing with adaptive capacity estimation#37
suisuss wants to merge 12 commits into
project-aethermesh:mainfrom
suisuss:feat/KEEP-905-rate-limit-aware-load-balancing

Conversation

@suisuss

@suisuss suisuss commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Aetherlay previously reacted to rate limiting with a single, blunt signal: an HTTP 429 on
the live proxy path, at three call sites, with no way to avoid triggering a provider's
limit in the first place. This PR adds three layers that work together - reactive
detection, proactive static throttling, and adaptive learned throttling - so the load
balancer can serve every request even when one or more configured providers are rate
limited or otherwise failing, without requiring any new configuration.

Linear: KEEP-905

How rate-limit detection works (reactive)

All rate-limit detection funnels through one new function, health.DetectRateLimit
(internal/health/ratelimit.go), called at every request path (HTTP proxy, WebSocket
handshake, and both the integrated and standalone health checkers):

  • HTTP 429 - any provider.
  • HTTP 402 - Infura-specific: their documented convention for "daily credit cap
    exhausted," which can't be sped up by probing sooner (unlike a 429 burst limit), so it's
    classified separately (IsDailyQuota) and seeds a much longer recovery wait from the
    endpoint's own max_backoff, not a short guess.
  • JSON-RPC error code -32005 embedded in an HTTP 200 body - some providers (e.g.
    Alchemy on batch requests) only signal rate limiting this way, invisible to a
    status-code-only check. The HTTP proxy path now buffers and scans 2xx JSON bodies
    (bounded to 10MB to protect memory on large legitimate responses like eth_getLogs) for
    this case, and retries the same request against a different endpoint instead of
    forwarding a partial/rate-limited response to the client.
  • Retry-After header, when present, is parsed and used to seed the endpoint's
    recovery backoff precisely instead of guessing.

Once detected, handleRateLimit marks the endpoint's RateLimitState.RateLimited = true
in Valkey and kicks off the existing RateLimitScheduler, which probes the endpoint on
exponential backoff (real eth_blockNumber calls) until it's seen enough consecutive
clean responses to flip back to healthy.

How load balancing works

Every request goes through two stages, then a retry loop:

  1. Filtering (getEndpointsByRole) - for each endpoint in a role (public /
    primary / fallback), three independent gates must all pass: health status, the
    reactive RateLimited flag above, and a proactive capacity gate (below).
  2. Selection (selectBestEndpointByRole) - within the highest-priority non-empty
    role, picks the endpoint with the lowest utilization score. If every candidate has a
    resolvable capacity ceiling (static or learned), scores are utilization ratios
    (24h request count / endpoint's own ceiling projected to a 24h-equivalent budget) so
    a higher-capacity endpoint isn't penalized for carrying proportionally more traffic. If
    any candidate has no ceiling at all, this falls back to raw 24h request count (today's
    original least-used heuristic) to avoid comparing endpoints on incompatible units.
  3. Retry loop - on any failure (400 handled specially, rate limit, or generic error),
    the failing endpoint is dropped from that request's remaining candidates and retried
    against the next one, up to PROXY_MAX_RETRIES. A client-facing request only fails if
    every available endpoint in every role is exhausted.

Proactive capacity throttling (static, pre-existing shape extended here)

An endpoint can optionally declare capacity: {max_requests, window_seconds} - a
self-imposed ceiling checked via a Valkey window counter (IncrementCapacityCount /
GetCapacityCount) before every dispatch. Once an endpoint hits its configured ceiling
for the current window, it's excluded from candidates for that window - independent of
whether the provider has actually rejected anything yet.

Adaptive capacity estimation (new - no configuration required)

Since none of the major providers (Infura, Alchemy, dRPC) expose a live "remaining quota"
signal, guessing a static ceiling up front is unreliable. This PR adds a learned
alternative that requires zero config by default (CAPACITY_LEARNING_ENABLED=true):

  • Check first, then approximate: an endpoint with no static capacity is never
    proactively throttled until it has actually been rate limited once. The first hit seeds
    an estimate from the real observed request count in that window (GetCapacityCount),
    halved for safety margin - not a guess.
  • Multiplicative decrease: every subsequent hit halves the ceiling again, always
    grounded in whichever is more conservative - the currently-effective (grown) estimate,
    or the freshly observed count. A cooldown (the endpoint's own learning window) collapses
    several near-simultaneous hits from one retry storm into a single decrease.
  • Additive increase, computed lazily: while clean, the ceiling grows by a small step
    (recomputed per decrease, scaled to the endpoint's own magnitude) for every elapsed
    interval - derived purely from elapsed wall-clock time at read time, with zero
    background goroutines and zero counters to race across replicas.
  • Composes with everything above: effectiveCapacityCeiling feeds the exact same
    gating and weighting logic as static capacity - static config still wins when present
    and is left completely untouched; learned estimates only ever engage for endpoints
    without it.
  • Mirrored in the standalone health checker (services/health-checker/main.go), which
    shares the identical store.ApplyCapacityDecrease math with the load balancer, since
    both processes mutate the same persisted estimate.

Test plan

  • internal/server/failover_soak_test.go (new): drives the real HTTP forwarding path
    (not a stub) against local httptest backends and fires 100-150 requests per scenario,
    asserting every single request succeeds (200) while one or more endpoints
    misbehave simultaneously - always-429, always-500 ("or something else"), a mix of both,
    and a statically-capped endpoint exhausting its ceiling mid-run.
  • internal/health/ratelimit_test.go, internal/store/capacity_estimate_test.go: pure,
    table-driven tests for detection and the AIMD math (decrease/cooldown/growth).
  • internal/server/capacity_test.go, capacity_learning_test.go: gating/weighting
    integration tests for both static and learned ceilings, including regression coverage
    confirming existing static-only behavior is unchanged.
  • go build ./..., go vet ./..., go test ./..., gofmt -l . all pass.

Summary by CodeRabbit

  • New Features
    • Added endpoint capacity controls with static ceilings and adaptive learning-based proactive throttling.
    • Added enable/disable flags and environment variables for capacity learning and throttling.
    • Introduced new Prometheus metrics for capacity utilization, ceiling skips, and learning/estimate changes.
  • Bug Fixes
    • Improved rate-limit recovery using provider-aware signals (e.g., Retry-After, daily-quota hints, and embedded JSON-RPC rate-limit errors inside HTTP 200).
    • Enhanced request routing and backoff behavior for both HTTP and WebSocket paths, including better handling when bodies exceed scan limits.
  • Documentation
    • Expanded README with capacity control guidance and updated “Rate Limit Recovery” behavior.

@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 27cdc58d-51eb-42a2-84d5-e5c22c152add

📥 Commits

Reviewing files that changed from the base of the PR and between 2564bd4 and 56a5a2e.

📒 Files selected for processing (6)
  • internal/server/capacity_learning_test.go
  • internal/server/server.go
  • internal/server/server_test.go
  • internal/store/capacity_estimate.go
  • services/health-checker/main.go
  • services/health-checker/main_test.go
🚧 Files skipped from review as they are similar to previous changes (6)
  • services/health-checker/main_test.go
  • services/health-checker/main.go
  • internal/store/capacity_estimate.go
  • internal/server/capacity_learning_test.go
  • internal/server/server_test.go
  • internal/server/server.go

Walkthrough

This PR adds capacity configuration and documentation, structured rate-limit signal detection, Valkey-backed capacity counters and learned estimates, server-side capacity-aware dispatch and recovery, and standalone health-checker wiring. It also updates tests and metrics to cover capacity throttling, adaptive learning, provider-specific rate-limit handling, and the new configuration flags.

Changes

Related issues: None identified.
Related PRs: None identified.
Suggested labels: enhancement, documentation, tests
Suggested reviewers: maintainers familiar with server dispatch, health-checking, and store code

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Server
  participant Valkey
  participant Backend
  Client->>Server: request
  Server->>Server: recordCapacityUsage
  Server->>Backend: forward request
  Backend-->>Server: 2xx/429/402
  Server->>Server: DetectRateLimit / body scan
  Server->>Valkey: update rate-limit or capacity state
Loading

Poem:
capacity counts rise
signals steer the next backoff
endpoints learn and yield

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: rate-limit-aware load balancing with adaptive capacity estimation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
services/health-checker/main.go (1)

128-138: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

CurrentBackoff is reset on every rate-limit detection, not just the first, undoing the scheduler's exponential-backoff progress.

Unlike FirstRateLimited, which is guarded to only be set once (if state.FirstRateLimited.IsZero()), state.CurrentBackoff is unconditionally recomputed from standaloneInitialBackoff on every call to this handler. Since the standalone health checker periodically re-checks already-rate-limited endpoints (that's its job — detecting recovery), each subsequent detection will overwrite whatever exponential backoff the main load balancer's recovery scheduler has already built up (via multiplying CurrentBackoff on failed recovery attempts) back down to the freshly-seeded value (often 0 for a plain 429 without Retry-After). The line-range change notes confirm the prior behavior intentionally left this field "unset/unchanged" — this change removes that protection.

This can cause the recovery scheduler to probe/retry the endpoint far more aggressively than the configured backoff schedule intends, since the two processes race on the same shared Valkey key.

🐛 Proposed fix: only seed CurrentBackoff on first detection
 		// Mark as rate limited but don't start recovery scheduling (that's for the main load balancer)
 		now := time.Now()
+		wasAlreadyRateLimited := state.RateLimited
 		state.RateLimited = true
 		state.LastRecoveryCheck = now
-		state.CurrentBackoff = standaloneInitialBackoff(cfg, chain, endpointID, signal)
+		if !wasAlreadyRateLimited {
+			state.CurrentBackoff = standaloneInitialBackoff(cfg, chain, endpointID, signal)
+		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/health-checker/main.go` around lines 128 - 138, The rate-limit
handler in health-checker is overwriting the shared recovery backoff on every
detection instead of only seeding it once. Update the logic around
`state.CurrentBackoff` and `standaloneInitialBackoff` so it is only initialized
when the endpoint is first marked rate-limited, similar to the existing
`state.FirstRateLimited` guard, and leave any existing backoff value unchanged
on later checks. Keep the rest of the state updates in the same handler
(`state.RateLimited`, `state.LastRecoveryCheck`, `FirstRateLimited`) intact.
🧹 Nitpick comments (3)
services/health-checker/main_test.go (1)

266-283: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding a regression test for repeated rate-limit detections.

This test only exercises a single call to the handler. Given the CurrentBackoff overwrite issue flagged in main.go (line 133), a follow-up test asserting that a second call for an already-rate-limited endpoint does not reset an in-progress backoff would guard against regressions once that fix lands.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/health-checker/main_test.go` around lines 266 - 283, Extend
TestCreateStandaloneRateLimitHandlerSeedsBackoffFromSignal to cover repeated
rate-limit detections: after the first call to createStandaloneRateLimitHandler
seeds the backoff for the same endpoint, invoke the returned handler again with
another RateLimitSignal and verify the existing rate-limit state in
valkeyClient.GetRateLimitState is not reset or overwritten. Use the
createStandaloneRateLimitHandler and GetRateLimitState symbols to locate the
behavior and assert CurrentBackoff remains the in-progress value after the
second detection.
internal/store/capacity_estimate.go (1)

76-94: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

EffectiveMaxRequests grows the ceiling unbounded over elapsed wall-clock time.

The additive-increase step compounds indefinitely (steps*estimate.IncreaseStep with no cap), so after a long idle/healthy stretch the effective ceiling can grow far beyond anything ever observed, effectively neutralizing the learned throttle until the next real rate-limit hit corrects it. This may be an intentional trade-off (avoids introducing new config per the PR's stated goal), but worth confirming it's not an oversight, since a very long clean period could let a traffic burst through unchecked.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/capacity_estimate.go` around lines 76 - 94,
`EffectiveMaxRequests` currently increases the ceiling without any upper bound
based on elapsed time, so cap the additive growth to a safe maximum instead of
letting `steps*estimate.IncreaseStep` grow indefinitely. Update the logic in
`EffectiveMaxRequests` to clamp the returned value using an existing learned
limit or another explicit ceiling derived from
`CapacityEstimate`/`CapacityLearning`, while preserving the current stateless
behavior and using `estimate.MaxRequests`, `estimate.LastDecreaseAt`, and
`params.IncreaseStep` as the key symbols to adjust.
internal/server/server.go (1)

945-1031: 🚀 Performance & Scalability | 🔵 Trivial | 🏗️ Heavy lift

effectiveCapacityCeiling is re-resolved per candidate on every retry.

selectBestEndpointByRole is invoked once per retry attempt (from the outer loop in handleRequestHTTP/handleRequestWS), and for each retry it re-resolves effectiveCapacityCeiling for every candidate endpoint again — on top of the resolution already done once per candidate in getEndpointsByRole's proactive skip check. For a request needing several retries across several candidates with learned estimates, this multiplies Valkey round-trips (GetCapacityEstimate) well beyond what's structurally necessary for a single incoming request. The in-function ceilings cache only avoids the double-resolution within one call to selectBestEndpointByRole, not across the getEndpointsByRole call or across retries.

Consider resolving each candidate's ceiling once per incoming request (e.g., cache on EndpointWithID or a per-request map) and reusing it across getEndpointsByRole and all retry iterations of selectBestEndpointByRole.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/server/server.go` around lines 945 - 1031,
`selectBestEndpointByRole` is re-resolving `effectiveCapacityCeiling` for the
same candidates on every retry, causing repeated Valkey reads. Move ceiling
resolution to a per-request cache so `getEndpointsByRole` and
`selectBestEndpointByRole` reuse the same `endpointCeiling`/resolved capacity
data instead of calling `effectiveCapacityCeiling` repeatedly. Use the existing
`endpointCeiling` cache pattern and wire it through the request flow in
`handleRequestHTTP`/`handleRequestWS` so each endpoint’s ceiling is computed
once per incoming request.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/server/server.go`:
- Around line 1269-1297: The JSON-response rate-limit scan in the proxy path is
buffering every 2xx JSON body up to maxRateLimitScanBodyBytes before any bytes
are forwarded, which regresses streaming and increases memory use. Restrict this
behavior in the response handling block around bodyCarriesRateLimitSignal,
providerForEndpoint, and findChainAndEndpointByURL to only the
providers/endpoints that actually need embedded 200-rate-limit detection (for
example the known Alchemy batch case), or switch to a streaming-safe inspection
approach instead of fully buffering generic JSON responses. Keep the existing
non-JSON and oversized-body streaming behavior unchanged.
- Around line 881-903: The Valkey capacity lookup in the endpoint selection path
still uses a non-request context, and capacity limits are not validated before
use. Update the calls in the selection logic and the related capacity lookup
path in `effectiveCapacityCeiling` to use the request-scoped context or a short
timeout instead of `context.Background()`, and add validation so
`CapacityLimit.WindowSeconds` and `CapacityLimit.MaxRequests` must be positive
before they are applied. Ensure `GetCapacityCount`, `capacityBucketKey`, and the
ceiling check reject invalid values rather than allowing panics or making every
endpoint skippable.

In `@internal/store/valkey.go`:
- Around line 392-441: Guard zero WindowSeconds in the capacity bucket path:
capacityBucketKey currently divides by windowSeconds, so a 0 value from
IncrementCapacityCount/GetCapacityCount will panic. Add a zero-value fallback or
validation before calling capacityBucketKey, and keep the behavior consistent
with CapacityLearning.WindowSeconds by defaulting to a safe window (for example
60) or rejecting invalid config earlier in the load path.

In `@README.md`:
- Around line 507-510: The `window_seconds` behavior described in the README is
inconsistent with the implementation in `ApplyCapacityDecrease` and
`effectiveCapacityCeiling`; the stored window is overwritten on every decrease
instead of staying fixed after the first estimate. Either update the README to
match the current behavior or change `ApplyCapacityDecrease` to preserve
`prior.WindowSeconds` when persisting the estimate, so existing endpoints keep
their original window size.

---

Outside diff comments:
In `@services/health-checker/main.go`:
- Around line 128-138: The rate-limit handler in health-checker is overwriting
the shared recovery backoff on every detection instead of only seeding it once.
Update the logic around `state.CurrentBackoff` and `standaloneInitialBackoff` so
it is only initialized when the endpoint is first marked rate-limited, similar
to the existing `state.FirstRateLimited` guard, and leave any existing backoff
value unchanged on later checks. Keep the rest of the state updates in the same
handler (`state.RateLimited`, `state.LastRecoveryCheck`, `FirstRateLimited`)
intact.

---

Nitpick comments:
In `@internal/server/server.go`:
- Around line 945-1031: `selectBestEndpointByRole` is re-resolving
`effectiveCapacityCeiling` for the same candidates on every retry, causing
repeated Valkey reads. Move ceiling resolution to a per-request cache so
`getEndpointsByRole` and `selectBestEndpointByRole` reuse the same
`endpointCeiling`/resolved capacity data instead of calling
`effectiveCapacityCeiling` repeatedly. Use the existing `endpointCeiling` cache
pattern and wire it through the request flow in
`handleRequestHTTP`/`handleRequestWS` so each endpoint’s ceiling is computed
once per incoming request.

In `@internal/store/capacity_estimate.go`:
- Around line 76-94: `EffectiveMaxRequests` currently increases the ceiling
without any upper bound based on elapsed time, so cap the additive growth to a
safe maximum instead of letting `steps*estimate.IncreaseStep` grow indefinitely.
Update the logic in `EffectiveMaxRequests` to clamp the returned value using an
existing learned limit or another explicit ceiling derived from
`CapacityEstimate`/`CapacityLearning`, while preserving the current stateless
behavior and using `estimate.MaxRequests`, `estimate.LastDecreaseAt`, and
`params.IncreaseStep` as the key symbols to adjust.

In `@services/health-checker/main_test.go`:
- Around line 266-283: Extend
TestCreateStandaloneRateLimitHandlerSeedsBackoffFromSignal to cover repeated
rate-limit detections: after the first call to createStandaloneRateLimitHandler
seeds the backoff for the same endpoint, invoke the returned handler again with
another RateLimitSignal and verify the existing rate-limit state in
valkeyClient.GetRateLimitState is not reset or overwritten. Use the
createStandaloneRateLimitHandler and GetRateLimitState symbols to locate the
behavior and assert CurrentBackoff remains the in-progress value after the
second detection.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 90b898f9-771f-43fa-b429-9e3f1026bff8

📥 Commits

Reviewing files that changed from the base of the PR and between 89610b8 and 412cd66.

📒 Files selected for processing (23)
  • README.md
  • configs/endpoints-example.json
  • internal/config/config.go
  • internal/config/config_test.go
  • internal/health/checker.go
  • internal/health/health_test.go
  • internal/health/ratelimit.go
  • internal/health/ratelimit_test.go
  • internal/helpers/helpers.go
  • internal/metrics/definitions.go
  • internal/server/capacity_learning_test.go
  • internal/server/capacity_test.go
  • internal/server/failover_soak_test.go
  • internal/server/rate_limit_scheduler.go
  • internal/server/server.go
  • internal/server/server_test.go
  • internal/store/capacity_estimate.go
  • internal/store/capacity_estimate_test.go
  • internal/store/testutils.go
  • internal/store/valkey.go
  • internal/store/valkey_test.go
  • services/health-checker/main.go
  • services/health-checker/main_test.go

Comment thread internal/server/server.go
Comment on lines +881 to +903
// Proactively skip an endpoint that has hit its capacity ceiling (static
// or learned) for the current window, so selection routes to another
// endpoint before the provider's own rate limiter would trigger.
// Independent of RateLimited above: this is a self-imposed budget, not
// a provider signal.
if s.appConfig.CapacityThrottlingEnabled {
if maxRequests, windowSeconds, hasCeiling := s.effectiveCapacityCeiling(chain, endpointID, endpoint); hasCeiling {
count, err := s.valkeyClient.GetCapacityCount(context.Background(), chain, endpointID, windowSeconds)
if err == nil {
if metrics.EndpointCapacityUtilization != nil {
metrics.EndpointCapacityUtilization.WithLabelValues(chain, endpointID).Set(float64(count) / float64(maxRequests))
}
if count >= maxRequests {
log.Debug().Str("chain", chain).Str("endpoint", endpointID).Str("role", role).Msg("Skipping endpoint at its capacity ceiling")
if metrics.EndpointCapacitySkippedTotal != nil {
metrics.EndpointCapacitySkippedTotal.WithLabelValues(chain, endpointID).Inc()
}
continue
}
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map relevant files and locate the symbols mentioned in the review comment.
git ls-files | rg '(^|/)(internal/server/server\.go|.*config.*\.go|.*valkey.*\.go|.*capacity.*\.go|.*learning.*\.go)$'

printf '\n--- server.go outline ---\n'
ast-grep outline internal/server/server.go --view expanded | sed -n '1,260p'

printf '\n--- search for capacity-related call sites ---\n'
rg -n "GetCapacityCount|GetCapacityEstimate|recordCapacityUsage|applyLearnedCapacityDecrease|effectiveCapacityCeiling|getEndpointsByRole|getAvailableEndpoints|selectBestEndpointByRole|CapacityThrottlingEnabled|CapacityLearningEnabled|WindowSeconds|MaxRequests" internal/server/server.go internal

printf '\n--- config validation search ---\n'
rg -n "WindowSeconds|MaxRequests|CapacityLimit|validate.*capacity|capacity.*validate|WindowSeconds > 0|MaxRequests > 0|must be.*positive|positive.*capacity" internal

Repository: project-aethermesh/aetherlay

Length of output: 47690


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n--- internal/server/server.go around relevant sections ---\n'
sed -n '870,1045p' internal/server/server.go

printf '\n--- internal/server/server.go around Valkey timeout-bearing helpers ---\n'
sed -n '1338,1398p' internal/server/server.go

printf '\n--- internal/config/config.go around CapacityLimit and validation ---\n'
sed -n '1,260p' internal/config/config.go

printf '\n--- search for any positive/non-zero capacity validation elsewhere ---\n'
rg -n "positive|non-zero|nonzero|must be > 0|must be positive|validate.*Capacity|CapacityLimit" internal/config internal/helpers internal/server

Repository: project-aethermesh/aetherlay

Length of output: 22068


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n--- internal/store/valkey.go around client setup and capacity methods ---\n'
sed -n '1,220p' internal/store/valkey.go
printf '\n--- internal/store/valkey.go around capacity methods ---\n'
sed -n '400,520p' internal/store/valkey.go
printf '\n--- internal/store/capacity_estimate.go ---\n'
sed -n '1,220p' internal/store/capacity_estimate.go

Repository: project-aethermesh/aetherlay

Length of output: 16595


🌐 Web query:

valkey-go Do(ctx context.Context) command timeout context.Background deadline client timeout

💡 Result:

In valkey-go, the Do(ctx, cmd) method respects the provided context's deadline and cancellation [1][2]. If the context expires (e.g., via context.WithTimeout) before a response is received, the operation will return early with an error, typically context.DeadlineExceeded [1][3][2]. Key points regarding timeout and context behavior: 1. Context Deadline: When you pass a context with a timeout to Do, the client monitors this deadline [1][2]. Note that returning early due to a deadline does not guarantee the command was not sent to or processed by the server; it only means the client stopped waiting for the response [1][3][2]. 2. Client vs. Context Timeouts: - Context Timeout: Controlled by the context passed to individual Do calls, affecting how long the client waits for that specific operation [1][2]. - ConnWriteTimeout: A client-level configuration (set in ClientOption) that controls the maximum duration the client waits for responses to pipeline commands [4][5]. It acts as a safety mechanism to detect unresponsive connections or local congestion [4]. If not explicitly set, it defaults to the Dialer's KeepAlive interval multiplied by 10 [4][5]. 3. Cancellation Behavior: Manually canceling a context is reliably respected in "pipeline mode," which is used for concurrent requests on the same connection [1][2]. For dedicated connections (often used for blocking requests), you may need to enable ClientOption.AlwaysPipelining to ensure context cancellation is immediately effective [1][2]. 4. Automatic Retries: The client automatically retries read-only commands by default if they fail, provided the context deadline has not been exceeded [1][2]. If the context is canceled or expired, the retry logic terminates [6][7]. You can disable this behavior using the DisableRetry option or configure the retry delay [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n--- capacity bucket key helper ---\n'
rg -n "func capacityBucketKey|capacityBucketKey\\(" internal/store/valkey.go internal/store

printf '\n--- surrounding helper implementation ---\n'
sed -n '520,620p' internal/store/valkey.go

printf '\n--- config tests around capacity defaults/validation ---\n'
sed -n '340,420p' internal/config/config_test.go

Repository: project-aethermesh/aetherlay

Length of output: 3206


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n--- handleRequestHTTP / handleRequestWS around getAvailableEndpoints ---\n'
sed -n '407,670p' internal/server/server.go

printf '\n--- capacityBucketKey implementation ---\n'
sed -n '390,445p' internal/store/valkey.go

Repository: project-aethermesh/aetherlay

Length of output: 13816


Add request-scoped timeouts to the Valkey capacity lookups and validate positive capacity limits.

  • internal/server/server.go:888,1024 still use context.Background(), so these Valkey reads can outlive the request timeout and stall endpoint selection when Valkey is slow. Thread the handler context through these calls or wrap them in a short timeout.
  • CapacityLimit.WindowSeconds and MaxRequests still need positive-value validation; windowSeconds == 0 panics in capacityBucketKey, and maxRequests <= 0 makes the endpoint always skippable.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/server/server.go` around lines 881 - 903, The Valkey capacity lookup
in the endpoint selection path still uses a non-request context, and capacity
limits are not validated before use. Update the calls in the selection logic and
the related capacity lookup path in `effectiveCapacityCeiling` to use the
request-scoped context or a short timeout instead of `context.Background()`, and
add validation so `CapacityLimit.WindowSeconds` and `CapacityLimit.MaxRequests`
must be positive before they are applied. Ensure `GetCapacityCount`,
`capacityBucketKey`, and the ceiling check reject invalid values rather than
allowing panics or making every endpoint skippable.

Comment thread internal/server/server.go Outdated
Comment thread internal/store/valkey.go
Comment thread README.md
suisuss added 9 commits July 1, 2026 15:34
…indow_seconds

An endpoint's static capacity.window_seconds is used as a divisor when
computing the Valkey bucket key for capacity counting. Omitting the field
(JSON zero value) or setting it negative caused a panic on the endpoint's
first request, and since capacity gating evaluates every endpoint in a role
before selection, this took down the entire chain, not just the misconfigured
endpoint.

LoadConfig now validates window_seconds at load time, warning and disabling
capacity throttling for that endpoint rather than crashing. capacityBucketKey
and MockValkeyClient's capacity methods also clamp defensively, since they're
reachable through the public ValkeyClientIface beyond just the config path.
recordCapacityUsage and applyLearnedCapacityDecrease resolved an endpoint's
learning window from the live config on every call, while
effectiveCapacityCeiling's gating/weighting read path used the window frozen
into the endpoint's CapacityEstimate at last-seed time. Since the Valkey
bucket key is derived from the window value itself, a config change to an
endpoint's capacity_learning.window_seconds after it already had a learned
estimate caused writes and reads to target different buckets - the gate would
then read a permanently stale/zero count and never trip again, silently
defeating the proactive throttle until another real rate-limit hit happened
to re-sync the frozen value.

Both the load balancer and the standalone health checker now consult the
estimate's frozen window (once one exists) for the cooldown check and
observed-count read, falling back to the live-resolved default only before
any estimate has been seeded.
…very

performRecoveryCheck used CurrentBackoff == 0 as its only signal for "this is
the first recovery attempt, don't multiply yet." Before this feature,
CurrentBackoff always started at exactly 0, so that check worked. Now
handleRateLimit can seed CurrentBackoff to a precise nonzero value from a
Retry-After header or Infura's daily-quota MaxBackoff, which collided with
that sentinel: the very first probe after a signaled rate limit already
multiplied the backoff, instead of repeating the precise value once more
first - exactly the same treatment a guessed InitialBackoff already gets.

Gate on RecoveryAttempts instead, so a signal-seeded value gets the same
"one repeat wait, then escalate" shape as the no-signal path, just with a
more precise baseline.
Response headers were copied onto the client's http.ResponseWriter
unconditionally right after the non-2xx check, before the embedded
rate-limit body scan decided whether to abort. handleRequestHTTP's retry
loop reuses the same ResponseWriter across attempts, so an aborted attempt
(2xx response whose body embeds a rate-limit signal) left its headers
sitting in the writer's header map uncommitted, and a subsequent successful
retry's headers were then added on top rather than replacing them -
producing duplicate header values (e.g. two Content-Length) in the response
actually sent to the client.

Headers are now copied only immediately before each point the function
commits to writing a response (extracted into copyResponseHeaders, called
right before each WriteHeader), never before an abort path.
An endpoint's static capacity.max_requests of 0 (e.g. omitted from the JSON)
or negative made the "count >= max_requests" gating check in
getEndpointsByRole true on the very first request, silently and permanently
excluding the endpoint from selection with no distinguishing error from a
real outage.

LoadConfig now validates max_requests alongside window_seconds, warning and
disabling capacity throttling for that endpoint when either is non-positive.
The AIMD capacity-decrease sequence and the recovery-backoff-seeding logic
were duplicated verbatim between the load balancer (internal/server) and the
standalone health checker (services/health-checker) - two separate processes
that both mutate the same Valkey-persisted capacity estimate. Hand-duplicated
copies are a correctness risk: any future change to the math or eligibility
rules would need to be applied identically in both places, and a missed spot
would make the two processes silently disagree about what the learned
ceiling means for a given endpoint.

Extract the decrease sequence into store.ApplyLearnedCapacityDecreaseIfEligible
(internal/store/capacity_estimate.go) and the backoff-seeding logic into
health.InitialBackoffForSignal (internal/health/ratelimit.go). Both
server.Server.applyLearnedCapacityDecrease/initialBackoffForSignal and
services/health-checker/main.go's applyStandaloneLearnedCapacityDecrease/
standaloneInitialBackoff are now thin wrappers delegating to these shared
functions, preserving their existing signatures so all existing tests keep
passing unmodified.

As a byproduct of sharing the decrease sequence, the standalone health
checker's wrapper now applies the same 2-second context timeout the load
balancer already used, instead of an unbounded context.Background() - a
Valkey call hanging in the standalone checker no longer risks blocking
indefinitely.
ApplyLearnedCapacityDecreaseIfEligible correctly read against an existing
estimate's frozen WindowSeconds (fixed previously for the read/observedCount
path), but the decrease it persisted still passed the live-resolved
params.WindowSeconds to ApplyCapacityDecrease instead of the frozen value.
So each rate-limit hit after an operator changed an endpoint's
capacity_learning.window_seconds would silently re-freeze the estimate to
the new value, contradicting the documented invariant that the window is
frozen for the estimate's lifetime once seeded, not just stable between
reads.

Reuse the same frozen-if-exists windowSeconds value for both the read
(cooldown check, GetCapacityCount) and the write (ApplyCapacityDecrease),
so the persisted window can only ever be set once, at first seed.

Found by CodeRabbit's review of PR project-aethermesh#37 (README.md:510's documented
invariant).
capacityWindowSeconds, effectiveCapacityCeiling, and the capacity-ceiling
gating check in getEndpointsByRole all called GetCapacityEstimate/
GetCapacityCount with context.Background(), so a slow or unresponsive Valkey
could stall endpoint selection indefinitely instead of failing fast within
the request's own timeout budget.

Wrap each of these three call sites in a 2-second context.WithTimeout,
matching the bound already used for the capacity-decrease and
usage-recording paths (recordCapacityUsage, applyLearnedCapacityDecrease).
Also removed a stale duplicate doc comment on capacityWindowSeconds left
over from an earlier commit in this branch.

Found by CodeRabbit's review of PR project-aethermesh#37 (internal/server/server.go:888,1024).
Every 2xx JSON-RPC response through the proxy was being fully buffered (up
to maxRateLimitScanBodyBytes = 10MB) to scan for a rate-limit error embedded
in the body - a quirk specific to Alchemy on batch requests, which can
return HTTP 200 with the error only inside the JSON-RPC payload. Applying
this to every provider regressed streaming for large legitimate responses
(e.g. eth_getLogs), delaying time-to-first-byte until the full body was
read, and held up to 10MB in memory per in-flight response regardless of
provider.

Add providerNeedsEmbeddedRateLimitScan, resolving the endpoint's provider
before deciding whether to buffer at all. Only Alchemy responses are
buffered and scanned; every other provider's 2xx JSON response streams
through unmodified and uninspected, exactly as it did before this feature
was added.

Found by CodeRabbit's review of PR project-aethermesh#37 (internal/server/server.go:1297).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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)
internal/server/capacity_learning_test.go (1)

521-568: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert that capacity lookups were actually exercised.

TestCapacityValkeyLookupsUseRequestScopedTimeouts only fails when a no-deadline call is observed. If the selection path stops calling GetCapacityCount / GetCapacityEstimate, sawNoDeadline remains false and the test still passes.

Proposed test hardening
 type deadlineCheckingValkeyClient struct {
 	*store.MockValkeyClient
 	sawNoDeadline bool
+	lookupCount   int
 }

 func (c *deadlineCheckingValkeyClient) GetCapacityCount(ctx context.Context, chain, endpoint string, windowSeconds int) (int64, error) {
+	c.lookupCount++
 	if _, ok := ctx.Deadline(); !ok {
 		c.sawNoDeadline = true
 	}
 	return c.MockValkeyClient.GetCapacityCount(ctx, chain, endpoint, windowSeconds)
 }

 func (c *deadlineCheckingValkeyClient) GetCapacityEstimate(ctx context.Context, chain, endpoint string) (*store.CapacityEstimate, error) {
+	c.lookupCount++
 	if _, ok := ctx.Deadline(); !ok {
 		c.sawNoDeadline = true
 	}
 	return c.MockValkeyClient.GetCapacityEstimate(ctx, chain, endpoint)
 }
@@
+	if client.lookupCount == 0 {
+		t.Fatal("Expected the selection path to perform at least one capacity Valkey lookup")
+	}
 	if client.sawNoDeadline {
 		t.Error("Expected all capacity Valkey lookups in the selection path to use a context with a deadline, not context.Background()")
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/server/capacity_learning_test.go` around lines 521 - 568, The test
only detects missing deadlines when Valkey lookups happen, so it can still pass
if getAvailableEndpoints, effectiveCapacityCeiling, or capacityWindowSeconds
stop calling GetCapacityCount/GetCapacityEstimate. Harden
TestCapacityValkeyLookupsUseRequestScopedTimeouts by asserting the
deadlineCheckingValkeyClient was actually invoked at least once (for example by
tracking call counts or a separate “wasCalled” flag) in addition to
sawNoDeadline, so the test fails if the capacity lookup path is no longer
exercised.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/server/rate_limit_scheduler.go`:
- Around line 247-257: The retry backoff logic in rateLimitRecovery scheduling
must handle persisted state where RecoveryAttempts is greater than zero but
CurrentBackoff is still zero. Update the branch in the scheduler logic around
state.RecoveryAttempts and state.CurrentBackoff so later attempts initialize a
non-zero backoff (for example by seeding InitialBackoff when CurrentBackoff is
zero) before applying BackoffMultiplier, ensuring the recovery loop does not
keep scheduling zero-delay checks.

In `@internal/store/valkey_test.go`:
- Around line 332-342: The test only covers MockValkeyClient, so it misses the
real ValkeyClient path where raw windowSeconds is still used. Add a shared
normalization helper for capacity windows and use it in both ValkeyClient and
MockValkeyClient inside IncrementCapacityCount and GetCapacityCount so
non-positive values are clamped consistently. Then expand the regression
coverage to exercise the production ValkeyClient path, not just the mock, to
verify 0 and negative windowSeconds no longer reach bucket-key or EXPIRE logic.

---

Nitpick comments:
In `@internal/server/capacity_learning_test.go`:
- Around line 521-568: The test only detects missing deadlines when Valkey
lookups happen, so it can still pass if getAvailableEndpoints,
effectiveCapacityCeiling, or capacityWindowSeconds stop calling
GetCapacityCount/GetCapacityEstimate. Harden
TestCapacityValkeyLookupsUseRequestScopedTimeouts by asserting the
deadlineCheckingValkeyClient was actually invoked at least once (for example by
tracking call counts or a separate “wasCalled” flag) in addition to
sawNoDeadline, so the test fails if the capacity lookup path is no longer
exercised.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d49b942c-9ad3-43c4-ac0d-029f149d805b

📥 Commits

Reviewing files that changed from the base of the PR and between 412cd66 and 2564bd4.

📒 Files selected for processing (13)
  • internal/config/config.go
  • internal/config/config_test.go
  • internal/health/ratelimit.go
  • internal/server/capacity_learning_test.go
  • internal/server/rate_limit_scheduler.go
  • internal/server/rate_limit_scheduler_test.go
  • internal/server/server.go
  • internal/server/server_test.go
  • internal/store/capacity_estimate.go
  • internal/store/testutils.go
  • internal/store/valkey.go
  • internal/store/valkey_test.go
  • services/health-checker/main.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • internal/store/testutils.go
  • internal/store/valkey.go
  • services/health-checker/main.go
  • internal/server/server.go

Comment on lines +247 to 257
if state.RecoveryAttempts == 1 {
// First attempt for this episode: only fall back to InitialBackoff if nothing
// more precise was seeded: a signal-seeded value is left untouched, so the
// second attempt still waits the exact seeded duration once more before this
// same "else" branch below starts multiplying it.
if state.CurrentBackoff == 0 {
state.CurrentBackoff = rateLimitConfig.InitialBackoff
}
} else {
newBackoff := int(float64(state.CurrentBackoff) * rateLimitConfig.BackoffMultiplier)
state.CurrentBackoff = min(newBackoff, rateLimitConfig.MaxBackoff)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle zero backoff on later attempts.

If an existing persisted state has RecoveryAttempts > 0 and CurrentBackoff == 0, the new else branch multiplies zero by the multiplier and keeps scheduling zero-delay recovery checks indefinitely.

Proposed fix
 	if state.RecoveryAttempts == 1 {
 		// First attempt for this episode: only fall back to InitialBackoff if nothing
 		// more precise was seeded: a signal-seeded value is left untouched, so the
 		// second attempt still waits the exact seeded duration once more before this
 		// same "else" branch below starts multiplying it.
 		if state.CurrentBackoff == 0 {
 			state.CurrentBackoff = rateLimitConfig.InitialBackoff
 		}
 	} else {
+		if state.CurrentBackoff == 0 {
+			state.CurrentBackoff = rateLimitConfig.InitialBackoff
+		}
 		newBackoff := int(float64(state.CurrentBackoff) * rateLimitConfig.BackoffMultiplier)
 		state.CurrentBackoff = min(newBackoff, rateLimitConfig.MaxBackoff)
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if state.RecoveryAttempts == 1 {
// First attempt for this episode: only fall back to InitialBackoff if nothing
// more precise was seeded: a signal-seeded value is left untouched, so the
// second attempt still waits the exact seeded duration once more before this
// same "else" branch below starts multiplying it.
if state.CurrentBackoff == 0 {
state.CurrentBackoff = rateLimitConfig.InitialBackoff
}
} else {
newBackoff := int(float64(state.CurrentBackoff) * rateLimitConfig.BackoffMultiplier)
state.CurrentBackoff = min(newBackoff, rateLimitConfig.MaxBackoff)
if state.RecoveryAttempts == 1 {
// First attempt for this episode: only fall back to InitialBackoff if nothing
// more precise was seeded: a signal-seeded value is left untouched, so the
// second attempt still waits the exact seeded duration once more before this
// same "else" branch below starts multiplying it.
if state.CurrentBackoff == 0 {
state.CurrentBackoff = rateLimitConfig.InitialBackoff
}
} else {
if state.CurrentBackoff == 0 {
state.CurrentBackoff = rateLimitConfig.InitialBackoff
}
newBackoff := int(float64(state.CurrentBackoff) * rateLimitConfig.BackoffMultiplier)
state.CurrentBackoff = min(newBackoff, rateLimitConfig.MaxBackoff)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/server/rate_limit_scheduler.go` around lines 247 - 257, The retry
backoff logic in rateLimitRecovery scheduling must handle persisted state where
RecoveryAttempts is greater than zero but CurrentBackoff is still zero. Update
the branch in the scheduler logic around state.RecoveryAttempts and
state.CurrentBackoff so later attempts initialize a non-zero backoff (for
example by seeding InitialBackoff when CurrentBackoff is zero) before applying
BackoffMultiplier, ensuring the recovery loop does not keep scheduling
zero-delay checks.

Comment on lines +332 to +342
func TestCapacityCountDoesNotPanicOnNonPositiveWindowSeconds(t *testing.T) {
client := NewMockValkeyClient()
ctx := context.Background()

for _, windowSeconds := range []int{0, -1, -100} {
if _, err := client.IncrementCapacityCount(ctx, "test-chain", "ep1", windowSeconds); err != nil {
t.Fatalf("IncrementCapacityCount(windowSeconds=%d) failed: %v", windowSeconds, err)
}
if _, err := client.GetCapacityCount(ctx, "test-chain", "ep1", windowSeconds); err != nil {
t.Fatalf("GetCapacityCount(windowSeconds=%d) failed: %v", windowSeconds, err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Cover the production Valkey path, not just the mock.

This regression test only verifies MockValkeyClient, which already normalizes windowSeconds <= 0 to 1. The real ValkeyClient still uses the raw value for bucket-key calculation and EXPIRE, so 0/negative values can still panic or issue invalid Valkey commands despite this test passing.

Suggested direction
func normalizeCapacityWindowSeconds(windowSeconds int) int {
	if windowSeconds <= 0 {
		return 1
	}
	return windowSeconds
}

Use that shared helper in both mock and production IncrementCapacityCount / GetCapacityCount, then add coverage for the production helper/path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/valkey_test.go` around lines 332 - 342, The test only covers
MockValkeyClient, so it misses the real ValkeyClient path where raw
windowSeconds is still used. Add a shared normalization helper for capacity
windows and use it in both ValkeyClient and MockValkeyClient inside
IncrementCapacityCount and GetCapacityCount so non-positive values are clamped
consistently. Then expand the regression coverage to exercise the production
ValkeyClient path, not just the mock, to verify 0 and negative windowSeconds no
longer reach bucket-key or EXPIRE logic.

@suisuss

suisuss commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Test coverage summary

The automated suite covers the rate-limiting system at three levels:

Pure math (internal/store/capacity_estimate_test.go, internal/health/ratelimit_test.go): table-driven tests for the AIMD decrease/cooldown/growth functions and rate-limit signal detection, independent of any HTTP or Valkey plumbing.

Integration (internal/server/capacity_test.go, capacity_learning_test.go): gating and weighting behavior for both static and learned capacity ceilings, including regression coverage confirming pre-existing static-only behavior is unchanged, and the frozen-window invariant holding across the write and read paths.

End-to-end failover (internal/server/failover_soak_test.go): drives the real HTTP forwarding path (not a stub) against local httptest backends, firing 100-150 requests per scenario and asserting every single request succeeds while one or more endpoints misbehave simultaneously - always-429, always-500, a mix of both, and a statically-capped endpoint exhausting its ceiling mid-run.

Manual verification (not committed - documented here for the record)

To specifically confirm the adaptive/learned capacity approximation converges sensibly rather than diverging or collapsing, I ran two scratch simulations directly against the AIMD functions (deleted afterward since they duplicate coverage already in the tests above):

  1. Step-by-step lifecycle trace on an endpoint with no static config: 20 real requests observed, a rate-limit hit seeds the estimate at floor(200.5)=10, the ceiling is immediately enforced (endpoint excluded from selection within the same window), 3 clean intervals grow it to 13, and a second hit in a lighter-traffic window (8 requests) correctly grounds the next decrease in the lower of the two figures (min(13, 8)=8 -> floor(80.5)=4) rather than the grown-but-stale ceiling.

  2. 40-window simulation against a hidden true provider capacity of 50 (never revealed directly, only enforced via rejections): the learned ceiling settled into a stable, periodic sawtooth between 25 and 51 (bracketing the true limit within 2%), repeating identically every ~13 windows - no divergence, no permanent collapse toward the floor.

Both confirm the approximation behaves as designed under repeated cycles, not just in a single isolated decrease.

suisuss added 2 commits July 2, 2026 12:27
…ease

handleRateLimit called applyLearnedCapacityDecrease unconditionally for every
confirmed rate-limit signal, including Infura's HTTP 402 daily-credit-cap
exhaustion. That signal says nothing about an endpoint's short-term RPS
capacity, so folding it into the AIMD estimator taught it the wrong lesson -
halving a burst-capacity ceiling in response to a daily quota running out,
contradicting the PR's own rationale for keeping the two signal types apart.

Add an isDailyQuota parameter to the shared
store.ApplyLearnedCapacityDecreaseIfEligible (used by both the load balancer
and the standalone health checker) that short-circuits the decrease entirely
for a daily-quota signal, leaving the long-cooldown recovery backoff already
seeded for it (health.InitialBackoffForSignal) as the sole response. Covered
independently for both call paths - the load balancer
(TestApplyLearnedCapacityDecreaseSkipsForDailyQuotaSignal) and the standalone
health checker (TestCreateStandaloneRateLimitHandlerSkipsCapacityEstimateForDailyQuota)
- since the two are separate processes mutating the same persisted estimate.

Raised in a PR project-aethermesh#37 review comment questioning whether 402/daily-quota
exhaustion was kept separate from burst 429s in the adaptive estimator.
…te-limit scan

defaultForwardRequestWithBodyFunc's embedded-rate-limit body scan already
fails open when a 2xx JSON body exceeds maxRateLimitScanBodyBytes - streaming
it through unmodified and unscanned rather than buffering it fully - but
nothing asserted that behavior. Add a regression test with an oversized
Alchemy body carrying an embedded rate-limit error beyond the scan cap,
confirming it streams through verbatim and the endpoint is not marked rate
limited.

Raised in a PR project-aethermesh#37 review comment asking for coverage of the scan-cap
boundary.
@sanbotto

sanbotto commented Jul 2, 2026

Copy link
Copy Markdown
Member

Creating a preprod image in order to test it out.

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