Skip to content

Commit d0cc294

Browse files
committed
fix: keep legacy 200/400 codes for older clients in schedule-related endpoints
Signed-off-by: Nicolas Höning <nicolas@seita.nl>
1 parent d64270f commit d0cc294

9 files changed

Lines changed: 252 additions & 30 deletions

File tree

documentation/changelog.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Bugfixes
2121
v1.0.0 | August 14, 2026
2222
============================
2323

24-
.. warning:: As of this release we standardize asynchronous job responses to use the ``job`` field and return HTTP ``202 Accepted`` while a background job is queued or running. See :ref:`api_background_jobs` for the response format and polling flow. Legacy response fields such as ``schedule`` and ``forecast`` will be deprecated; clients should migrate to ``job`` (see the Infrastructure / Support section below for migration details).
24+
.. warning:: As of this release we standardize asynchronous job responses to use the ``job`` field and return HTTP ``202 Accepted`` while a background job is queued or running. See :ref:`api_background_jobs` for the response format and polling flow. Legacy response fields such as ``schedule`` and ``forecast`` will be deprecated; clients should migrate to ``job`` (see the Infrastructure / Support section below for migration details). To receive legacy status codes (e.g. for older clients), hosts can use :ref:`legacy-schedule-client-config.
2525

2626
.. warning:: Upgrading to this version requires running ``flexmeasures db upgrade`` (you can create a backup first with ``flexmeasures db-ops dump``).
2727

@@ -87,7 +87,7 @@ Infrastructure / Support
8787
-------------------------
8888

8989
* Support storing encrypted connection secrets on organisations and assets, including utility functions, encryption key configuration, CLI commands to set and delete secrets, and UI tables that show stored secret names and optional expiration times without exposing their values [see `PR #2236 <https://www.github.com/FlexMeasures/flexmeasures/pull/2236>`_]
90-
* Standardize job-trigger API responses to return ``202 Accepted`` and a canonical ``job`` field; legacy response fields such as ``schedule`` and ``forecast`` are preserved for backward-compatibility but marked deprecated with migration guidance in :ref:`api_background_jobs` [see `PR #2224 <https://github.com/FlexMeasures/flexmeasures/pull/2224>`_].
90+
* Standardize job-trigger API responses to return ``202 Accepted`` and a canonical ``job`` field; legacy response fields such as ``schedule`` and ``forecast`` are preserved for backward-compatibility but marked deprecated with migration guidance in :ref:`api_background_jobs` [see `PR #2224 <https://github.com/FlexMeasures/flexmeasures/pull/2224>`_ and `PR #XXXX <https://github.com/FlexMeasures/flexmeasures/pull/XXXX>`_].
9191
* Warn on startup when ``TRUSTED_HOSTS`` is unset, as that lets clients poison the URLs FlexMeasures generates, such as password reset links; the setting can now also be given as a comma-separated environment variable, and the ``development`` environment trusts loopback hosts by default (so reaching a development server by its LAN address or through a tunnel now means listing that host) [see `PR #2389 <https://www.github.com/FlexMeasures/flexmeasures/pull/2389>`_]
9292
* Upgraded dependencies [see `PR #1485 <https://www.github.com/FlexMeasures/flexmeasures/pull/1485>`_, `PR #2215 <https://www.github.com/FlexMeasures/flexmeasures/pull/2215>`_, `PR #2243 <https://www.github.com/FlexMeasures/flexmeasures/pull/2243>`_, `PR #2348 <https://www.github.com/FlexMeasures/flexmeasures/pull/2348>`_ and `PR #2388 <https://www.github.com/FlexMeasures/flexmeasures/pull/2388>`_]
9393
* Add a ``FLEXMEASURES_SENTRY_DAILY_RATE_LIMIT`` setting for spreading a host's Sentry error allowance across the month with a fail-open daily Redis counter, and send the startup error about the database schema not being at the Alembic head revision to Sentry at most once per UTC calendar day per pair of current and expected revisions (it is still logged in full on every start) [see `PR #2366 <https://www.github.com/FlexMeasures/flexmeasures/pull/2366>`_]

documentation/configuration.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,6 +1024,31 @@ If ``False``, the API transparently follows the fallback job and returns the fal
10241024

10251025
Default: ``False``
10261026

1027+
1028+
.. _legacy-schedule-client-config:
1029+
1030+
FLEXMEASURES_LEGACY_SCHEDULEACCEPTED_STATUS_CLIENT_VERSION_ATTRIBUTE
1031+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1032+
1033+
Backwards-compatibility switch for scheduling-related endpoints in API v3.
1034+
1035+
Name of a version-valued asset attribute used to identify clients that still expect schedule trigger requests to return ``HTTP status 200 (OK)`` and unfinished schedule requests to return ``HTTP status 400`` with a message about the scheduling job "waiting to be processed".
1036+
FlexMeasures checks the scheduled asset itself, its parent asset, and its grandparent asset for this attribute.
1037+
When unset, all clients receive the standard ``202 Accepted`` response for accepted trigger requests and unfinished schedule requests.
1038+
1039+
For example: "v2g-liberty-version"
1040+
1041+
Default: ``None``
1042+
1043+
FLEXMEASURES_LEGACY_SCHEDULEACCEPTED_STATUS_MAX_INCOMPATIBLE_CLIENT_VERSION
1044+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1045+
1046+
Maximum client version that receives the legacy schedule responses.
1047+
Assets whose configured client-version attribute contains this version or a lower version receive the legacy schedule responses.
1048+
1049+
Default: ``"0.9.1"``
1050+
1051+
10271052
.. _reporters-config:
10281053

10291054
Reporters

flexmeasures/api/common/responses.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,7 @@ def request_accepted_for_processing(
387387
message: str = "Request has been accepted for processing.",
388388
legacy_key: str | None = None,
389389
job_results_url: str | None = None,
390+
status_code: int = 202,
390391
) -> ResponseTuple:
391392
"""
392393
Standard 202 response when a background job is accepted.
@@ -414,7 +415,7 @@ def request_accepted_for_processing(
414415
# keep legacy key for backwards compatibility; not (yet) deprecated, see docstring
415416
resp[legacy_key] = job_id
416417

417-
return resp, 202
418+
return resp, status_code
418419

419420

420421
def request_too_large(message: str) -> ResponseTuple:

flexmeasures/api/common/utils/api_utils.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from copy import deepcopy
44
import json
55
import re
6+
from packaging.version import InvalidVersion, Version
67
from timely_beliefs.beliefs.classes import BeliefsDataFrame
78
from timely_beliefs.sensors.func_store import knowledge_horizons
89
from typing import Sequence
@@ -62,6 +63,48 @@ def upsample_values(
6263
return value_groups
6364

6465

66+
def use_legacy_schedule_accepted_status(asset: GenericAsset) -> bool:
67+
version_attribute = current_app.config.get(
68+
"FLEXMEASURES_LEGACY_SCHEDULEACCEPTED_STATUS_CLIENT_VERSION_ATTRIBUTE"
69+
)
70+
if not version_attribute:
71+
return False
72+
73+
client_version, attribute_asset = _get_asset_attribute_from_nearby_hierarchy(
74+
asset, version_attribute
75+
)
76+
if client_version is None or attribute_asset is None:
77+
return False
78+
try:
79+
return Version(str(client_version)) <= Version(
80+
str(
81+
current_app.config[
82+
"FLEXMEASURES_LEGACY_SCHEDULEACCEPTED_STATUS_MAX_INCOMPATIBLE_CLIENT_VERSION"
83+
]
84+
)
85+
)
86+
except InvalidVersion:
87+
current_app.logger.warning(
88+
"Ignoring invalid schedule client version %r on asset %s.",
89+
client_version,
90+
attribute_asset.id,
91+
)
92+
return False
93+
94+
95+
def _get_asset_attribute_from_nearby_hierarchy(
96+
asset: GenericAsset, attribute: str, max_parent_depth: int = 2
97+
) -> tuple[object | None, GenericAsset | None]:
98+
current_asset = asset
99+
for _ in range(max_parent_depth + 1):
100+
if attribute in (current_asset.attributes or {}):
101+
return current_asset.attributes[attribute], current_asset
102+
if current_asset.parent_asset is None:
103+
break
104+
current_asset = current_asset.parent_asset
105+
return None, None
106+
107+
65108
def unique_ever_seen(iterable: Sequence, selector: Sequence):
66109
"""
67110
Return unique iterable elements with corresponding lists of selector elements, preserving order.

flexmeasures/api/v3_0/assets.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
from flexmeasures.api.common.utils.api_utils import (
7373
get_accessible_accounts,
7474
copy_asset,
75+
use_legacy_schedule_accepted_status,
7576
)
7677
from flexmeasures.api.common.responses import (
7778
unprocessable_entity,
@@ -1794,6 +1795,7 @@ def trigger_schedule(
17941795
return request_accepted_for_processing(
17951796
job.id,
17961797
legacy_key="schedule",
1798+
status_code=200 if use_legacy_schedule_accepted_status(asset) else 202,
17971799
)
17981800

17991801
@route("/<id>/kpis", methods=["GET"])

flexmeasures/api/v3_0/sensors.py

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@
4141
from flexmeasures.api.common.schemas.sensors import SensorId # noqa F401
4242
from flexmeasures.api.common.schemas.users import AccountIdField
4343
from flexmeasures.api.common.rate_limiting import limit_triggers
44-
from flexmeasures.api.common.utils.api_utils import process_sensor_data_ingestion
44+
from flexmeasures.api.common.utils.api_utils import (
45+
process_sensor_data_ingestion,
46+
use_legacy_schedule_accepted_status,
47+
)
4548
from flexmeasures.data.services.utils import job_status_description
4649
from flexmeasures.api.common.utils.deprecation_utils import (
4750
_add_headers as add_deprecation_header,
@@ -1090,6 +1093,11 @@ def trigger_schedule(
10901093
job_results_url=url_for(
10911094
"SensorAPI:get_schedule", id=sensor.id, uuid=job.id
10921095
),
1096+
status_code=(
1097+
200
1098+
if use_legacy_schedule_accepted_status(sensor.generic_asset)
1099+
else 202
1100+
),
10931101
)
10941102

10951103
# mark endpoint as deprecated
@@ -1326,21 +1334,20 @@ def get_schedule( # noqa: C901
13261334
elif job.is_failed:
13271335
return unknown_schedule(job_status_description(job, scheduler_info_msg))
13281336
else:
1329-
if current_app.config.get("FLEXMEASURES_API_SUNSET_ACTIVE"):
1330-
job_status = job.get_status()
1331-
job_status_name = (
1332-
job_status.upper()
1333-
if isinstance(job_status, str)
1334-
else job_status.name
1335-
)
1336-
return (
1337-
dict(
1338-
status=job_status_name,
1339-
message=job_status_description(job, scheduler_info_msg),
1340-
),
1341-
202,
1342-
)
1343-
return unknown_schedule(job_status_description(job, scheduler_info_msg))
1337+
job_status = job.get_status()
1338+
job_status_name = (
1339+
job_status.upper() if isinstance(job_status, str) else job_status.name
1340+
)
1341+
response = dict(
1342+
status=job_status_name,
1343+
message=job_status_description(job, scheduler_info_msg),
1344+
)
1345+
if use_legacy_schedule_accepted_status(sensor.generic_asset):
1346+
return response, 400
1347+
return (
1348+
response,
1349+
202,
1350+
)
13441351
schedule_start = job.kwargs["start"]
13451352

13461353
data_source = get_data_source_for_job(job)

flexmeasures/api/v3_0/tests/test_sensor_schedules.py

Lines changed: 147 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,18 @@
1212
unknown_schedule,
1313
unrecognized_event,
1414
)
15+
from flexmeasures.api.common.utils.api_utils import use_legacy_schedule_accepted_status
1516
from flexmeasures.api.tests.utils import check_deprecation
1617
from flexmeasures.api.v3_0.tests.utils import (
1718
get_sensor_by_name,
1819
message_for_trigger_schedule,
1920
)
2021
from flexmeasures.data.models.data_sources import DataSource
22+
from flexmeasures.data.models.generic_assets import GenericAsset
2123
from flexmeasures.data.models.time_series import Sensor
22-
from flexmeasures.utils.job_utils import work_on_rq
2324
from flexmeasures.data.services.scheduling import handle_scheduling_exception
2425
from flexmeasures.tests.utils import get_test_sensor
26+
from flexmeasures.utils.job_utils import work_on_rq
2527
from flexmeasures.utils.unit_utils import ur
2628

2729

@@ -343,18 +345,118 @@ def test_trigger_and_get_schedule_with_unknown_prices(
343345
assert "prices unknown" in get_schedule_response.json["message"].lower()
344346

345347

348+
@pytest.mark.parametrize("version_attribute_level", ["asset", "parent", "grandparent"])
349+
def test_legacy_schedule_accepted_status_checks_nearby_asset_hierarchy(
350+
app,
351+
add_battery_assets,
352+
monkeypatch,
353+
version_attribute_level,
354+
):
355+
battery = add_battery_assets["Test battery"]
356+
building = add_battery_assets["Test building"]
357+
version_attribute = "flexmeasures-client-version"
358+
monkeypatch.setitem(
359+
app.config,
360+
"FLEXMEASURES_LEGACY_SCHEDULEACCEPTED_STATUS_CLIENT_VERSION_ATTRIBUTE",
361+
version_attribute,
362+
)
363+
monkeypatch.setitem(
364+
app.config,
365+
"FLEXMEASURES_LEGACY_SCHEDULEACCEPTED_STATUS_MAX_INCOMPATIBLE_CLIENT_VERSION",
366+
"0.9.1",
367+
)
368+
battery.attributes = {
369+
key: value
370+
for key, value in (battery.attributes or {}).items()
371+
if key != version_attribute
372+
}
373+
building.attributes = {
374+
key: value
375+
for key, value in (building.attributes or {}).items()
376+
if key != version_attribute
377+
}
378+
379+
if version_attribute_level == "asset":
380+
battery.attributes = {**(battery.attributes or {}), version_attribute: "0.7.0"}
381+
elif version_attribute_level == "parent":
382+
building.attributes = {
383+
**(building.attributes or {}),
384+
version_attribute: "0.7.0",
385+
}
386+
else:
387+
site = GenericAsset(
388+
name="schedule client version site",
389+
generic_asset_type=building.generic_asset_type,
390+
owner=building.owner,
391+
attributes={version_attribute: "0.7.0"},
392+
)
393+
monkeypatch.setattr(building, "parent_asset", site)
394+
395+
assert use_legacy_schedule_accepted_status(battery)
396+
397+
398+
@pytest.mark.parametrize("trigger_endpoint", ["sensor", "asset"])
346399
@pytest.mark.parametrize(
347400
"requesting_user", ["test_prosumer_user@seita.nl"], indirect=True
348401
)
349-
def test_get_schedule_unfinished_job_returns_202_when_sunset_active(
402+
def test_trigger_schedule_returns_200_for_legacy_schedule_accepted_status(
350403
app,
404+
db,
351405
add_battery_assets,
352406
keep_scheduling_queue_empty,
407+
monkeypatch,
353408
requesting_user,
409+
trigger_endpoint,
354410
):
355411
sensor = add_battery_assets["Test battery"].sensors[0]
356-
original_sunset_active = app.config.get("FLEXMEASURES_API_SUNSET_ACTIVE")
357-
app.config["FLEXMEASURES_API_SUNSET_ACTIVE"] = True
412+
version_attribute = "flexmeasures-client-version"
413+
monkeypatch.setitem(
414+
app.config,
415+
"FLEXMEASURES_LEGACY_SCHEDULEACCEPTED_STATUS_CLIENT_VERSION_ATTRIBUTE",
416+
version_attribute,
417+
)
418+
monkeypatch.setitem(
419+
app.config,
420+
"FLEXMEASURES_LEGACY_SCHEDULEACCEPTED_STATUS_MAX_INCOMPATIBLE_CLIENT_VERSION",
421+
"0.9.1",
422+
)
423+
sensor.generic_asset.attributes = {
424+
**(sensor.generic_asset.attributes or {}),
425+
version_attribute: "0.7.0",
426+
}
427+
db.session.commit()
428+
429+
message = message_for_trigger_schedule()
430+
if trigger_endpoint == "asset":
431+
message["flex-model"] = [{**message["flex-model"], "sensor": sensor.id}]
432+
url = url_for("AssetAPI:trigger_schedule", id=sensor.generic_asset.id)
433+
else:
434+
url = url_for("SensorAPI:trigger_schedule", id=sensor.id)
435+
436+
with app.test_client() as client:
437+
trigger_schedule_response = client.post(url, json=message)
438+
439+
assert trigger_schedule_response.status_code == 200
440+
assert (
441+
trigger_schedule_response.json["job"]
442+
== trigger_schedule_response.json["schedule"]
443+
)
444+
assert len(app.queues["scheduling"]) == 1
445+
446+
447+
@pytest.mark.parametrize(
448+
"requesting_user", ["test_prosumer_user@seita.nl"], indirect=True
449+
)
450+
def test_get_schedule_unfinished_job_returns_202_by_default(
451+
app,
452+
db,
453+
add_battery_assets,
454+
keep_scheduling_queue_empty,
455+
monkeypatch,
456+
requesting_user,
457+
):
458+
sensor = add_battery_assets["Test battery"].sensors[0]
459+
monkeypatch.setitem(app.config, "FLEXMEASURES_API_SUNSET_ACTIVE", False)
358460

359461
with app.test_client() as client:
360462
trigger_schedule_response = client.post(
@@ -372,16 +474,52 @@ def test_get_schedule_unfinished_job_returns_202_when_sunset_active(
372474
assert get_schedule_response.json["status"] in {"QUEUED", "STARTED", "DEFERRED"}
373475
assert "message" in get_schedule_response.json
374476

375-
app.config["FLEXMEASURES_API_SUNSET_ACTIVE"] = False
477+
version_attribute = "flexmeasures-client-version"
478+
monkeypatch.setitem(
479+
app.config,
480+
"FLEXMEASURES_LEGACY_SCHEDULEACCEPTED_STATUS_CLIENT_VERSION_ATTRIBUTE",
481+
version_attribute,
482+
)
483+
monkeypatch.setitem(
484+
app.config,
485+
"FLEXMEASURES_LEGACY_SCHEDULEACCEPTED_STATUS_MAX_INCOMPATIBLE_CLIENT_VERSION",
486+
"0.9.1",
487+
)
488+
sensor.generic_asset.attributes = {
489+
**(sensor.generic_asset.attributes or {}),
490+
version_attribute: "0.9.1",
491+
}
492+
db.session.commit()
493+
376494
with app.test_client() as client:
377-
get_schedule_response_old = client.get(
495+
get_schedule_response_legacy_client = client.get(
378496
url_for("SensorAPI:get_schedule", id=sensor.id, uuid=job_id),
379497
)
380498

381-
app.config["FLEXMEASURES_API_SUNSET_ACTIVE"] = original_sunset_active
499+
assert get_schedule_response_legacy_client.status_code == 400
500+
# Legacy flexmeasures-client releases retry HTTP 400 responses whose
501+
# message contains this exact, long-standing substring.
502+
assert (
503+
"Scheduling job waiting" in get_schedule_response_legacy_client.json["message"]
504+
)
505+
assert get_schedule_response_legacy_client.json["status"] in {
506+
"QUEUED",
507+
"STARTED",
508+
"DEFERRED",
509+
}
510+
511+
sensor.generic_asset.attributes = {
512+
**(sensor.generic_asset.attributes or {}),
513+
version_attribute: "0.9.2",
514+
}
515+
db.session.commit()
516+
517+
with app.test_client() as client:
518+
get_schedule_response_compatible_client = client.get(
519+
url_for("SensorAPI:get_schedule", id=sensor.id, uuid=job_id),
520+
)
382521

383-
assert get_schedule_response_old.status_code == 400
384-
assert get_schedule_response_old.json["status"] == unknown_schedule()[0]["status"]
522+
assert get_schedule_response_compatible_client.status_code == 202
385523

386524

387525
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)