Skip to content

feat(ops): warn when the tier outgrows the rate limiter's single-instance assumption - #94

Merged
bgard68 merged 2 commits into
mainfrom
claude/new-session-f6pcok
Aug 26, 2026
Merged

feat(ops): warn when the tier outgrows the rate limiter's single-instance assumption#94
bgard68 merged 2 commits into
mainfrom
claude/new-session-f6pcok

Conversation

@bgard68

@bgard68 bgard68 commented Aug 26, 2026

Copy link
Copy Markdown
Owner

The assumption

Throttling counters live in the serving process's memory. That is correct on F1, which runs one instance — one counter table, and it is the whole picture.

It stops being correct the moment a second instance exists. Each keeps its own counters and enforces the configured budget separately, so two instances allow twice the configured limit and four allow four times.

Nothing about that surfaces on its own. No error, no rejected request, no log line — the numbers in appsettings.json simply stop being the numbers in effect. Same silent-failure shape as the proxy misconfiguration ProxyConfigurationCheck already exists for, and it deserves the same treatment: a check, not a comment.

The sharp edge this guards

Provision.ps1:42 takes the tier as a parameter, defaulting to F1. Passing -Sku S1 is a one-word change that silently multiplies every limit by the instance count.

What it does

ScaleOutCheck warns once at startup when WEBSITE_SKU names a tier that can run more than one instance — anything other than Free or Shared.

Two deliberate choices, both pinned by tests:

  • It reports capability, not live instance count. No instance can see how many siblings it has, so "this deployment can scale out" is the only available signal — and the more useful one anyway, since on an autoscaling plan the second instance arrives with nothing marking the transition.
  • An unrecognised tier warns rather than staying quiet. A tier this code has never heard of is likelier to be new and scalable than new and single-instance, and the wrong guess in that direction is the silent one.

Absent off App Service — local runs, containers — where there is no platform claim to check, so it stays quiet rather than training an operator to ignore it.

Testable by construction

It takes the SKU as an argument instead of reading the environment itself. That is the direct lesson from the defect fixed earlier on this branch: logic that reads its own inputs is logic a test cannot drive, and unreachable code is exactly how the forwarded-header bug survived review, CI, and coverage.

Documentation

The config handbook now states the constraint outright, including why the database is not a substitute for a shared counter store: counters are written on every request, rejected ones included, so a serverless database would be billed for compute in proportion to the abuse the limiter exists to absorb.

Verification

  • 16 new tests covering single-instance tiers, scale-capable tiers, casing, absent values, unknown tiers, and log-forging via a hostile WEBSITE_SKU.
  • 546 tests pass against real PostgreSQL.
  • dotnet format --verify-no-changes clean, 0 warnings under -warnaserror.

Generated by Claude Code

claude added 2 commits August 26, 2026 13:13
…t the caller's

Rate limiting partitioned on the leftmost X-Forwarded-For entry. A proxy appends to
that header rather than replacing it, so a caller sending "X-Forwarded-For: 9.9.9.9"
arrives as "9.9.9.9, <real client>" and position zero is a value they chose. Varying
it per request minted a fresh partition every time, which is throttling defeated --
with TrustForwardedFor correctly set to true, on the deployed app.

TrustForwardedFor was the setting that got all the attention, and it turns out to
answer only half the question. Whether to read the header is one decision; which
entry of it a proxy actually wrote is the other, and only the second is a trust
boundary.

A unit test asserted the wrong semantics by name -- The_leftmost_entry_in_a_
forwarded_chain_is_the_client -- so the defect was pinned in place by a passing test
that described the code accurately and the protocol incorrectly.

Read from the trusted end instead. With TrustedProxyHops proxies in front the client
sits at count - hops; everything left of it is caller-supplied and ignored. A chain
shorter than the hop count falls back to the connection address rather than trusting
an entry nearer the caller.

Entries are normalised through IPAddress/IPEndPoint, which drops the ":port" App
Service appends. That mattered: the source port is ephemeral, so keeping it would
have partitioned per connection instead of per caller and reopened the same hole
from the other side.

ProxyConfigurationCheck gained a third warning, for a hop count higher than the
chain arriving -- that misconfiguration reproduces the global-cap outage while the
setting that explains it reads as correct.

Verified: the pre-fix code returns "9.9.9.9" for the forged chain and the new code
returns the real client, both exercised directly. 530 tests pass, format clean, no
warnings under -warnaserror.

Not verified: the live spoof test against App Service. This sandbox's network policy
blocks azurewebsites.net (403 on CONNECT), so whether the platform normalises the
header before the app sees it is still unconfirmed from here. The fix is correct
either way -- it stops depending on that question having a favourable answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EA4mmpcb1rcvNntHR1iG6j
…ance assumption

Throttling counters live in the serving process's memory. That is correct on the F1
tier, which runs one instance, and wrong the moment a second one exists: each keeps
its own counters and enforces the configured budget separately, so two instances
allow twice the configured limit and four allow four times.

Nothing about that surfaces. No error, no rejected request, no log line -- the
numbers in appsettings.json simply stop being the numbers in effect. It is the same
silent-failure shape as the proxy misconfiguration, and it deserves the same
treatment: a check, not a comment.

ScaleOutCheck warns once at startup when WEBSITE_SKU names a tier that can run more
than one instance. It reports capability rather than live instance count, because no
instance can see how many siblings it has -- and on an autoscaling plan the second
one can arrive at any moment, with nothing marking the transition. Absent off App
Service, where there is no platform claim to check, so local runs stay quiet.

Worth noting the sharp edge this guards: the provisioning script takes the tier as a
parameter and defaults to F1. Passing a scale-capable SKU is a one-word change that
silently multiplies every limit.

The handbook now states the constraint outright, including why the database is not a
substitute for a shared counter store: counters are written on every request,
rejected ones included, so a serverless database would be billed for compute in
proportion to the abuse the limiter exists to absorb.

Written as a static method taking the SKU as an argument rather than reading the
environment itself, so it is reachable by tests -- the lesson from the defect fixed
in the previous commit, where unreachable code was exactly how a bug survived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EA4mmpcb1rcvNntHR1iG6j
@bgard68
bgard68 merged commit fb07943 into main Aug 26, 2026
8 checks passed
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