Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions documentation/api/change_log.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ API change log
v3.0-32 | July XX, 2026
""""""""""""""""""""""""

- API endpoints are now rate-limited. A request which exceeds a limit is answered with a ``429 (Too Many Requests)`` status code and a ``Retry-After`` header stating how many seconds to wait. Responses also carry ``X-RateLimit-*`` headers, describing the limit that applied, how much of it is left, and when it resets. A stricter limit applies to ``POST /assets/<id>/schedules/trigger``, ``POST /sensors/<id>/schedules/trigger`` and ``POST /sensors/<id>/forecasts/trigger`` than to other endpoints; the health endpoints are exempt. Per-account overrides are set by assigning the account a plan (a ``Plan`` database row), rather than through an account attribute.
- Extended ``GET /api/v3_0/jobs/<uuid>`` with a ``result`` field containing ``unresolved`` and ``resolved`` arrays, each keyed by asset ID. For scheduling jobs, this surfaces soft state-of-charge constraint analysis: ``soc-minima`` and ``soc-maxima`` violations (with a ``violation`` magnitude) or satisfied constraints (with a ``margin`` headroom). Both arrays are empty when no SoC constraints were defined.

v3.0-31 | 2026-06-01
Expand Down
1 change: 1 addition & 0 deletions documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ v1.0.0 | July XX, 2026
New features
-------------

* The API is now rate-limited, with a generous default limit on all endpoints and a stricter limit on triggering schedules and forecasts (which only counts triggers we accepted). Limits are configurable, and can be set per account by putting the account on a plan, which hosts create with ``flexmeasures add plan`` and admins assign from the account page [see `PR #2306 <https://www.github.com/FlexMeasures/flexmeasures/pull/2306>`_]
* Breaking behaviour change: the top-level flex-context's ``relax-constraints`` field now defaults to ``True`` (matching the default already used within each ``commodities`` entry), so constraint violations are softly penalized by default instead of being hard constraints, unless explicitly set to ``False`` [see `PR #2172 <https://www.github.com/FlexMeasures/flexmeasures/pull/2172>`_]
* Support for creating new assets by using another asset as a template from the UI. [see `PR #2195 <https://www.github.com/FlexMeasures/flexmeasures/pull/2195>`_ and `PR #2268 <https://www.github.com/FlexMeasures/flexmeasures/pull/2268>`_
* In the UI, asset and sensor lists can be filtered by ID prefix through API-backed search fields [see `PR #2231 <https://www.github.com/FlexMeasures/flexmeasures/pull/2231>`_]
Expand Down
2 changes: 2 additions & 0 deletions documentation/cli/commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ of which some are referred to in this documentation.
``flexmeasures add initial-structure`` Initialize structural data like users, roles and asset types.
``flexmeasures add account-role`` Create a FlexMeasures tenant account role.
``flexmeasures add account`` Create a FlexMeasures tenant account.
``flexmeasures add plan`` Create a plan, which sets rate limits and quotas for the accounts on it.
``flexmeasures add user`` Create a FlexMeasures user.
``flexmeasures add asset-type`` Create a new asset type.
``flexmeasures add asset`` Create a new asset.
Expand Down Expand Up @@ -68,6 +69,7 @@ of which some are referred to in this documentation.

================================================= =======================================
``flexmeasures edit attribute`` Edit (or add) an asset attribute or sensor attribute.
``flexmeasures edit plan`` Edit a plan's rate limits and quotas, or retire it.
``flexmeasures edit secret`` Edit (or add) an encrypted secret on an account or asset.
``flexmeasures edit resample-data`` Assign a new event resolution to an existing sensor and resample its data accordingly.
``flexmeasures edit transfer-parenthood`` (Re)assign parent assets.
Expand Down
98 changes: 98 additions & 0 deletions documentation/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,104 @@ Password of the redis server.

Default: ``None``

.. _rate-limiting-config:

API rate limiting
-----------------

FlexMeasures rate-limits its API, to protect the server against clients which ask for more than their fair share ―
in particular against clients which re-trigger expensive computations like scheduling in a tight loop.

Two limits apply. A default limit applies to every API endpoint, and a stricter limit applies to the endpoints
which trigger scheduling and forecasting. Requests which exceed a limit are answered with a ``429`` status code,
and a ``Retry-After`` header telling the client how long to wait.

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.

The two limits count differently:

- The **default limit** counts *every* request, including the ones we refuse. The limiter runs before
authentication, so requests without valid credentials are counted, too (per IP address, as there is no user
to count them against). This is what bounds a client who keeps sending requests we refuse.
- The **trigger limit** only counts triggers we *accepted*. It exists to protect the expensive computation which
a trigger sets in motion, and a request we rejected ― because its payload did not validate, or because the
asset belongs to someone else ― cost us no computation. In other words, a client who made a mistake in their
flex-model does not pay for it out of their scheduling budget (they still pay for it out of the default one).

.. _rate-limiting-plans:

Plans
^^^^^

Individual accounts can be given their own limits, which take precedence over the settings below, by putting the
account on a plan. A plan (``flexmeasures.data.models.user.Plan``) bundles the rate limits and quotas which apply
to the accounts assigned to it, so that it can be shared as a tier (say, "Free" and "Pro"):

.. code-block:: bash

$ flexmeasures add plan --name Pro --trigger-rate-limit "60 per 5 minutes" --rate-limit-key account

Admins assign an account to a plan from the account page in the UI, or through
``PATCH /api/v3_0/accounts/<id>`` (``plan_id``).

A plan's ``default_rate_limit``, ``trigger_rate_limit`` and ``rate_limit_key`` override the settings below for the
accounts on that plan. A field left unset (``None``) falls back to the server-wide setting, so an account on a plan
which only sets ``trigger_rate_limit`` is treated like everybody else for all other requests. Use the value
``"unlimited"`` to exempt an account from a limit altogether.

A plan usually reflects a contractual agreement, so rather than editing a plan which accounts are on, retire it
(``flexmeasures edit plan --name Pro --legacy``) and create the plan you want to offer instead. 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`` and ``max_clients``). These are not enforced yet.

.. note:: Changing an account's ``rate_limit_key`` orphans the counters it has in Redis (the old keys simply expire),
so the account may get a fresh budget for the remainder of the current window. Harmless, but worth knowing
when you wonder why a plan change handed someone a clean slate.

RATELIMIT_ENABLED
^^^^^^^^^^^^^^^^^

Whether to rate-limit the API at all. Set this to ``False`` to turn rate limiting off.

Default: ``True``

FLEXMEASURES_API_DEFAULT_RATE_LIMIT
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

How often a client may call the API. This is one budget for the whole API, counted per user (or per IP address,
if unauthenticated). The health endpoints are exempt, so that monitoring cannot lock itself out.

Default: ``"500 per minute"``

FLEXMEASURES_API_TRIGGER_RATE_LIMIT
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

How often a client may trigger a schedule or a forecast. This is the expensive work, so this limit is stricter
than the default one. The trigger endpoints share this budget, so triggering a forecast and triggering a schedule
draw on the same one.

Default: ``"10 per 5 minutes"``

FLEXMEASURES_API_RATE_LIMIT_KEY
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

What ``FLEXMEASURES_API_TRIGGER_RATE_LIMIT`` is counted against. How often it is reasonable to re-compute a
schedule is a business decision, so you decide what shares a budget:

- ``"account"``: the account has a single budget, shared by all of its assets and users. This is how billing
usually works, so it is the default.
- ``"account+asset"``: each asset gets its own budget, so triggering for one asset never blocks another. Note
that this multiplies the limit by the number of assets an account has.
- ``"user"``: each user gets their own budget.

An account's plan can override this per account (see :ref:`rate-limiting-plans`). An unrecognized value falls back
to ``"account"`` rather than raising an error.

Default: ``"account"``


Demonstrations
--------------

Expand Down
3 changes: 3 additions & 0 deletions flexmeasures/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from flexmeasures.data import db
from flexmeasures import __version__ as flexmeasures_version
from flexmeasures.api.common import rate_limiting
from flexmeasures.api.common.utils.api_utils import catch_timed_belief_replacements
from flexmeasures.data.models.user import User
from flexmeasures.api.common.utils.args_parsing import (
Expand Down Expand Up @@ -156,6 +157,8 @@ def register_at(app: Flask):
app.register_error_handler(IntegrityError, catch_timed_belief_replacements)
app.unauthorized_handler_api = invalid_sender

rate_limiting.register_at(app)

app.register_blueprint(
flexmeasures_api, url_prefix="/api"
) # now registering the blueprint will affect all endpoints
Expand Down
211 changes: 211 additions & 0 deletions flexmeasures/api/common/rate_limiting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
"""
Rate limiting for the FlexMeasures API.

Two limits apply:

- a generous default limit on every endpoint under ``/api/``, and
- a stricter limit on the endpoints that trigger expensive computation (scheduling and forecasting).

Both are configurable (see ``FLEXMEASURES_API_DEFAULT_RATE_LIMIT`` and ``FLEXMEASURES_API_TRIGGER_RATE_LIMIT``),
and both can be overridden per account, by assigning the account a ``Plan`` with
``default_rate_limit`` and/or ``trigger_rate_limit`` set (see ``flexmeasures.data.models.user.Plan``).
The special value "unlimited" exempts an account from a limit.

The two limits count differently. The default limit counts every request, including those we refuse
(so that a client hammering us with bad credentials is bounded, too). The trigger limit only counts
triggers we accepted, because it exists to protect the expensive computation which those set in motion:
a client whose payload we rejected did not cost us a schedule, and should not pay for one.

Note that the limiter runs before authentication, so unauthenticated callers are counted by IP address.
"""

from __future__ import annotations

from flask import Flask, Response, current_app, jsonify, request
from flask_limiter import Limiter, RequestLimit
from flask_limiter.util import get_remote_address
from flask_login import current_user

from flexmeasures.api.common.responses import too_many_requests
from flexmeasures.data import db
from flexmeasures.data.models.time_series import Sensor
from flexmeasures.data.models.user import RateLimitKey
from flexmeasures.utils.validation_utils import UNLIMITED_RATE_LIMIT

# Endpoints under /api/ which the default limit should not apply to
EXEMPT_PATH_PREFIXES = ("/api/v3_0/health",)

_VALID_RATE_LIMIT_KEYS = {key.value for key in RateLimitKey}


def _plan():
"""The plan of the current user's account, if any."""
if not current_user.is_authenticated or current_user.account is None:
return None
return current_user.account.plan


def _account_rate_limit(limit_name: str) -> str | None:
"""Look up an account's plan-level override for the given limit, if any."""
plan = _plan()
if plan is None:
return None
if limit_name == "default":
return plan.default_rate_limit
if limit_name == "trigger":
return plan.trigger_rate_limit
return None


def _is_unlimited(limit_name: str) -> bool:
"""Whether the account is exempt from the given limit."""
return _account_rate_limit(limit_name) == UNLIMITED_RATE_LIMIT


def default_key_func() -> str:
"""Count requests against the user, or against the IP address if unauthenticated."""
if current_user.is_authenticated:
return f"user:{current_user.id}"
return get_remote_address()


def _rate_limit_key_value() -> str:
"""Determine what to count triggers against: the account's plan, or the server config.

Falls back to the server config (and ultimately to a hardcoded default) rather than raising,
so that a bad value never turns into a 500 on every request for an account.
"""
plan = _plan()
if plan is not None and plan.rate_limit_key is not None:
return plan.rate_limit_key.value
key = current_app.config["FLEXMEASURES_API_RATE_LIMIT_KEY"]
if key not in _VALID_RATE_LIMIT_KEYS:
current_app.logger.error(
f"Unknown FLEXMEASURES_API_RATE_LIMIT_KEY '{key}'. "
f"Use one of {sorted(_VALID_RATE_LIMIT_KEYS)}. Falling back to '{RateLimitKey.ACCOUNT.value}'."
)
return RateLimitKey.ACCOUNT.value
return key


def _asset_id_of_trigger() -> int | None:
"""Which asset a trigger request is about.

The asset endpoint names the asset in its path, while the (deprecated) sensor endpoints name a sensor,
so we resolve that sensor's asset. That way, both ways of triggering the same asset share one budget.
Returns None if we cannot tell, which the caller reads as "count this against the account as a whole".
"""
resource_id = (request.view_args or {}).get("id")
try:
resource_id = int(resource_id)
except (TypeError, ValueError):
return None # the view is about to reject this request anyway
if "/assets/" in request.path:
return resource_id
sensor = db.session.get(Sensor, resource_id)
return sensor.generic_asset_id if sensor is not None else None


def trigger_key_func() -> str:
"""Count triggers against whatever the host (or the account's plan) configured."""
if not current_user.is_authenticated:
return get_remote_address()
key = _rate_limit_key_value()
if key == RateLimitKey.USER.value:
return f"user:{current_user.id}"
account_key = f"account:{current_user.account_id}"
if key == RateLimitKey.ACCOUNT.value:
return account_key
asset_id = _asset_id_of_trigger() # "account+asset"
if asset_id is None:
return account_key
return f"{account_key}|asset:{asset_id}"


def default_limit() -> str:
return (
_account_rate_limit("default")
or current_app.config["FLEXMEASURES_API_DEFAULT_RATE_LIMIT"]
)


def trigger_limit() -> str:
return (
_account_rate_limit("trigger")
or current_app.config["FLEXMEASURES_API_TRIGGER_RATE_LIMIT"]
)


def _exempt_from_default_limit() -> bool:
"""The default limit only applies to the API, and not to endpoints we exempt explicitly."""
if not request.path.startswith("/api/"):
return True
if request.path.startswith(EXEMPT_PATH_PREFIXES):
return True
return _is_unlimited("default")


limiter = Limiter(
key_func=default_key_func,
# An application limit is one budget for the whole API, rather than one budget per endpoint,
# which is what "how often a client may call the API" should mean.
application_limits=[default_limit],
application_limits_exempt_when=_exempt_from_default_limit,
headers_enabled=True, # sets Retry-After and X-RateLimit-* headers
)


def _trigger_set_work_in_motion(response: Response) -> bool:
"""Whether a trigger request got to the expensive part, and should therefore be counted.

A request we refused (bad credentials, no permission, invalid payload) cost us no computation,
so it does not spend the account's trigger budget. Such requests are still counted by the default
limit, which applies to every API endpoint.
"""
return response.status_code < 400


def limit_triggers():
"""Decorator for endpoints which trigger expensive computation, like scheduling."""
return limiter.shared_limit(
trigger_limit,
# All trigger endpoints share one budget. Without this, each of them would get its own,
# so a client could ask for twice as many schedules by alternating between the asset
# endpoint and the (deprecated) sensor endpoint.
scope="triggers",
key_func=trigger_key_func,
exempt_when=lambda: _is_unlimited("trigger"),
deduct_when=_trigger_set_work_in_motion,
)


def rate_limit_exceeded_handler(error):
"""Respond to a hit rate limit like we respond to other API errors.

The Retry-After and X-RateLimit-* headers are added by the limiter itself, after this request.
"""
limit: RequestLimit | None = limiter.current_limit
message = "You hit a rate limit."
if limit is not None:
message += f" This endpoint allows {limit.limit}."
response_data, status_code = too_many_requests(message)
response = jsonify(response_data)
response.status_code = status_code
return response


def register_at(app: Flask):
"""Set up rate limiting, storing counts in the Redis we already connected to."""
app.config.setdefault("RATELIMIT_STORAGE_URI", "redis://")
if app.config["RATELIMIT_STORAGE_URI"].startswith("redis://"):
# Reuse the connection we already made, rather than opening a second one
app.config.setdefault(
"RATELIMIT_STORAGE_OPTIONS",
{"connection_pool": app.redis_connection.connection_pool},
)
# If Redis is unreachable, let requests through rather than take the API down with it.
app.config.setdefault("RATELIMIT_SWALLOW_ERRORS", True)
app.config.setdefault("RATELIMIT_IN_MEMORY_FALLBACK_ENABLED", True)

limiter.init_app(app)
app.register_error_handler(429, rate_limit_exceeded_handler)
4 changes: 4 additions & 0 deletions flexmeasures/api/common/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,10 @@ def request_too_large(message: str) -> ResponseTuple:
return dict(result="Rejected", status="PAYLOAD_TOO_LARGE", message=message), 413


def too_many_requests(message: str) -> ResponseTuple:
return dict(result="Rejected", status="TOO_MANY_REQUESTS", message=message), 429


def pluralize(usef_role_name: str) -> str:
"""Adding a trailing 's' works well for USEF roles."""
return "%ss" % usef_role_name
2 changes: 2 additions & 0 deletions flexmeasures/api/v3_0/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ def patch(self, account_data: dict, id: int, account: Account):
- The **consultancy_account_id** field can be edited by admins. Non-admin users can only set it to their
own account, and only if their account has the {{CONSULTANCY_ACCOUNT_ROLE}} account role and they have
the consultant or account-admin user role.
- The **plan_id** field can only be edited by admins, and cannot be set to a legacy plan.

security:
- ApiKeyAuth: []
Expand Down Expand Up @@ -360,6 +361,7 @@ def patch(self, account_data: dict, id: int, account: Account):
"secondary_color",
"logo_url",
"consultancy_account_id",
"plan_id",
"attributes",
"account_roles",
]
Expand Down
Loading
Loading