Skip to content

Commit 1491d32

Browse files
authored
Merge branch 'main' into worktree-app-subdomain
2 parents 80f82e8 + 8db1a58 commit 1491d32

7 files changed

Lines changed: 1367 additions & 379 deletions

File tree

examples/deploy_patterns/pyproject_package/uv.lock

Lines changed: 54 additions & 55 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/genai/handoff/uv.lock

Lines changed: 41 additions & 41 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/ml/image_classification/uv.lock

Lines changed: 161 additions & 161 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

plugins/agents/hermes/uv.lock

Lines changed: 189 additions & 61 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

plugins/mlflow/uv.lock

Lines changed: 832 additions & 60 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/flyte/_sentry.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,16 @@ def _is_user_actionable_connect_error(exc: BaseException) -> bool:
158158
)
159159

160160

161+
# 405 Method Not Allowed. Every Connect RPC the SDK issues is a POST: connectrpc
162+
# sends GET only when the caller passes `use_get=True` to `execute_unary`, and the
163+
# SDK never does (there is no `use_get` anywhere in this repo); client/server/bidi
164+
# streams are hardcoded to POST. A Connect handler, meanwhile, always accepts POST
165+
# on its own procedure path. So a 405 cannot be the backend rejecting the SDK's
166+
# choice of method -- it proves the POST was answered by something that does not
167+
# route Connect procedures at all, exactly like the 2xx case above.
168+
_NOT_A_CONNECT_ROUTE_HTTP_PHRASES: frozenset[str] = frozenset({HTTPStatus.METHOD_NOT_ALLOWED.phrase})
169+
170+
161171
# connectrpc rejects a response whose content-type it cannot decode with
162172
# `ConnectError(Code.UNKNOWN, f"invalid content-type: '{received}'; expecting '{wanted}'")`
163173
# (connectrpc/_protocol_connect.py). A `text/*` body — an HTML error page, a login
@@ -184,6 +194,12 @@ def _is_non_connect_endpoint_response(exc: BaseException) -> bool:
184194
exact signature as a misconfigured endpoint (#1235) when it comes back from
185195
the auth metadata fetch; these are the same thing arriving on a data-plane
186196
call instead.
197+
3. A 405 Method Not Allowed. FLYTE-SDK-81: `flyte run` against an endpoint whose
198+
POST was refused, surfaced as `RuntimeSystemError: Upload failed for ...:
199+
Method Not Allowed`. The SDK only ever POSTs (see
200+
`_NOT_A_CONNECT_ROUTE_HTTP_PHRASES`), and a Connect handler always accepts
201+
POST on its procedure path, so a 405 means the request was routed somewhere
202+
that serves no Connect procedures.
187203
188204
Either way it is endpoint/network configuration, never a Python-SDK logic bug,
189205
and the SDK cannot recover from it.
@@ -212,7 +228,7 @@ def _is_non_connect_endpoint_response(exc: BaseException) -> bool:
212228
if getattr(exc, "details", ()):
213229
return False
214230
message = (getattr(exc, "message", "") or "").strip()
215-
if message in _NON_OK_SUCCESS_HTTP_PHRASES:
231+
if message in _NON_OK_SUCCESS_HTTP_PHRASES or message in _NOT_A_CONNECT_ROUTE_HTTP_PHRASES:
216232
return True
217233
content_type = _INVALID_CONTENT_TYPE_RE.match(message)
218234
return bool(content_type and content_type.group("received").strip().lower().startswith("text/"))

tests/flyte/test_sentry.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -781,3 +781,76 @@ def test_non_connect_endpoint_response_ignores_message_merely_mentioning_html():
781781
"Body: <html>\r\n<head><title>502 Bad Gateway</title></head>\r\n",
782782
)
783783
assert not _sentry._is_non_connect_endpoint_response(err)
784+
785+
786+
# --- FLYTE-SDK-81: a 405 on a POST the SDK always sends as a POST ---
787+
788+
789+
def test_capture_exception_skips_method_not_allowed():
790+
"""405 means the POST was routed somewhere that serves no Connect procedures."""
791+
err = _wire_error_for_status(405)
792+
with mock.patch.object(_sentry, "init") as init_mock:
793+
_sentry.capture_exception(err)
794+
init_mock.assert_not_called()
795+
796+
797+
def test_capture_exception_skips_method_not_allowed_wrapped_in_runtime_system_error():
798+
"""The real FLYTE-SDK-81 shape: the 405 arrives as the __cause__ of an upload failure."""
799+
from flyte.errors import RuntimeSystemError
800+
801+
try:
802+
raise _wire_error_for_status(405)
803+
except Exception as inner:
804+
err = RuntimeSystemError(
805+
"UploadError",
806+
"Upload failed for /var/folders/j8/T/tmpzar713fu/fastc34306b3.tar.gz "
807+
"(org='flyte', project='flytesnacks', domain='development'): Method Not Allowed",
808+
)
809+
err.__cause__ = inner
810+
811+
with mock.patch.object(_sentry, "init") as init_mock:
812+
_sentry.capture_exception(err)
813+
init_mock.assert_not_called()
814+
815+
816+
@pytest.mark.parametrize("status", [400, 406, 409, 410, 500, 501])
817+
def test_non_connect_endpoint_response_ignores_other_4xx_and_5xx(status):
818+
"""Only 405 is added to the filter; every other unmapped status stays real signal.
819+
820+
400 is FLYTE-SDK-7C and 500 is FLYTE-SDK-64 -- both produce the identical
821+
UNKNOWN/bare-phrase shape and both must keep reaching Sentry.
822+
"""
823+
assert not _sentry._is_non_connect_endpoint_response(_wire_error_for_status(status))
824+
825+
826+
def test_method_not_allowed_carrying_details_still_reports():
827+
"""A real Connect JSON error body yields details; from_http_status never does."""
828+
from connectrpc.code import Code
829+
from connectrpc.errors import ConnectError
830+
from flyteidl2.common import identity_pb2
831+
832+
err = ConnectError(Code.UNKNOWN, "Method Not Allowed", details=[identity_pb2.Identity()])
833+
assert not _sentry._is_non_connect_endpoint_response(err)
834+
835+
836+
def test_sdk_never_sends_a_connect_get():
837+
"""Pins the premise of the 405 filter: no `use_get=True` call site exists in the SDK.
838+
839+
connectrpc's `execute_unary` sends GET only when the caller opts in with
840+
`use_get=True`. If that ever changes, a 405 could become the backend legitimately
841+
rejecting our method, and this filter would start hiding a real SDK bug.
842+
"""
843+
import ast
844+
import pathlib
845+
846+
src = pathlib.Path(_sentry.__file__).parent
847+
offenders = []
848+
for path in sorted(src.rglob("*.py")):
849+
try:
850+
tree = ast.parse(path.read_text(encoding="utf-8"))
851+
except SyntaxError: # pragma: no cover - vendored/generated sources
852+
continue
853+
for node in ast.walk(tree):
854+
if isinstance(node, ast.Call) and any(kw.arg == "use_get" for kw in node.keywords):
855+
offenders.append(f"{path.relative_to(src)}:{node.lineno}")
856+
assert offenders == [], f"SDK now issues Connect GETs at {offenders}; revisit the 405 filter"

0 commit comments

Comments
 (0)