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 16 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
81 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.
…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>
There was a problem hiding this comment.
Pull request overview
This PR introduces API rate limiting (with a stricter shared budget for scheduling/forecasting triggers) backed by Redis via Flask-Limiter, and adds a “Plan” concept to override rate limits (and future quotas) per account/organisation.
Changes:
- Add Flask-Limiter-based default and trigger-specific rate limits, including consistent 429 JSON errors and rate-limit headers.
- Introduce
Plan(DB model + migration) and wireplan_idinto the Accounts API, UI account page, and CLI (flexmeasures add/edit plan). - Add documentation and tests covering rate limiting behavior and plan assignment.
Reviewed changes
Copilot reviewed 26 out of 27 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| pyproject.toml | Add Flask-Limiter dependency (Redis-backed storage). |
| flexmeasures/utils/validation_utils.py | Add rate-limit string validation and "unlimited" sentinel. |
| flexmeasures/utils/config_defaults.py | Add rate-limiting config defaults and testing overrides. |
| flexmeasures/ui/views/accounts.py | Load assignable plans for the account page (admin-only). |
| flexmeasures/ui/tests/test_account_crud.py | UI tests for plan visibility and admin plan assignment UI. |
| flexmeasures/ui/templates/accounts/account.html | Add plan selector for admins and plan display row. |
| flexmeasures/ui/static/openapi-specs.json | Update generated OpenAPI spec for plan_id on accounts. |
| flexmeasures/data/schemas/account.py | Add plan_id to schemas and validate admin-only + non-legacy plans. |
| flexmeasures/data/models/user.py | Add Plan model and RateLimitKey enum; link Account.plan_id. |
| flexmeasures/data/migrations/versions/6a767f36151c_add_plan_table_and_account_plan_id.py | Create plan table + account.plan_id FK. |
| flexmeasures/cli/utils.py | Add Click callback to validate rate-limit options. |
| flexmeasures/cli/tests/test_data_add.py | Add CLI tests for plan creation/editing and validation. |
| flexmeasures/cli/data_edit.py | Add flexmeasures edit plan command. |
| flexmeasures/cli/data_add.py | Add flexmeasures add plan command. |
| flexmeasures/api/v3_0/tests/test_rate_limiting.py | Add API tests for default/trigger limits, sharing, exemptions, and plan overrides. |
| flexmeasures/api/v3_0/tests/test_accounts_api.py | Add API tests for patching plan_id and plan validation. |
| flexmeasures/api/v3_0/sensors.py | Apply trigger rate limiting decorator to schedule/forecast trigger endpoints. |
| flexmeasures/api/v3_0/assets.py | Apply trigger rate limiting decorator to asset schedule trigger endpoint. |
| flexmeasures/api/v3_0/accounts.py | Document/allow plan_id in PATCH field list. |
| flexmeasures/api/common/responses.py | Add too_many_requests (429) response helper. |
| flexmeasures/api/common/rate_limiting.py | Implement Limiter setup, keying, shared trigger limit, and 429 handler registration. |
| flexmeasures/api/init.py | Register rate limiting during API setup. |
| documentation/configuration.rst | Document rate limiting config, plans, and behavior. |
| documentation/cli/commands.rst | Document new CLI commands: add plan and edit plan. |
| documentation/changelog.rst | Add main changelog entry for rate limiting + plans. |
| documentation/api/change_log.rst | Add API changelog entry for rate limiting semantics and headers. |
Comments suppressed due to low confidence (2)
flexmeasures/api/v3_0/tests/test_accounts_api.py:355
- Please remove the debug
printstatement from this test; it adds noise to test output and doesn’t assert anything.
response = client.patch(
url_for("AccountAPI:patch", id=account.id),
json={"plan_id": 999999},
)
print(f"Response: {response.json}")
flexmeasures/api/v3_0/tests/test_accounts_api.py:373
- Please remove the debug
printstatement from this test; it adds noise to test output and doesn’t assert anything.
response = client.patch(
url_for("AccountAPI:patch", id=account.id),
json={"plan_id": legacy_plan.id},
)
print(f"Response: {response.json}")
Semantic resolutions beyond the textual conflicts: - Keep @limit_triggers() on the asset trigger endpoint, now decorating main's AssetTriggerSchemaV3-based signature. - Trigger endpoints return 202 Accepted since #2224, so the rate-limiting tests now expect 202 for accepted triggers. The deduct-on-acceptance logic (status < 400) needs no change. - uv.lock: regenerated from main's version with 'uv lock', re-adding Flask-Limiter (and its redis<8 pin) on top of main's lock state. Signed-off-by: F.N. Claessen <felix@seita.nl>
- The Forbidden message for non-admin plan changes now follows the UI terminology guideline (organisation, no internal role names). - The new plan tests carry the response payload in the assert message instead of printing it, so failures stay informative without stdout noise. Signed-off-by: F.N. Claessen <felix@seita.nl>
The plan_id validator rejected legacy plans unconditionally, but the account edit form always resends the current plan_id — so an account already on a legacy plan could no longer save any change. The legacy check now lives in the PATCH endpoint, where the target account is known: resubmitting the account's own plan passes, assigning a legacy plan to any other account still returns 422. Regression test included. Also pin the RateLimitKey enum's type name in the model to match the migration, per Copilot's suggestion. Signed-off-by: F.N. Claessen <felix@seita.nl>
- Loading assignable plans now requires update-level admin access, and the plan dropdown only renders when there are plans to offer, so admin-readers no longer get an assignment control they cannot use. - The 429 message no longer claims the limit is per endpoint (the default limit spans the API; the trigger limit is shared). - validate_rate_limit: RST docstring per the repo convention, explicit return type, and exception chaining preserved with 'from exc'. - Changelog entry leads with 'organisation' per the UI terminology guideline, keeping 'account' only as the technical carrier. Signed-off-by: F.N. Claessen <felix@seita.nl>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 26 out of 27 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
flexmeasures/api/common/rate_limiting.py:136
trigger_limit()can return the special value "unlimited" from a plan. Since this is not a parseable limit string, it risks causing Flask-Limiter parsing errors if the limit callable is evaluated even whenexempt_whenis true. Returning a parseable limit (and keeping the exemption logic inlimit_triggers) avoids that failure mode.
def trigger_limit() -> str:
return (
_account_rate_limit("trigger")
or current_app.config["FLEXMEASURES_API_TRIGGER_RATE_LIMIT"]
)
A plan's 'unlimited' value is an exemption (granted by exempt_when), not a limit spec, so the limit callables now fall back to the server config instead of returning it. Previously the behaviour was right, but only because Flask-Limiter logs and skips an unparseable limit. Adds a test exempting an account from the default limit, which exercises exactly this path. Signed-off-by: F.N. Claessen <felix@seita.nl>
NULL is a meaningful state for every plan field except the name and the legacy flag (it means: the server-wide behaviour applies), but the edit command filtered out None values, so a set field could never be unset again. A repeatable --clear option (mirroring the --null idiom of 'edit attribute') now does that, and refuses to both set and clear the same field. Signed-off-by: F.N. Claessen <felix@seita.nl>
The endpoint-level 422 was the last ad-hoc {"errors": [...]} response
in this module (the pattern the schema refactor on main removed), so
clients would have needed to special-case it. It now goes through the
unprocessable_entity helper, keyed by field, and the test pins the
envelope shape.
Signed-off-by: F.N. Claessen <felix@seita.nl>
nhoening
left a comment
There was a problem hiding this comment.
- Endpoints which are rate-limited don't seem to be documenting their 429 response yet. Re-generate the OpenAPI JSON afterwards.
- Please also create a simple
flexmeasures show planscommand. Show a table with the ID and the supported fields as columns. - For
flexmeasures edit plan, I suggest that parameter--idis used for identification, so the--nameparameter can be used to also edit the plan name.
- Document the 429 response on every rate-limited endpoint. The limiter guards these endpoints from outside the views, so no docstring declares this response; the specs generator adds it, and knows which endpoints hit the stricter trigger limit. Regenerated the OpenAPI JSON. - Add `flexmeasures show plans`, listing each plan's rate limits and quotas. - `flexmeasures edit plan` now identifies a plan by `--id`, which frees up `--name` to rename the plan. Signed-off-by: F.N. Claessen <felix@seita.nl>
|
Thanks! All three points are addressed in 07a5083:
|
Signed-off-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com>
Merging main brought in its own migration (3c2f9e5a1d47), next to this branch's plan migration (6a767f36151c), which left the branch with two heads. That fails `flexmeasures db upgrade`, and with it the Docker image build. Signed-off-by: F.N. Claessen <felix@seita.nl>
- Move the rate-limiting explanation and the plan management out of the
settings list, into host/plans.rst ("Plans and rate-limiting"), which links
back to the settings. configuration.rst now just lists the settings, like it
does elsewhere. The new page documents creating, listing, editing and
retiring plans, and assigning an account to one from the UI or the API.
- Leave the quota fields out of `flexmeasures show plans`, as long as nothing
enforces them.
- Reword the `edit plan` docstring.
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