@@ -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