Rate limiting for scheduling triggers - #587
Conversation
Signed-off-by: F.N. Claessen <felix@seita.nl>
nhoening
left a comment
There was a problem hiding this comment.
I found two arguments against protecting a whole resource in one way.
In this solution (also using a syntactic sugar on top of Flask), they found a way to add decorators per method. Not sure if Flask-Classful has that.
Have you tried to decorate an endpoint outside of Flask-Classful's reach?
| # Apply rate limit: a schedule can be triggered once per 5 minutes per sensor per account | ||
| SensorAPI.decorators.append( | ||
| app.limiter.limit( | ||
| "1 per 5 minutes", |
There was a problem hiding this comment.
We apply this here for all endpoints, but the timing we want to allow should differ per endpoint.
There was a problem hiding this comment.
But given the cost function, it is only effectively applied to one endpoint. We are free to add more limiter.limit() decorators with other rate limits that affect other endpoints (via the cost function).
| v3_0_api_prefix = "/api/v3_0" | ||
|
|
||
| def cost_function() -> int: | ||
| if request.endpoint == "SensorAPI:trigger_schedule": |
There was a problem hiding this comment.
I'm afraid that if for some reason the endpoint's identification string changes, we suddenly stop limiting it.
There was a problem hiding this comment.
I can probably find a way to obtain this string in a way that directly references the class method.
SensorAPI.__name__ + ":" + SensorAPI.trigger_schedule.__name__ would be my first try.
I'd definitely prefer that, but did they find a way though? I don't see any comment claiming they were able to add a decorator for a distinct class method. Only for all of the methods in the class, by adding the What is possible is to tell |
|
Have you tried yet to decorate an endpoint outside of Flask-Classful? |
|
I have. That had the same problem of not having the app context available. Applying it to a whole blueprint did work, though. I had accomplished the latter in the register function of |
|
I don't recall why the app context is needed for... |
Signed-off-by: F.N. Claessen <felix@seita.nl>
It's needed to set up the |
|
I'm stuck here because it seems weird that such core functionality (adding the It's passed to the |
|
On this branch you'll find the rest of my tech spike where I test out limiting a non-FlaskView endpoint by decorating it directly, and also limiting an entire blueprint. The former doesn't work (the decorator seems to be ignored), the latter does work. To test out, run
In my branch, the issue with the decorated endpoint approach seems not to be in getting the limit string from the app (I just hardcoded a string), but setting up the limiter to work with the app. I guess you could carry on my tech spike, or we should come to a decision on how I can move forward. Otherwise, I'm stuck, too. I've made two suggestions:
|
|
I believe I'm willing to pursue the cost function approach. I did not yet understand at which (accumulated) costs the rate limiter stops access. Is that simply 1? Or can this be some setting? And are costs accumulated per endpoint? Maybe you can point me to the place in the rate-limiter docs where I can learn this. I could not find it. In any case, I'd still want to:
I'm willing to work on that. |
For |
…e to the actual endpoints) Signed-off-by: Nicolas Höning <nicolas@seita.nl>
|
Current list of ideas of TODOs:
|
Signed-off-by: Nicolas Höning <nicolas@seita.nl>
|
Superseded by #2306, which implements rate limiting and closes #306. The blocker that stalled this spike is gone: we could not decorate a single The TODOs collected in the discussion here are addressed there as well: a separate base limit for all API endpoints next to the stricter trigger limit, the limit key made configurable (per asset / per account / per user), account-level overrides through a Closing in favour of #2306. |
…s in particular (#2306) * feat: rate-limit the API, and the scheduling and forecasting triggers 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 * docs: point the rate-limiting changelog entry at PR #2306 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C2JTqYVFPmJgX7kN2DSvbE * feat: move per-account rate limit overrides to a Plan model 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. * Address review: count only accepted triggers, default to per-account 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> * Address Copilot review: plain-language permission error, no debug prints - 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> * Fix: accounts on a legacy plan must stay editable 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> * Address Copilot review round 3: permissions, wording, docstring - 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> * Never hand the 'unlimited' sentinel to the rate limit parser 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> * CLI: let 'edit plan' clear fields back to the server defaults 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> * Use the standard error envelope for the legacy-plan refusal 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> * Address review: document 429, show plans, edit plan by ID - 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> * Merge the two Alembic heads the main merge left behind 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> * Address review: move plan docs to their own page, hide unenforced quotas - 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> * Exempt play servers from both rate limits A play server is the mode for running simulations, which trigger scheduling and forecasting in a tight loop on purpose — precisely what the trigger limit exists to stop, so the two are at odds. Keyed on the play mode rather than on the development environment: a dev server keeps rate-limiting like production does, so that the limits cannot first surface once they are live. Signed-off-by: F.N. Claessen <felix@seita.nl> --------- Signed-off-by: F.N. Claessen <felix@seita.nl> Signed-off-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
I completed my tech spike.
I couldn't (figure out how to) decorate only the trigger method of our FlaskView, so I worked around that by decorating the entire SensorAPI and using the cost function to only count towards the limit for the trigger method. It's not the most elegant solution, but it does look easily extensible this way.
We also discussed offline on what to apply the limit (using
key_func): users or accounts. Here I decided to combine the account and the sensor id, just to let a test pass (test_trigger_and_get_schedulefirst schedules a battery and then schedules a charging station). But it might also make sense to apply a limit per sensor.