Repository navigation
fix: extend provider error boundary to streaming and batch paths #830
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
ae5afe3
76c18f1
d727c08
f1f91e9
faac2d4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1206,6 +1206,7 @@ def _stream_send( | |
| headers=headers, | ||
| method="POST", | ||
| ) | ||
| stream_error: RuntimeError | None = None | ||
| try: | ||
| with self._open_provider(request, destination) as response: | ||
| for raw in response: | ||
|
|
@@ -1219,13 +1220,25 @@ def _stream_send( | |
| chunk = json.loads(data) | ||
| except json.JSONDecodeError: | ||
| continue | ||
| delta = chunk.get("choices", [{}])[0].get("delta", {}).get("content") | ||
| choices = chunk.get("choices") or [{}] | ||
| delta = (choices[0] or {}).get("delta", {}).get("content") | ||
|
Comment on lines
+1223
to
+1224
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Explicit null delta still aborts the stream The new Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| if delta: | ||
| yield delta | ||
| except urllib.error.HTTPError as exc: | ||
| except Exception as exc: # noqa: BLE001 - provider error boundary (CWE-209) | ||
| # The gateway's own terminal tool-stop contract must survive the | ||
| # boundary: convert the provider HTTP shape into the package-owned | ||
| # stop error so callers keep the 409 semantics they rely on. | ||
| if _is_tool_execution_stopped(exc): | ||
| raise _provider_tool_execution_stopped(agent) from None | ||
| raise | ||
| if isinstance(exc, ToolFallbackStoppedError): | ||
| raise | ||
|
Comment on lines
+1227
to
+1234
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Tool-stop check safe on non-HTTP exceptions
Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| # A stream may already have emitted bytes, so it can neither be retried | ||
| # nor failed over to another provider. Keep the provider status, body, | ||
| # and exception cause inside the gateway; callers get one stable, | ||
| # package-owned error instead of raw provider diagnostics. | ||
| stream_error = RuntimeError(f"provider {agent.id} streaming request failed") | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
|
||
| if stream_error is not None: | ||
| raise stream_error | ||
|
seonghobae marked this conversation as resolved.
|
||
|
|
||
| # -- Full OpenAI passthrough (transport) ------------------------------------ | ||
| # Requests that carry provider features the multi-agent verifier cannot merge | ||
|
|
@@ -1436,9 +1449,18 @@ def batch_chat( | |
| results = self._local_batch_chat(agent, requests, temperature, effort_profile) | ||
| else: | ||
| destination = self._validate_provider(agent) # pragma: no cover | ||
| results = self._batch_run( # pragma: no cover | ||
| agent, requests, temperature, poll_interval, poll_timeout, destination, effort_profile | ||
| ) | ||
| batch_error: RuntimeError | None = None | ||
| try: | ||
| results = self._batch_run( # pragma: no cover | ||
| agent, requests, temperature, poll_interval, poll_timeout, destination, effort_profile | ||
| ) | ||
| except Exception: # noqa: BLE001 - provider batch boundary (CWE-209) | ||
| # Batch upload, polling, and output retrieval all cross the same | ||
| # public gateway boundary; provider bodies and exception text stay | ||
| # inside the authorized provider observability system. | ||
| batch_error = RuntimeError(f"provider {agent.id} batch request failed") | ||
|
seonghobae marked this conversation as resolved.
|
||
| if batch_error is not None: | ||
| raise batch_error | ||
|
seonghobae marked this conversation as resolved.
|
||
| return _validate_batch_results(requests, results) | ||
|
|
||
| def _local_batch_chat( | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -431,6 +431,41 @@ def test_mock_path_is_unchanged_no_failover_no_circuit_state() -> None: | |||||||
| assert orchestrator._circuit == {} | ||||||||
|
|
||||||||
|
|
||||||||
| def test_batch_boundary_hides_raw_upload_error_text_and_cause() -> None: | ||||||||
| """Provider Batch API failures surface one package-owned error (CWE-209). | ||||||||
|
|
||||||||
| Regression: ``_batch_run`` (upload, poll, download) propagated raw | ||||||||
| ``urllib`` errors carrying the provider URL and response body out of | ||||||||
| ``batch_chat``. | ||||||||
| """ | ||||||||
| class RawBatchFailureClient(ModelClient): | ||||||||
| def _validate_provider(self, agent): # type: ignore[override] | ||||||||
| return None # skip URL/credential validation; only the boundary matters here | ||||||||
|
|
||||||||
| def _batch_run(self, agent, requests, temperature, poll_interval, poll_timeout, destination=None): # type: ignore[override] | ||||||||
| raise RuntimeError("provider-secret-batch-body") | ||||||||
|
Comment on lines
+445
to
+446
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Batch boundary test never exercises the secret it claims to scrub The
Suggested change
Was this helpful? React with 👍 or 👎 to provide feedback. |
||||||||
|
|
||||||||
| client = RawBatchFailureClient() | ||||||||
| agent = ModelAgent("batch_worker", "gpt", base_url="https://provider.example/v1") | ||||||||
| try: | ||||||||
| client.batch_chat(agent, {"task_0": [{"role": "user", "content": "ping"}]}) | ||||||||
| except RuntimeError as error: | ||||||||
| assert "batch request failed" in str(error) | ||||||||
| assert "provider-secret-batch-body" not in str(error) | ||||||||
| assert error.__cause__ is None | ||||||||
| else: # pragma: no cover | ||||||||
| raise AssertionError("a failed provider batch must raise a package-owned error") | ||||||||
|
|
||||||||
|
|
||||||||
| def test_mock_batch_path_is_not_wrapped() -> None: | ||||||||
| """The mock batch path keeps its plain results — no boundary wrapping.""" | ||||||||
| client = ModelClient() | ||||||||
| agent = ModelAgent("solo_worker", "mock") | ||||||||
| results = client.batch_chat(agent, {"task_0": [{"role": "user", "content": "hello"}]}) | ||||||||
| assert set(results) == {"task_0"} | ||||||||
| assert results["task_0"]["content"] | ||||||||
|
|
||||||||
|
|
||||||||
| if __name__ == "__main__": | ||||||||
| for name, fn in sorted(globals().items()): | ||||||||
| if name.startswith("test_") and callable(fn): | ||||||||
|
|
||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📝 Info: OSError catch subsumes URLError and TimeoutError
Adding
OSErrormakes the existingURLErrorandTimeoutErrortuple entries redundant (both subclassOSError). Code mapping is unaffected because_provider_discovery_error_codechecks the more specific types first;JSONDecodeErroris aValueError, notOSError, so it still yieldsinvalid_response.Was this helpful? React with 👍 or 👎 to provide feedback.