@@ -70,6 +70,8 @@ def _route_for_metrics(path: str) -> str:
7070 parts [3 ] = "{run_id}"
7171 elif parts [0 ] == "schedules" and len (parts ) >= 2 :
7272 parts [1 ] = "{schedule_id}"
73+ elif parts [:2 ] == ["bridge-adapters" , "webhook" ] and len (parts ) >= 3 :
74+ parts [2 ] = "{adapter}"
7375 elif (
7476 parts [:2 ] == ["worker" , "workflow-tasks" ]
7577 or parts [:2 ] == ["worker" , "activity-tasks" ]
@@ -362,6 +364,47 @@ class ScheduleBackfillResult:
362364 results : list [dict [str , Any ]] | None = None
363365
364366
367+ @dataclass
368+ class BridgeAdapterOutcome :
369+ """Machine-readable result returned by a bridge adapter event."""
370+
371+ schema : str
372+ version : int
373+ adapter : str
374+ action : str | None
375+ accepted : bool
376+ outcome : str
377+ idempotency_key : str | None = None
378+ reason : str | None = None
379+ target : dict [str , Any ] | None = None
380+ correlation : dict [str , Any ] | None = None
381+ workflow_id : str | None = None
382+ run_id : str | None = None
383+ workflow_type : str | None = None
384+ control_plane_outcome : str | None = None
385+ raw : dict [str , Any ] | None = None
386+
387+ @classmethod
388+ def from_dict (cls , data : dict [str , Any ]) -> BridgeAdapterOutcome :
389+ return cls (
390+ schema = str (data .get ("schema" , "" )),
391+ version = int (data .get ("version" , 0 )),
392+ adapter = str (data .get ("adapter" , "" )),
393+ action = data .get ("action" ),
394+ accepted = bool (data .get ("accepted" , False )),
395+ outcome = str (data .get ("outcome" , "" )),
396+ idempotency_key = data .get ("idempotency_key" ),
397+ reason = data .get ("reason" ),
398+ target = data .get ("target" ) if isinstance (data .get ("target" ), dict ) else None ,
399+ correlation = data .get ("correlation" ) if isinstance (data .get ("correlation" ), dict ) else None ,
400+ workflow_id = data .get ("workflow_id" ),
401+ run_id = data .get ("run_id" ),
402+ workflow_type = data .get ("workflow_type" ),
403+ control_plane_outcome = data .get ("control_plane_outcome" ),
404+ raw = data ,
405+ )
406+
407+
365408class WorkflowHandle :
366409 """Convenience wrapper for operating on one workflow ID."""
367410
@@ -696,6 +739,68 @@ async def _do_request() -> httpx.Response:
696739 self .metrics .increment (CLIENT_REQUESTS , tags = tags )
697740 self .metrics .record (CLIENT_REQUEST_DURATION_SECONDS , time .perf_counter () - start , tags = tags )
698741
742+ async def _request_bridge_outcome (self , path : str , * , json : Any = None , context : str = "" ) -> dict [str , Any ]:
743+ start = time .perf_counter ()
744+ route = _route_for_metrics (path )
745+ status_code = "none"
746+ outcome = "pending"
747+
748+ async def _do_request () -> httpx .Response :
749+ resp = await self ._http .request (
750+ "POST" ,
751+ f"/api{ path } " ,
752+ headers = self ._headers (worker = False ),
753+ json = json ,
754+ )
755+ if resp .status_code != 422 :
756+ resp .raise_for_status ()
757+ return resp
758+
759+ try :
760+ try :
761+ resp = await self .retry_policy .execute (_do_request )
762+ except httpx .HTTPStatusError as exc :
763+ status_code = str (exc .response .status_code )
764+ outcome = "http_error"
765+ try :
766+ body = exc .response .json ()
767+ except ValueError :
768+ body = exc .response .text
769+ _raise_for_status (exc .response .status_code , body , context = context )
770+ raise
771+
772+ status_code = str (resp .status_code )
773+ if not resp .content :
774+ raise ServerError (
775+ resp .status_code ,
776+ {"reason" : "invalid_bridge_outcome" , "message" : "expected JSON object, got empty response" },
777+ )
778+ data = resp .json ()
779+ if not isinstance (data , dict ):
780+ raise ServerError (
781+ resp .status_code ,
782+ {
783+ "reason" : "invalid_bridge_outcome" ,
784+ "message" : f"expected JSON object, got { type (data ).__name__ } " ,
785+ },
786+ )
787+ outcome = "bridge_rejected" if resp .status_code == 422 else "ok"
788+ return data
789+ except Exception as exc :
790+ if outcome == "pending" :
791+ outcome = type (exc ).__name__
792+ raise
793+ finally :
794+ tags = {
795+ "method" : "POST" ,
796+ "route" : route ,
797+ "plane" : "control" ,
798+ "status_code" : status_code ,
799+ "outcome" : outcome ,
800+ }
801+ self .metrics .increment (CLIENT_REQUESTS , tags = tags )
802+ self .metrics .record (CLIENT_REQUEST_DURATION_SECONDS , time .perf_counter () - start , tags = tags )
803+
699804 async def get_cluster_info (self ) -> dict [str , Any ]:
700805 """Fetch server build identity, capabilities, and protocol manifests."""
701806 result = await self ._request ("GET" , "/cluster/info" , worker = False , context = "get_cluster_info" )
@@ -911,6 +1016,40 @@ async def get_history(self, workflow_id: str, run_id: str) -> Any:
9111016 "GET" , f"/workflows/{ workflow_id } /runs/{ run_id } /history" , context = workflow_id
9121017 )
9131018
1019+ async def send_webhook_bridge_event (
1020+ self ,
1021+ adapter : str ,
1022+ * ,
1023+ action : str ,
1024+ idempotency_key : str ,
1025+ target : dict [str , Any ],
1026+ input : dict [str , Any ] | None = None ,
1027+ correlation : dict [str , Any ] | None = None ,
1028+ ) -> BridgeAdapterOutcome :
1029+ """Send one bounded webhook bridge event and return its contract outcome.
1030+
1031+ The bridge endpoint intentionally returns machine-readable rejected
1032+ outcomes as HTTP 422. This method returns those outcomes instead of
1033+ raising :class:`InvalidArgument`, while auth and unexpected server
1034+ failures still use the normal SDK exception mapping.
1035+ """
1036+ body : dict [str , Any ] = {
1037+ "action" : action ,
1038+ "idempotency_key" : idempotency_key ,
1039+ "target" : target ,
1040+ }
1041+ if input is not None :
1042+ body ["input" ] = input
1043+ if correlation is not None :
1044+ body ["correlation" ] = correlation
1045+
1046+ data = await self ._request_bridge_outcome (
1047+ f"/bridge-adapters/webhook/{ quote (adapter , safe = '._:-' )} " ,
1048+ json = body ,
1049+ context = f"bridge adapter { adapter } " ,
1050+ )
1051+ return BridgeAdapterOutcome .from_dict (data )
1052+
9141053 async def signal_workflow (
9151054 self , workflow_id : str , signal_name : str , * , args : list [Any ] | None = None
9161055 ) -> None :
0 commit comments