Rate limiting for the API, and for scheduling and forecasting triggers in particular#2306
Rate limiting for the API, and for scheduling and forecasting triggers in particular#2306Flix6x wants to merge 5 commits into
Conversation
… in particular Scheduling is our most expensive operation, and nothing stopped a client from triggering it in a tight loop. A generous default limit now applies to every endpoint under /api/, and a stricter limit to the endpoints which trigger schedules and forecasts. Both limits are configurable, and can be overridden per account through the "rate_limits" account attribute. What the trigger limit is counted against is a business decision, so hosts choose: per asset, per account, or per user. Counts live in the Redis we already connect to. If Redis is unreachable, we let requests through rather than take the API down with it. This picks up the tech spike in #587, which worked around flask-classful not supporting per-method decorators. It does support them now, so the cost-function workaround is gone. Closes #306. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C2JTqYVFPmJgX7kN2DSvbE
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C2JTqYVFPmJgX7kN2DSvbE
Documentation build overview
20 files changed ·
|
|
I vote to make It's a crucial attribute, and will be checked often I believe parsing attributes is a bit more expensive. |
|
Agreed that it shouldn't stay buried in On the performance rationale: I don't think we'd actually gain much there. The cost isn't decoding the JSON — it's loading the Why not scalar columns on So I'd propose the thing you actually suggested in #306: a I'd also put the key (what a trigger limit is counted against) on the plan, not just the limit value. My first instinct was to keep it as server config, on the grounds that keying is a hosting/protection decision rather than a customer entitlement — but that doesn't hold up: capping how many assets an account may create is just as much a hosting decision, and that's going on the plan too. And putting both on the plan makes it self-contained: "10 per 5 minutes, per asset" is a complete statement of what an account gets, whereas the value alone is only half an entitlement (the effective total is the value times the number of budgets). Sketch: class RateLimitKey(enum.Enum):
ACCOUNT_ASSET = "account+asset" # each asset gets its own budget
ACCOUNT = "account" # the account has one shared budget
USER = "user" # each user gets their own budget
class Plan(db.Model):
"""A bundle of entitlements. Can be shared as a tier (e.g. "Free", "Pro")."""
id = Column(Integer, primary_key=True)
name = Column(String(80), unique=True)
# Rate limits: counted per time window, enforced per request (Redis).
# NULL means: fall back to the server config.
default_rate_limit = Column(String(64), nullable=True) # e.g. "500 per minute"
trigger_rate_limit = Column(String(64), nullable=True) # e.g. "10 per 5 minutes"
# What the trigger limit is counted against. Together with the limit above,
# this fully specifies the entitlement: "10 per 5 minutes, per asset".
# Enum rather than free text: this value feeds the rate limiter's key function.
rate_limit_key = Column(Enum(RateLimitKey), nullable=True)
# Quotas: static caps on rows, enforced at creation time (SELECT count(*)).
# NULL means: no cap.
max_users = Column(Integer, nullable=True)
max_assets = Column(Integer, nullable=True)
class Account(db.Model, AuthModelMixin):
...
plan_id = Column(Integer, ForeignKey("plan.id"), nullable=True)
plan = db.relationship("Plan", backref="accounts")Three things worth flagging explicitly, because they're easy to get wrong:
Minor operational note for the docs: changing an account's Nothing is released yet, so the migration is trivial and there's no data to move. Happy to do it in this PR, or land the rate limiting as it stands and do the plan table as an immediate follow-up — your call on how much you want to grow this one. |
|
I agree with this design. One thing that came to my mind: For a consultancy account, we might think about a This might need follow-up work, but for now I would make sure the host (admin users?) would be able to choose a default plan for clients (as a config setting, for all consultancy clients, or even per account - but probably the former, so we can get started). Just note that the host account (usually the first to be created on a new FM instance) would treat created consultancy accounts as its own clients. |
Replace Account.attributes["rate_limits"] with a first-class Plan table (Account.plan_id FK), per Nicolas' review on PR #2306. A plan holds default_rate_limit, trigger_rate_limit and rate_limit_key (Enum), plus not-yet-enforced quota columns (max_users, max_assets, max_clients) as groundwork for a follow-up PR. The trigger key resolution now falls back to the server config (and ultimately to "account+asset") instead of raising, so a bad key value can never turn into a 500 on every request for an account.
There was a problem hiding this comment.
Good work. I have some smaller requests and questions.
Also, let's bring some of this to the UI. Hosts and users can not see plans, what plan an account is on, nor can they change the plan.
To begin with, I suggest:
- on the account page, show the plan the account is on
- admins see a plan dropdown in the account edit form, so they can change it.
Plan management has more details to it so maybe that is a follow-up. For instance, a plan usually represents a contractual agreement. Changing a plan should not be taken too lightly. Instead, the default choice might be to turn a plan into a legacy plan, which cannot be edited (we might decide to add that field in this PR already). Also, plans might have a time limitation, after which the account switches to a new one - but this needs to be discussed, it could be also done by each hosts in their own way.
| Counts are kept in Redis (see :ref:`redis-config`), so that all your workers share them. If Redis cannot be | ||
| reached, FlexMeasures lets requests through rather than refusing them. | ||
|
|
||
| .. note:: The limiter runs before authentication, so requests without valid credentials are counted per IP address. |
There was a problem hiding this comment.
What if someone tries an endpoint for which they would not be authorized - will it still count towards their limit then, that they attempted to use the endpoint?
There was a problem hiding this comment.
It did, and it no longer does — for the trigger limit.
The limiter runs before authentication, so as it stood, a 401/403 (and a 422) spent the trigger budget. That is now split by which limit we are talking about, because the two exist for different reasons:
- The trigger limit only counts triggers we accepted (
deduct_when=lambda response: response.status_code < 400). It protects the expensive computation, and a request we refused cost us no computation. So a client whose flex-model does not validate, or who asked for an asset which is not theirs, keeps their scheduling budget. - The default limit still counts every request, including the ones we refuse. That is what bounds a client hammering us with bad credentials — and it means we do not need the second counter you were worried about in your other comment.
Both are now spelled out in the docs.
| An account's plan can override this per account (see above). An unrecognized value falls back to | ||
| ``"account+asset"`` rather than raising an error. | ||
|
|
||
| Default: ``"account+asset"`` |
There was a problem hiding this comment.
I believe the default business decision should be "account". That's how billing works.
There was a problem hiding this comment.
Agreed, and changed: FLEXMEASURES_API_RATE_LIMIT_KEY now defaults to "account", and an unrecognized value falls back to "account" as well (rather than to "account+asset").
I also made the "account+asset" option honest while I was in there. It used to key on request.path, which meant a schedule triggered through the asset endpoint and one triggered through the deprecated sensor endpoint counted against different budgets, even for the same asset. It now resolves the sensor's asset, so the budget really is per asset. The docs note that "account+asset" multiplies the limit by the number of assets an account has — which is the argument for your default.
| so tests which only care about counting may pass an empty message. | ||
| """ | ||
| return client.post( | ||
| url_for("SensorAPI:trigger_schedule", id=sensor_id), |
There was a problem hiding this comment.
We should use the AssetAPI for testing, rather than the deprecated endpoint.
There was a problem hiding this comment.
Done — the tests now trigger through AssetAPI:trigger_schedule.
Worth flagging what that turned up. I kept one test on the deprecated sensor endpoint, and it failed: Flask-Limiter gives every endpoint its own bucket unless you tell it otherwise, so the three trigger endpoints each had their own budget, and a client could have doubled their allowance by alternating between the asset and the sensor endpoint. The trigger limit is now a shared_limit(scope="triggers"), and that test asserts the two endpoints draw on the same budget.
The same bug bit the default limit, which was really "500 per minute per endpoint" rather than "500 per minute to call the API". It is now an application limit, i.e. one budget for the whole API, which is what its docs already claimed.
| other_sensor = add_battery_assets["Test small battery"].sensors[0] | ||
|
|
||
| with app.test_client() as client: | ||
| assert trigger(client, sensor.id).status_code == 422 # spends the budget |
There was a problem hiding this comment.
I'm not sure why this is misconfigured (422) - please add a short comment.
Also, as I said above - technically there is a difference between getting to the point of computation (which is what we are rate limiting) or not (due to auth or config failures).
A design choice could be to only count the ones where the user got to the good part.
Is there consensus on which approach is favored?
Pro: users who made some mistake in their auth or config are not punished.
Contra: We might need to have two counters, I think.
There was a problem hiding this comment.
The 422s are gone, so there is nothing left to explain there: budget is now spent by triggers we accept, so the tests send valid payloads and assert on 200s.
On the design question, I went with your "pro", and I think the "contra" turns out not to bite. There is a difference between getting to the point of computation and not, and the trigger limit is exactly the limit that should care about it — it exists to protect the computation, and a request we refused cost us none. So it only counts triggers that were accepted (deduct_when on the response status).
We do not need two counters for it, though, because we already have a second limit doing that job: the default limit keeps counting every request, refused or not. So a client who fails auth in a loop is bounded (500/minute), while a client who got their flex-model wrong is not punished with a scheduling lockout. In other words: mistakes cost you out of the generous budget, not the scarce one.
There is a new test for it — test_rejected_triggers_do_not_spend_the_trigger_budget.
| ): | ||
| """A plan's rate_limit_key takes precedence over the server-wide config setting.""" | ||
| rate_limiting.setitem( | ||
| app.config, "FLEXMEASURES_API_RATE_LIMIT_KEY", "account+asset" |
There was a problem hiding this comment.
a bit inconsistent that we use constants from RateLimitKey in line 217 but not here.
There was a problem hiding this comment.
Fixed — RateLimitKey constants everywhere now, including in the parametrize list.
…budgets, surface plans in UI and CLI Nils's review on #2306, plus two bugs it surfaced. Counting: - The trigger limit now only deducts when a trigger was accepted (deduct_when), so a client whose payload we rejected, or who asked for someone else's asset, keeps their scheduling budget. The default limit still counts every request, including failed auth, so abuse stays bounded without a second counter. Keying: - FLEXMEASURES_API_RATE_LIMIT_KEY now defaults to "account" (how billing works), and an unrecognized value falls back to that too. - "account+asset" now keys on the actual asset, resolving the sensor's asset for the deprecated sensor endpoints, rather than on the request path. - The trigger endpoints now share one budget (shared_limit). They each had their own before, so a client could double their budget by alternating endpoints. - The default limit is now an application limit, i.e. one budget for the whole API rather than one per endpoint, which is what its docs already claimed. Plans: - Plan.legacy: retire a plan rather than editing one accounts are on. - Admins can see and set an account's plan on the account page; plan_id is admin-only on PATCH /accounts/<id>, and cannot be set to a legacy plan. - flexmeasures add plan / edit plan, so plans can be created and retired. Limit strings are validated on creation rather than at request time. Tests trigger through the AssetAPI rather than the deprecated sensor endpoint, and use the RateLimitKey constants throughout. Signed-off-by: F.N. Claessen <felix@seita.nl>
|
Thanks — all addressed in 18ac5d5, and your UI ask is in this PR. Plans in the UI. The account page now shows the plan the account is on, and admins get a plan dropdown in the account edit form. Under it: Legacy plans. I took you up on adding the field now, since nothing is released and the migration is free. Creating plans. The dropdown needed something to offer, so plans are created and retired from the CLI for now: flexmeasures add plan --name Pro --trigger-rate-limit "60 per 5 minutes" --rate-limit-key account
flexmeasures edit plan --name Pro --legacyLimit strings are validated when the plan is created (via the Left for follow-up, per your read that plan management has more to it: a plans CRUD UI, enforcing the quotas ( Two bugs also came out of your review, which I have described in the threads: the trigger endpoints each had their own budget instead of sharing one (a client could double their allowance by alternating between the asset and the deprecated sensor endpoint), and the default limit was counted per endpoint rather than across the API. Both fixed. |
Resolve conflicts around the accounts API/UI. main moved account-field validation into AccountPatchSchema and added an account-create endpoint; this branch's per-account Plan support is ported into that structure: plan_id becomes a schema-validated field (admin-only, no legacy plans) rather than a view-level check, with the UI, openapi spec and tests following suit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: F.N. Claessen <felix@seita.nl>
Closes #306. Supersedes the tech spike in #587.
Scheduling is our most expensive operation, and nothing stopped a client from triggering it in a tight loop. This adds rate limiting via Flask-Limiter.
What it does
500 per minute) on the API as a whole, counted per user (per IP address if unauthenticated). The health endpoints are exempt, so that monitoring cannot lock itself out.10 per 5 minutes) on the endpoints which set expensive computation in motion, which share one budget:POST /assets/<id>/schedules/triggerPOST /sensors/<id>/schedules/trigger(deprecated, but still callable)POST /sensors/<id>/forecasts/triggerHitting a limit returns a
429with our usual JSON error shape, plusRetry-AfterandX-RateLimit-*headers (which also gives clients something to poll on, cf. #645).The two limits count differently. The default limit counts every request, including the ones we refuse — that is what bounds a client who keeps sending us requests we reject, including bad credentials. The trigger limit only counts triggers we accepted: it exists to protect the computation a trigger sets in motion, and a request we rejected (its payload did not validate, or the asset belongs to someone else) cost us no computation. So a client who made a mistake in their flex-model does not pay for it out of their scheduling budget.
Configuration
RATELIMIT_ENABLEDTrueFLEXMEASURES_API_DEFAULT_RATE_LIMIT"500 per minute"FLEXMEASURES_API_TRIGGER_RATE_LIMIT"10 per 5 minutes"FLEXMEASURES_API_RATE_LIMIT_KEY"account"How often it is reasonable to re-compute a schedule is a business decision, so the host decides what shares a budget:
account(the default — one budget per account, which is how billing tends to work),account+asset(each asset gets its own budget), oruser.Plans
Per the discussion in #306, limits can be set per account by putting the account on a
Plan: a bundle of the rate limits and quotas which apply to the accounts assigned to it, so it can be shared as a tier ("Free", "Pro"). A field left unset falls back to the server-wide config setting, so behaviour is unchanged for anyone without a plan. The special value"unlimited"exempts an account from a limit.flexmeasures add plan --name Pro --trigger-rate-limit "60 per 5 minutes" --rate-limit-key accountAdmins put an account on a plan from the account page, which also shows the plan the account is on:
plan_idis admin-only onPATCH /api/v3_0/accounts/<id>, likeconsultancy_account_id.flexmeasures edit plan --name Pro --legacy. A legacy plan keeps applying to the accounts already on it, but is no longer offered when assigning a plan.Plans also carry quotas (
max_users,max_assets,max_clients). Those are not enforced yet — a quota is a cap on rows, checked at creation time, which is a separate enforcement path from a rate limit, and is left to a follow-up.Counts are kept in the Redis we already connect to, so workers share them. If Redis is unreachable, requests are let through rather than taking the API down.
Notes for the reviewer
SensorAPIand used acostfunction which counted 0 for every method except the trigger. flask-classful 0.16 stacks per-method decorators fine, so@limit_triggers()now sits on each trigger endpoint and the workaround is dropped.shared_limit(otherwise a client could double their budget by alternating between the asset and the sensor trigger endpoint), and the default limit is now an application limit, i.e. one budget for the whole API rather than one per endpoint.Flask-Limiter[redis]pinsredis<8, so this moves us from redis 8.0.0 to 7.4.1. It still satisfies our ownredis>4.5and works with rq and fakeredis. I preferred the supported version over forcing a combinationlimitsexplicitly excludes, but shout if you would rather keep redis 8 and drop the extra.init_appwhen disabled — meaning a test cannot turn it on afterwards, only off. The rate-limiting tests lower the limits themselves.🤖 Generated with Claude Code
https://claude.ai/code/session_01C2JTqYVFPmJgX7kN2DSvbE