Skip to content

Commit 74d88d9

Browse files
Kowserclaude
andcommitted
S1: scheduler transport hand-fixes — PUT pause/resume with 405→GET fallback, reason param, typed get_schedule
Per-schedule pause/resume verbs are a server-family split: OSS Conductor maps them PUT-only (conductor-oss#1064), Orkes Conductor GET-only. The generated client sent GET (Orkes dialect), which fails on OSS/embedded servers. Now: - scheduler_resource_api.py (HAND-FIX, regeneration-guarded): pause/resume send PUT; pause accepts an optional `reason` query param; get_schedule deserializes to WorkflowSchedule instead of leaking raw camelCase dicts. - OrkesSchedulerClient: falls back to the legacy GET dialect on 405 and caches the discovered verb per instance (one wasted round-trip max on Orkes servers); 404 never triggers fallback. get_schedule returns None for Conductor's 200-with-empty-body missing-schedule responses, honoring the ABC's declared Optional[WorkflowSchedule] contract. pause_schedule gains reason= (additive). - SchedulerClient ABC: fix the broken tuple annotation on get_schedule; add reason= to pause_schedule. - New tests/unit/orkes/test_scheduler_resource_contract.py (13 tests): pins paths/verbs/query-params/response_type against spec regeneration, the None normalization, reason forwarding, and the 405 fallback + verb cache. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e714961 commit 74d88d9

4 files changed

Lines changed: 238 additions & 11 deletions

File tree

src/conductor/client/http/api/scheduler_resource_api.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,10 @@ def get_schedule_with_http_info(self, name, **kwargs): # noqa: E501
393393
body=body_params,
394394
post_params=form_params,
395395
files=local_var_files,
396-
response_type='object', # noqa: E501
396+
# HAND-FIX (do not regenerate away): the endpoint returns a WorkflowSchedule;
397+
# 'object' made this method leak raw camelCase dicts, contradicting its own
398+
# declared contract. Guarded by tests/unit/orkes/test_scheduler_resource_contract.py.
399+
response_type='WorkflowSchedule', # noqa: E501
397400
auth_settings=auth_settings,
398401
async_req=params.get('async_req'),
399402
_return_http_data_only=params.get('_return_http_data_only'),
@@ -522,7 +525,9 @@ def pause_schedule_with_http_info(self, name, **kwargs): # noqa: E501
522525
returns the request thread.
523526
"""
524527

525-
all_params = ['name'] # noqa: E501
528+
# HAND-FIX (do not regenerate away): optional `reason` query param + PUT verb below.
529+
# Guarded by tests/unit/orkes/test_scheduler_resource_contract.py.
530+
all_params = ['name', 'reason'] # noqa: E501
526531
all_params.append('async_req')
527532
all_params.append('_return_http_data_only')
528533
all_params.append('_preload_content')
@@ -549,6 +554,8 @@ def pause_schedule_with_http_info(self, name, **kwargs): # noqa: E501
549554
path_params['name'] = params['name'] # noqa: E501
550555

551556
query_params = []
557+
if 'reason' in params and params['reason'] is not None:
558+
query_params.append(('reason', params['reason'])) # noqa: E501
552559

553560
header_params = {}
554561

@@ -564,7 +571,11 @@ def pause_schedule_with_http_info(self, name, **kwargs): # noqa: E501
564571
auth_settings = [] # noqa: E501
565572

566573
return self.api_client.call_api(
567-
'/scheduler/schedules/{name}/pause', 'GET',
574+
# HAND-FIX (do not regenerate away): OSS Conductor's SchedulerResource maps
575+
# per-schedule pause as PUT; the spec-generated GET is the Orkes-server dialect
576+
# (OrkesSchedulerClient falls back to GET on 405). Guarded by
577+
# tests/unit/orkes/test_scheduler_resource_contract.py.
578+
'/scheduler/schedules/{name}/pause', 'PUT',
568579
path_params,
569580
query_params,
570581
header_params,
@@ -827,7 +838,11 @@ def resume_schedule_with_http_info(self, name, **kwargs): # noqa: E501
827838
auth_settings = [] # noqa: E501
828839

829840
return self.api_client.call_api(
830-
'/scheduler/schedules/{name}/resume', 'GET',
841+
# HAND-FIX (do not regenerate away): OSS Conductor's SchedulerResource maps
842+
# per-schedule resume as PUT; the spec-generated GET is the Orkes-server dialect
843+
# (OrkesSchedulerClient falls back to GET on 405). Guarded by
844+
# tests/unit/orkes/test_scheduler_resource_contract.py.
845+
'/scheduler/schedules/{name}/resume', 'PUT',
831846
path_params,
832847
query_params,
833848
header_params,

src/conductor/client/orkes/orkes_scheduler_client.py

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from conductor.client.http.models.search_result_workflow_schedule_execution_model import \
77
SearchResultWorkflowScheduleExecutionModel
88
from conductor.client.http.models.workflow_schedule import WorkflowSchedule
9+
from conductor.client.http.rest import ApiException
910
from conductor.client.orkes.models.metadata_tag import MetadataTag
1011
from conductor.client.orkes.orkes_base_client import OrkesBaseClient
1112
from conductor.client.scheduler_client import SchedulerClient
@@ -14,12 +15,21 @@
1415
class OrkesSchedulerClient(OrkesBaseClient, SchedulerClient):
1516
def __init__(self, configuration: Configuration):
1617
super(OrkesSchedulerClient, self).__init__(configuration)
18+
# Per-schedule pause/resume verbs differ by server family: OSS Conductor maps them
19+
# PUT-only, Orkes Conductor GET-only. We send PUT first and remember a 405 so
20+
# subsequent calls go straight to the legacy GET dialect.
21+
self._legacy_scheduler_verbs = False
1722

1823
def save_schedule(self, save_schedule_request: SaveScheduleRequest):
1924
self.schedulerResourceApi.save_schedule(save_schedule_request)
2025

21-
def get_schedule(self, name: str) -> WorkflowSchedule:
22-
return self.schedulerResourceApi.get_schedule(name)
26+
def get_schedule(self, name: str) -> Optional[WorkflowSchedule]:
27+
schedule = self.schedulerResourceApi.get_schedule(name)
28+
# Conductor returns 200 with an empty/null body for missing schedules, which
29+
# deserializes to an empty model. A real schedule always carries `name`.
30+
if not schedule or not getattr(schedule, "name", None):
31+
return None
32+
return schedule
2333

2434
def get_all_schedules(self, workflow_name: Optional[str] = None) -> List[WorkflowSchedule]:
2535
kwargs = {}
@@ -46,14 +56,57 @@ def get_next_few_schedule_execution_times(self,
4656
def delete_schedule(self, name: str):
4757
self.schedulerResourceApi.delete_schedule(name)
4858

49-
def pause_schedule(self, name: str):
50-
self.schedulerResourceApi.pause_schedule(name)
59+
def pause_schedule(self, name: str, reason: Optional[str] = None):
60+
if self._legacy_scheduler_verbs:
61+
self._pause_resume_via_get(name, "pause", reason)
62+
return
63+
try:
64+
if reason is None:
65+
self.schedulerResourceApi.pause_schedule(name)
66+
else:
67+
self.schedulerResourceApi.pause_schedule(name, reason=reason)
68+
except ApiException as e:
69+
if e.status != 405:
70+
raise
71+
self._legacy_scheduler_verbs = True
72+
self._pause_resume_via_get(name, "pause", reason)
5173

5274
def pause_all_schedules(self):
5375
self.schedulerResourceApi.pause_all_schedules()
5476

5577
def resume_schedule(self, name: str):
56-
self.schedulerResourceApi.resume_schedule(name)
78+
if self._legacy_scheduler_verbs:
79+
self._pause_resume_via_get(name, "resume")
80+
return
81+
try:
82+
self.schedulerResourceApi.resume_schedule(name)
83+
except ApiException as e:
84+
if e.status != 405:
85+
raise
86+
self._legacy_scheduler_verbs = True
87+
self._pause_resume_via_get(name, "resume")
88+
89+
def _pause_resume_via_get(self, name: str, action: str, reason: Optional[str] = None):
90+
"""Legacy-dialect fallback: Orkes Conductor servers map per-schedule pause/resume
91+
as GET (their endpoint takes no reason param; sending it is harmless). Mirrors the
92+
generated call in scheduler_resource_api.py with only the verb changed."""
93+
query_params = []
94+
if reason is not None:
95+
query_params.append(("reason", reason))
96+
self.api_client.call_api(
97+
"/scheduler/schedules/{name}/" + action, "GET",
98+
{"name": name},
99+
query_params,
100+
{"Accept": self.api_client.select_header_accept(["application/json"])},
101+
body=None,
102+
post_params=[],
103+
files={},
104+
response_type="object",
105+
auth_settings=[],
106+
_return_http_data_only=True,
107+
_preload_content=True,
108+
collection_formats={},
109+
)
57110

58111
def resume_all_schedules(self):
59112
self.schedulerResourceApi.resume_all_schedules()

src/conductor/client/scheduler_client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def save_schedule(self, save_schedule_request: SaveScheduleRequest):
1414
pass
1515

1616
@abstractmethod
17-
def get_schedule(self, name: str) -> (Optional[WorkflowSchedule], str):
17+
def get_schedule(self, name: str) -> Optional[WorkflowSchedule]:
1818
pass
1919

2020
@abstractmethod
@@ -35,7 +35,7 @@ def delete_schedule(self, name: str):
3535
pass
3636

3737
@abstractmethod
38-
def pause_schedule(self, name: str):
38+
def pause_schedule(self, name: str, reason: Optional[str] = None):
3939
pass
4040

4141
@abstractmethod
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# Copyright (c) 2026 Agentspan
2+
# Licensed under the MIT License. See LICENSE file in the project root for details.
3+
4+
"""Regeneration guards for the HAND-FIXes in scheduler_resource_api.py, plus the
5+
OrkesSchedulerClient contract they enable.
6+
7+
The scheduler API spec used for code generation is out of date; these tests pin the
8+
hand-applied fixes so a future regeneration that reverts them fails loudly:
9+
10+
- per-schedule pause/resume send PUT (OSS Conductor dialect; Orkes servers get a
11+
GET fallback on 405 — see the verb-split analysis in the SDK docs)
12+
- pause accepts an optional ``reason`` query param
13+
- ``get_schedule`` deserializes to ``WorkflowSchedule`` (not a raw camelCase dict)
14+
15+
No network calls.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
from unittest.mock import MagicMock
21+
22+
import pytest
23+
24+
from conductor.client.configuration.configuration import Configuration
25+
from conductor.client.http.api.scheduler_resource_api import SchedulerResourceApi
26+
from conductor.client.http.models.workflow_schedule import WorkflowSchedule
27+
from conductor.client.http.rest import ApiException
28+
from conductor.client.orkes.orkes_scheduler_client import OrkesSchedulerClient
29+
30+
31+
def _client_with_mocks() -> OrkesSchedulerClient:
32+
client = OrkesSchedulerClient(Configuration(server_api_url="http://localhost:8080/api"))
33+
client.schedulerResourceApi = MagicMock()
34+
client.api_client = MagicMock()
35+
client.api_client.select_header_accept.return_value = "application/json"
36+
return client
37+
38+
39+
# ── HAND-FIX guards on the generated resource API ───────────────────
40+
41+
42+
class TestSchedulerResourceHandFixes:
43+
def setup_method(self):
44+
self.api_client = MagicMock()
45+
self.api = SchedulerResourceApi(api_client=self.api_client)
46+
47+
def _call_args(self):
48+
assert self.api_client.call_api.call_count == 1
49+
return self.api_client.call_api.call_args
50+
51+
def test_pause_sends_put(self):
52+
self.api.pause_schedule("sched-1")
53+
args, kwargs = self._call_args()
54+
assert args[0] == "/scheduler/schedules/{name}/pause"
55+
assert args[1] == "PUT"
56+
assert args[3] == [] # no reason -> no query params
57+
58+
def test_pause_forwards_reason_query_param(self):
59+
self.api.pause_schedule("sched-1", reason="maintenance window")
60+
args, _ = self._call_args()
61+
assert args[1] == "PUT"
62+
assert ("reason", "maintenance window") in args[3]
63+
64+
def test_resume_sends_put(self):
65+
self.api.resume_schedule("sched-1")
66+
args, _ = self._call_args()
67+
assert args[0] == "/scheduler/schedules/{name}/resume"
68+
assert args[1] == "PUT"
69+
70+
def test_get_schedule_deserializes_to_workflow_schedule(self):
71+
self.api.get_schedule("sched-1")
72+
args, kwargs = self._call_args()
73+
assert args[0] == "/scheduler/schedules/{name}"
74+
assert args[1] == "GET"
75+
assert kwargs["response_type"] == "WorkflowSchedule"
76+
77+
78+
# ── OrkesSchedulerClient contract over the fixed transport ─────────
79+
80+
81+
class TestOrkesSchedulerClientContract:
82+
def test_get_schedule_returns_model_when_name_present(self):
83+
client = _client_with_mocks()
84+
ws = WorkflowSchedule(name="sched-1", cron_expression="0 9 * * *")
85+
client.schedulerResourceApi.get_schedule.return_value = ws
86+
assert client.get_schedule("sched-1") is ws
87+
88+
def test_get_schedule_normalizes_empty_model_to_none(self):
89+
client = _client_with_mocks()
90+
client.schedulerResourceApi.get_schedule.return_value = WorkflowSchedule()
91+
assert client.get_schedule("missing") is None
92+
93+
def test_get_schedule_normalizes_falsy_to_none(self):
94+
client = _client_with_mocks()
95+
client.schedulerResourceApi.get_schedule.return_value = None
96+
assert client.get_schedule("missing") is None
97+
98+
def test_pause_bare_call_omits_reason(self):
99+
client = _client_with_mocks()
100+
client.pause_schedule("sched-1")
101+
client.schedulerResourceApi.pause_schedule.assert_called_once_with("sched-1")
102+
103+
def test_pause_forwards_reason(self):
104+
client = _client_with_mocks()
105+
client.pause_schedule("sched-1", reason="maintenance")
106+
client.schedulerResourceApi.pause_schedule.assert_called_once_with(
107+
"sched-1", reason="maintenance"
108+
)
109+
110+
111+
# ── Verb fallback for Orkes servers (GET-only dialect) ─────────────
112+
113+
114+
class TestVerbFallback:
115+
def test_405_falls_back_to_get(self):
116+
client = _client_with_mocks()
117+
client.schedulerResourceApi.pause_schedule.side_effect = ApiException(
118+
status=405, reason="Method Not Allowed"
119+
)
120+
client.pause_schedule("sched-1", reason="maintenance")
121+
args, _ = client.api_client.call_api.call_args
122+
assert args[0] == "/scheduler/schedules/{name}/pause"
123+
assert args[1] == "GET"
124+
assert args[2] == {"name": "sched-1"}
125+
assert ("reason", "maintenance") in args[3]
126+
127+
def test_405_result_is_cached_for_subsequent_calls(self):
128+
client = _client_with_mocks()
129+
client.schedulerResourceApi.pause_schedule.side_effect = ApiException(
130+
status=405, reason="Method Not Allowed"
131+
)
132+
client.pause_schedule("sched-1")
133+
client.pause_schedule("sched-2")
134+
client.resume_schedule("sched-1")
135+
# PUT attempted exactly once; everything after the 405 goes straight to GET.
136+
assert client.schedulerResourceApi.pause_schedule.call_count == 1
137+
client.schedulerResourceApi.resume_schedule.assert_not_called()
138+
assert client.api_client.call_api.call_count == 3
139+
140+
def test_404_propagates_without_fallback(self):
141+
client = _client_with_mocks()
142+
client.schedulerResourceApi.pause_schedule.side_effect = ApiException(
143+
status=404, reason="Not Found"
144+
)
145+
with pytest.raises(ApiException):
146+
client.pause_schedule("missing")
147+
client.api_client.call_api.assert_not_called()
148+
assert client._legacy_scheduler_verbs is False
149+
150+
def test_resume_405_falls_back_to_get(self):
151+
client = _client_with_mocks()
152+
client.schedulerResourceApi.resume_schedule.side_effect = ApiException(
153+
status=405, reason="Method Not Allowed"
154+
)
155+
client.resume_schedule("sched-1")
156+
args, _ = client.api_client.call_api.call_args
157+
assert args[0] == "/scheduler/schedules/{name}/resume"
158+
assert args[1] == "GET"
159+
assert args[3] == []

0 commit comments

Comments
 (0)