mattkevan commited on
Commit
3921761
·
1 Parent(s): 1f36bcf

fix: harden modal render status polling

Browse files
backend/modal_client.py CHANGED
@@ -18,12 +18,14 @@ class ModalSynthesisClient:
18
  base_url: Optional[str],
19
  auth_token: Optional[str] = None,
20
  timeout_seconds: float = 300.0,
21
- poll_interval_seconds: float = 1.0,
 
22
  ) -> None:
23
  self.base_url = base_url.rstrip("/") if base_url else None
24
  self.auth_token = auth_token
25
  self.timeout_seconds = timeout_seconds
26
  self.poll_interval_seconds = poll_interval_seconds
 
27
 
28
  @classmethod
29
  def from_env(cls) -> "ModalSynthesisClient":
@@ -31,7 +33,8 @@ class ModalSynthesisClient:
31
  base_url=os.getenv("SCRIPTORIUM_MODAL_BASE_URL"),
32
  auth_token=os.getenv("SCRIPTORIUM_MODAL_AUTH_TOKEN"),
33
  timeout_seconds=float(os.getenv("SCRIPTORIUM_MODAL_TIMEOUT_SECONDS", "300")),
34
- poll_interval_seconds=float(os.getenv("SCRIPTORIUM_MODAL_POLL_INTERVAL_SECONDS", "1")),
 
35
  )
36
 
37
  def is_configured(self) -> bool:
@@ -110,8 +113,26 @@ class ModalSynthesisClient:
110
  voice_config: VoiceConfig,
111
  ) -> Iterable[Dict[str, object]]:
112
  cursor = 0
 
113
  while True:
114
- data = self._get_json(f"/renders/{job_id}", params={"cursor": cursor})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  events = list(data.get("events") or [])
116
  cursor += len(events)
117
  for event in events:
@@ -178,7 +199,11 @@ class ModalSynthesisClient:
178
  "Try again, or increase SCRIPTORIUM_MODAL_TIMEOUT_SECONDS."
179
  ) from exc
180
  except requests.RequestException as exc:
181
- raise ValueError(f"Modal request failed: {exc}") from exc
 
 
 
 
182
 
183
  def _get_json(self, path: str, *, params: Dict[str, object]) -> Dict[str, object]:
184
  self._ensure_configured()
@@ -197,7 +222,11 @@ class ModalSynthesisClient:
197
  "Try again, or increase SCRIPTORIUM_MODAL_TIMEOUT_SECONDS."
198
  ) from exc
199
  except requests.RequestException as exc:
200
- raise ValueError(f"Modal request failed: {exc}") from exc
 
 
 
 
201
 
202
  def _absolute_url(self, path: str) -> str:
203
  if path.startswith("http://") or path.startswith("https://"):
 
18
  base_url: Optional[str],
19
  auth_token: Optional[str] = None,
20
  timeout_seconds: float = 300.0,
21
+ poll_interval_seconds: float = 3.0,
22
+ max_status_retries: int = 4,
23
  ) -> None:
24
  self.base_url = base_url.rstrip("/") if base_url else None
25
  self.auth_token = auth_token
26
  self.timeout_seconds = timeout_seconds
27
  self.poll_interval_seconds = poll_interval_seconds
28
+ self.max_status_retries = max_status_retries
29
 
30
  @classmethod
31
  def from_env(cls) -> "ModalSynthesisClient":
 
33
  base_url=os.getenv("SCRIPTORIUM_MODAL_BASE_URL"),
34
  auth_token=os.getenv("SCRIPTORIUM_MODAL_AUTH_TOKEN"),
35
  timeout_seconds=float(os.getenv("SCRIPTORIUM_MODAL_TIMEOUT_SECONDS", "300")),
36
+ poll_interval_seconds=float(os.getenv("SCRIPTORIUM_MODAL_POLL_INTERVAL_SECONDS", "3")),
37
+ max_status_retries=int(os.getenv("SCRIPTORIUM_MODAL_STATUS_RETRIES", "4")),
38
  )
39
 
40
  def is_configured(self) -> bool:
 
113
  voice_config: VoiceConfig,
114
  ) -> Iterable[Dict[str, object]]:
115
  cursor = 0
116
+ consecutive_status_failures = 0
117
  while True:
118
+ try:
119
+ data = self._get_json(f"/renders/{job_id}", params={"cursor": cursor})
120
+ consecutive_status_failures = 0
121
+ except ValueError as exc:
122
+ consecutive_status_failures += 1
123
+ if consecutive_status_failures > self.max_status_retries:
124
+ raise
125
+ yield {
126
+ "type": "log",
127
+ "session_id": session_id,
128
+ "backend": MODAL_BACKEND,
129
+ "model": voice_config.model,
130
+ "message": (
131
+ f"Transient Modal status error ({consecutive_status_failures}/{self.max_status_retries}): {exc}"
132
+ ),
133
+ }
134
+ time.sleep(self.poll_interval_seconds)
135
+ continue
136
  events = list(data.get("events") or [])
137
  cursor += len(events)
138
  for event in events:
 
199
  "Try again, or increase SCRIPTORIUM_MODAL_TIMEOUT_SECONDS."
200
  ) from exc
201
  except requests.RequestException as exc:
202
+ detail = ""
203
+ response = getattr(exc, "response", None)
204
+ if response is not None and response.text:
205
+ detail = f" | response={response.text[:400]}"
206
+ raise ValueError(f"Modal request failed: {exc}{detail}") from exc
207
 
208
  def _get_json(self, path: str, *, params: Dict[str, object]) -> Dict[str, object]:
209
  self._ensure_configured()
 
222
  "Try again, or increase SCRIPTORIUM_MODAL_TIMEOUT_SECONDS."
223
  ) from exc
224
  except requests.RequestException as exc:
225
+ detail = ""
226
+ response = getattr(exc, "response", None)
227
+ if response is not None and response.text:
228
+ detail = f" | response={response.text[:400]}"
229
+ raise ValueError(f"Modal request failed: {exc}{detail}") from exc
230
 
231
  def _absolute_url(self, path: str) -> str:
232
  if path.startswith("http://") or path.startswith("https://"):
backend/synthesis_service.py CHANGED
@@ -150,7 +150,9 @@ class SynthesisService:
150
  render_dir=render_dir,
151
  voice_config=voice_config,
152
  ):
153
- self._modal_status[session_id] = str(event.get("type", "running"))
 
 
154
  yield event
155
  if self._modal_status.get(session_id) not in {"failed", "cancelled"}:
156
  self._modal_status[session_id] = "completed"
 
150
  render_dir=render_dir,
151
  voice_config=voice_config,
152
  ):
153
+ event_type = str(event.get("type", "running"))
154
+ if event_type not in {"log"}:
155
+ self._modal_status[session_id] = event_type
156
  yield event
157
  if self._modal_status.get(session_id) not in {"failed", "cancelled"}:
158
  self._modal_status[session_id] = "completed"
modal_app.py CHANGED
@@ -88,16 +88,23 @@ def render_preview(payload: Dict[str, Any]) -> Dict[str, Any]:
88
 
89
 
90
  def _job_state(job_id: str) -> Dict[str, Any]:
91
- return dict(
92
- jobs.get(
93
- job_id,
94
- {
95
- "status": "pending",
96
- "events": [],
97
- "cancel_requested": False,
98
- },
99
- )
100
  )
 
 
 
 
 
 
 
 
 
101
 
102
 
103
  def _save_job_state(job_id: str, state: Dict[str, Any]) -> None:
@@ -228,13 +235,16 @@ def render_status_endpoint(
228
  authorization: str | None = Header(default=None),
229
  ) -> Dict[str, Any]:
230
  _check_auth(authorization)
231
- state = _job_state(job_id)
232
- events = list(state.get("events", []))
233
- return {
234
- "job_id": job_id,
235
- "status": state.get("status", "pending"),
236
- "events": events[cursor:],
237
- }
 
 
 
238
 
239
 
240
  @web_app.post("/renders/{job_id}/cancel")
 
88
 
89
 
90
  def _job_state(job_id: str) -> Dict[str, Any]:
91
+ raw = jobs.get(
92
+ job_id,
93
+ {
94
+ "status": "pending",
95
+ "events": [],
96
+ "cancel_requested": False,
97
+ },
 
 
98
  )
99
+ if raw is None:
100
+ return {
101
+ "status": "pending",
102
+ "events": [],
103
+ "cancel_requested": False,
104
+ }
105
+ if not isinstance(raw, dict):
106
+ raise RuntimeError(f"Corrupt job state for {job_id}: expected dict, got {type(raw).__name__}")
107
+ return dict(raw)
108
 
109
 
110
  def _save_job_state(job_id: str, state: Dict[str, Any]) -> None:
 
235
  authorization: str | None = Header(default=None),
236
  ) -> Dict[str, Any]:
237
  _check_auth(authorization)
238
+ try:
239
+ state = _job_state(job_id)
240
+ events = list(state.get("events", []))
241
+ return {
242
+ "job_id": job_id,
243
+ "status": state.get("status", "pending"),
244
+ "events": events[cursor:],
245
+ }
246
+ except Exception as exc:
247
+ raise HTTPException(status_code=500, detail=f"Unable to fetch render status for {job_id}: {exc}") from exc
248
 
249
 
250
  @web_app.post("/renders/{job_id}/cancel")
tests/test_modal_client.py CHANGED
@@ -28,11 +28,13 @@ def test_modal_client_defaults_to_longer_timeout() -> None:
28
  def test_modal_client_uses_longer_default_timeout_from_env(monkeypatch) -> None:
29
  monkeypatch.delenv("SCRIPTORIUM_MODAL_TIMEOUT_SECONDS", raising=False)
30
  monkeypatch.delenv("SCRIPTORIUM_MODAL_POLL_INTERVAL_SECONDS", raising=False)
 
31
 
32
  client = ModalSynthesisClient.from_env()
33
 
34
  assert client.timeout_seconds == 300.0
35
- assert client.poll_interval_seconds == 1.0
 
36
 
37
 
38
  def test_modal_client_surfaces_read_timeout_as_user_facing_error(monkeypatch, tmp_path) -> None:
@@ -51,3 +53,35 @@ def test_modal_client_surfaces_read_timeout_as_user_facing_error(monkeypatch, tm
51
  diffusion_steps=32,
52
  speed=1.0,
53
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  def test_modal_client_uses_longer_default_timeout_from_env(monkeypatch) -> None:
29
  monkeypatch.delenv("SCRIPTORIUM_MODAL_TIMEOUT_SECONDS", raising=False)
30
  monkeypatch.delenv("SCRIPTORIUM_MODAL_POLL_INTERVAL_SECONDS", raising=False)
31
+ monkeypatch.delenv("SCRIPTORIUM_MODAL_STATUS_RETRIES", raising=False)
32
 
33
  client = ModalSynthesisClient.from_env()
34
 
35
  assert client.timeout_seconds == 300.0
36
+ assert client.poll_interval_seconds == 3.0
37
+ assert client.max_status_retries == 4
38
 
39
 
40
  def test_modal_client_surfaces_read_timeout_as_user_facing_error(monkeypatch, tmp_path) -> None:
 
53
  diffusion_steps=32,
54
  speed=1.0,
55
  )
56
+
57
+
58
+ def test_modal_client_retries_transient_status_failures(monkeypatch, tmp_path) -> None:
59
+ client = ModalSynthesisClient(
60
+ base_url="https://scriptorium-tts--mattkevan.modal.run",
61
+ poll_interval_seconds=0,
62
+ max_status_retries=2,
63
+ )
64
+
65
+ calls = {"count": 0}
66
+
67
+ def fake_get_json(path, *, params):
68
+ calls["count"] += 1
69
+ if calls["count"] == 1:
70
+ raise ValueError("Modal request failed: 500 Server Error")
71
+ return {"status": "completed", "events": []}
72
+
73
+ monkeypatch.setattr(client, "_get_json", fake_get_json)
74
+ monkeypatch.setattr("backend.modal_client.time.sleep", lambda *_args, **_kwargs: None)
75
+
76
+ events = list(
77
+ client.render(
78
+ session_id="session-a",
79
+ job_id="job-123",
80
+ render_dir=tmp_path,
81
+ voice_config=type("Voice", (), {"model": "omnivoice"})(),
82
+ )
83
+ )
84
+
85
+ assert calls["count"] == 2
86
+ assert events[0]["type"] == "log"
87
+ assert "Transient Modal status error" in events[0]["message"]