-
Notifications
You must be signed in to change notification settings - Fork 12
Fast join client #191
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
base: main
Are you sure you want to change the base?
Fast join client #191
Changes from all commits
8da8495
89f0f83
27164bf
c181fb4
0e7d156
fbc85b4
ba27a0d
bfad9f2
4fe84e2
3a7b8be
471b53a
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 |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ | |
| import aiortc | ||
|
|
||
| from getstream.common import telemetry | ||
| from getstream.stream_response import StreamResponse | ||
| from getstream.utils import StreamAsyncIOEventEmitter | ||
| from getstream.video.rtc.coordinator.ws import StreamAPIWS | ||
| from getstream.video.rtc.pb.stream.video.sfu.event import events_pb2 | ||
|
|
@@ -22,6 +23,7 @@ | |
| ConnectionOptions, | ||
| connect_websocket, | ||
| join_call, | ||
| fast_join_call, | ||
| watch_call, | ||
| ) | ||
| from getstream.video.rtc.track_util import ( | ||
|
|
@@ -53,6 +55,7 @@ def __init__( | |
| user_id: Optional[str] = None, | ||
| create: bool = True, | ||
| subscription_config: Optional[SubscriptionConfig] = None, | ||
| fast_join: bool = False, | ||
| **kwargs: Any, | ||
| ): | ||
| super().__init__() | ||
|
|
@@ -61,6 +64,7 @@ def __init__( | |
| self.call: Call = call | ||
| self.user_id: Optional[str] = user_id | ||
| self.create: bool = create | ||
| self.fast_join: bool = fast_join | ||
| self.kwargs: Dict[str, Any] = kwargs | ||
| self.running: bool = False | ||
| self.session_id: str = str(uuid.uuid4()) | ||
|
|
@@ -269,21 +273,38 @@ async def _connect_internal( | |
| "coordinator-join-call", | ||
| ) as span: | ||
| if not (ws_url or token): | ||
| join_response = await join_call( | ||
| self.call, | ||
| self.user_id, | ||
| "auto", | ||
| self.create, | ||
| self.local_sfu, | ||
| **self.kwargs, | ||
| ) | ||
| ws_url = join_response.data.credentials.server.ws_endpoint | ||
| token = join_response.data.credentials.token | ||
| self.join_response = join_response | ||
| logger.debug(f"coordinator join response: {join_response.data}") | ||
| span.set_attribute( | ||
| "credentials", join_response.data.credentials.to_json() | ||
| ) | ||
| if self.fast_join: | ||
| # Use fast join to get multiple edge credentials | ||
| fast_join_response = await fast_join_call( | ||
| self.call, | ||
| self.user_id, | ||
| "auto", | ||
| self.create, | ||
| self.local_sfu, | ||
| **self.kwargs, | ||
| ) | ||
| logger.debug( | ||
| f"Received {len(fast_join_response.data.credentials)} edge credentials for fast join" | ||
| ) | ||
|
|
||
| self._fast_join_response = fast_join_response | ||
| else: | ||
| # Use regular join | ||
| join_response = await join_call( | ||
| self.call, | ||
| self.user_id, | ||
| "auto", | ||
| self.create, | ||
| self.local_sfu, | ||
| **self.kwargs, | ||
| ) | ||
| ws_url = join_response.data.credentials.server.ws_endpoint | ||
| token = join_response.data.credentials.token | ||
| self.join_response = join_response | ||
| logger.debug(f"coordinator join response: {join_response.data}") | ||
| span.set_attribute( | ||
| "credentials", join_response.data.credentials.to_json() | ||
| ) | ||
|
|
||
| # Use provided session_id or current one | ||
| current_session_id = session_id or self.session_id | ||
|
|
@@ -295,12 +316,38 @@ async def _connect_internal( | |
| with telemetry.start_as_current_span( | ||
| "sfu-signaling-ws-connect", | ||
| ) as span: | ||
| self._ws_client, sfu_event = await connect_websocket( | ||
| token=token, | ||
| ws_url=ws_url, | ||
| session_id=current_session_id, | ||
| options=self._connection_options, | ||
| ) | ||
| # Handle fast join or regular join | ||
| if self.fast_join and hasattr(self, "_fast_join_response"): | ||
| # Fast join - race multiple edges | ||
| self._ws_client, sfu_event, selected_cred = await self._race_edges( | ||
| self._fast_join_response.data.credentials, current_session_id | ||
| ) | ||
|
|
||
| # Use the selected credentials | ||
| ws_url = selected_cred.server.ws_endpoint | ||
| token = selected_cred.token | ||
|
|
||
| #map it to standard join call object so that retry/migration can happen | ||
| self.join_response = StreamResponse( | ||
| response=self._fast_join_response._StreamResponse__response, | ||
| data=JoinCallResponse( | ||
| call=self._fast_join_response.data.call, | ||
| members=self._fast_join_response.data.members, | ||
| credentials=selected_cred, | ||
| stats_options=self._fast_join_response.data.stats_options, | ||
| duration=self._fast_join_response.data.duration, | ||
| ) | ||
| ) | ||
|
|
||
| span.set_attribute("credentials", selected_cred.to_json()) | ||
| else: | ||
| # Regular join - connect to single edge | ||
| self._ws_client, sfu_event = await connect_websocket( | ||
| token=token, | ||
| ws_url=ws_url, | ||
| session_id=current_session_id, | ||
| options=self._connection_options, | ||
| ) | ||
|
|
||
| self._ws_client.on_wildcard("*", _log_event) | ||
| self._ws_client.on_event("ice_trickle", self._on_ice_trickle) | ||
|
|
@@ -530,3 +577,55 @@ async def _restore_published_tracks(self): | |
| await self._peer_manager.restore_published_tracks() | ||
| except Exception as e: | ||
| logger.error("Failed to restore published tracks", exc_info=e) | ||
|
|
||
| async def _race_edges(self, credentials_list, session_id): | ||
| """Try multiple edge WebSocket connections sequentially and return the first successful one. | ||
|
|
||
| This method iterates through edge URLs one by one, attempting to connect to each. | ||
| The first edge that successfully connects is used, and the iteration stops. | ||
|
|
||
| Args: | ||
| credentials_list: List of Credentials to try | ||
| session_id: Session ID for the connection | ||
|
|
||
| Returns: | ||
| Tuple of (WebSocket client, SFU event, selected Credentials) | ||
|
|
||
| Raises: | ||
| SfuConnectionError: If all edge connections fail | ||
| """ | ||
| if not credentials_list: | ||
| raise SfuConnectionError("No edge credentials provided for racing") | ||
|
|
||
| logger.info(f"Trying {len(credentials_list)} edge connections sequentially") | ||
|
|
||
| errors = [] | ||
|
|
||
| # Try each edge sequentially | ||
| for cred in credentials_list: | ||
| logger.debug(f"Trying edge {cred.server.edge_name} at {cred.server.ws_endpoint}") | ||
|
|
||
| try: | ||
| # Attempt to connect to this edge | ||
| ws_client, sfu_event = await connect_websocket( | ||
| token=cred.token, | ||
| ws_url=cred.server.ws_endpoint, | ||
| session_id=session_id, | ||
| options=self._connection_options, | ||
| ) | ||
|
|
||
| # Success! Return the connection and credentials | ||
| logger.info( | ||
| f"Edge {cred.server.edge_name} connected successfully" | ||
| ) | ||
| return ws_client, sfu_event, cred | ||
|
|
||
| except Exception as e: | ||
| errors.append((cred.server.edge_name, str(e))) | ||
| # Continue to next edge | ||
|
|
||
| # All connections failed | ||
| error_msg = "All edge connections failed:\n" + "\n".join( | ||
| f" - {edge}: {error}" for edge, error in errors | ||
| ) | ||
| raise SfuConnectionError(error_msg) | ||
|
Comment on lines
+581
to
+631
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. Sequential iteration defeats the purpose of "racing" connections. The Sequential attempts mean:
Implement true parallel racing using async def _race_edges(self, credentials_list, session_id):
"""Race multiple edge WebSocket connections in parallel and return the first successful one.
Args:
credentials_list: List of Credentials to try
session_id: Session ID for the connection
Returns:
Tuple of (WebSocket client, SFU event, selected Credentials)
Raises:
SfuConnectionError: If all edge connections fail
"""
if not credentials_list:
raise SfuConnectionError("No edge credentials provided for racing")
logger.info(f"Racing {len(credentials_list)} edge connections in parallel")
# Create tasks for all edges
async def try_edge(cred):
try:
ws_client, sfu_event = await connect_websocket(
token=cred.token,
ws_url=cred.server.ws_endpoint,
session_id=session_id,
options=self._connection_options,
)
return (ws_client, sfu_event, cred, None)
except Exception as e:
return (None, None, cred, e)
tasks = [asyncio.create_task(try_edge(cred)) for cred in credentials_list]
try:
# Wait for the first successful connection
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
# Check if we got a success
for task in done:
ws_client, sfu_event, cred, error = task.result()
if error is None:
# Success! Cancel remaining tasks
for t in pending:
t.cancel()
logger.info(f"Edge {cred.server.edge_name} won the race")
return ws_client, sfu_event, cred
# First task completed but failed, keep waiting
while pending:
done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
for task in done:
ws_client, sfu_event, cred, error = task.result()
if error is None:
# Success! Cancel remaining tasks
for t in pending:
t.cancel()
logger.info(f"Edge {cred.server.edge_name} connected successfully")
return ws_client, sfu_event, cred
# All failed - gather errors
errors = []
for task in tasks:
_, _, cred, error = task.result()
if error:
errors.append((cred.server.edge_name, str(error)))
error_msg = "All edge connections failed:\n" + "\n".join(
f" - {edge}: {error}" for edge, error in errors
)
raise SfuConnectionError(error_msg)
finally:
# Ensure all tasks are cleaned up
for task in tasks:
if not task.done():
task.cancel()🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,7 +11,7 @@ | |
| from getstream.utils import build_body_dict | ||
|
|
||
| # Import the types we need from __init__ without creating circular imports | ||
| from getstream.video.rtc.models import JoinCallResponse | ||
| from getstream.video.rtc.models import JoinCallResponse, FastJoinCallResponse | ||
|
|
||
| logger = logging.getLogger("getstream.video.rtc.coordinator") | ||
|
|
||
|
|
@@ -79,3 +79,68 @@ async def join_call_coordinator_request( | |
| path_params=path_params, | ||
| json=json_body, | ||
| ) | ||
|
|
||
|
|
||
| async def fast_join_call_coordinator_request( | ||
| call: Call, | ||
| user_id: str, | ||
| create: bool = False, | ||
| data: Optional[CallRequest] = None, | ||
| ring: Optional[bool] = None, | ||
| notify: Optional[bool] = None, | ||
| video: Optional[bool] = None, | ||
| location: Optional[str] = None, | ||
| ) -> StreamResponse[FastJoinCallResponse]: | ||
| """Make a fast join request to get multiple edge credentials from the coordinator. | ||
|
|
||
| Args: | ||
| call: The call to join | ||
| user_id: The user ID to join the call with | ||
| create: Whether to create the call if it doesn't exist | ||
| data: Additional call data if creating | ||
| ring: Whether to ring other users | ||
| notify: Whether to notify other users | ||
| video: Whether to enable video | ||
| location: The preferred location | ||
|
|
||
| Returns: | ||
| A response containing the call information and an array of credentials for multiple edges | ||
| """ | ||
| # Create a token for this user | ||
| token = call.client.stream.create_token(user_id=user_id) | ||
|
|
||
| # Create a new client with this token | ||
| client = call.client.stream.__class__( | ||
| api_key=call.client.stream.api_key, | ||
| api_secret=call.client.stream.api_secret, | ||
| base_url=call.client.stream.base_url, | ||
| ) | ||
|
|
||
| # Set up authentication | ||
| client.token = token | ||
| client.headers["Authorization"] = token | ||
| client.client.headers["Authorization"] = token | ||
|
|
||
| # Prepare path parameters for the request | ||
| path_params = { | ||
| "type": call.call_type, | ||
| "id": call.id, | ||
| } | ||
|
|
||
| # Build the request body | ||
| json_body = build_body_dict( | ||
| location=location or "FRA", # Default to Frankfurt if not specified | ||
| create=create, | ||
| notify=notify, | ||
| ring=ring, | ||
| video=video, | ||
| data=data, | ||
| ) | ||
|
|
||
| # Make the POST request to fast join the call | ||
| return await client.post( | ||
| "/api/v2/video/call/{type}/{id}/fast_join", | ||
| FastJoinCallResponse, | ||
| path_params=path_params, | ||
| json=json_body, | ||
| ) | ||
|
Comment on lines
+84
to
+146
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. 🛠️ Refactor suggestion | 🟠 Major Significant code duplication with join_call_coordinator_request. The Consider refactoring to a shared internal function: async def _call_coordinator_request(
call: Call,
user_id: str,
endpoint: str,
response_type: type,
create: bool = False,
data: Optional[CallRequest] = None,
ring: Optional[bool] = None,
notify: Optional[bool] = None,
video: Optional[bool] = None,
location: Optional[str] = None,
):
"""Internal function to make coordinator requests."""
token = call.client.stream.create_token(user_id=user_id)
client = call.client.stream.__class__(
api_key=call.client.stream.api_key,
api_secret=call.client.stream.api_secret,
base_url=call.client.stream.base_url,
)
client.token = token
client.headers["Authorization"] = token
client.client.headers["Authorization"] = token
path_params = {"type": call.call_type, "id": call.id}
json_body = build_body_dict(
location=location or "FRA",
create=create,
notify=notify,
ring=ring,
video=video,
data=data,
)
return await client.post(
f"/api/v2/video/call/{{type}}/{{id}}/{endpoint}",
response_type,
path_params=path_params,
json=json_body,
)
async def join_call_coordinator_request(...) -> StreamResponse[JoinCallResponse]:
return await _call_coordinator_request(call, user_id, "join", JoinCallResponse, ...)
async def fast_join_call_coordinator_request(...) -> StreamResponse[FastJoinCallResponse]:
return await _call_coordinator_request(call, user_id, "fast_join", FastJoinCallResponse, ...)🤖 Prompt for AI Agents |
||
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.
Accessing private attribute via name mangling is fragile.
Line 332 accesses
self._fast_join_response._StreamResponse__responseusing Python name mangling to reach a private attribute. This is brittle and will break if theStreamResponseclass is refactored.Consider one of these alternatives:
StreamResponse:_fast_join_response:StreamResponseto be constructed without the httpx response if it's not needed:🤖 Prompt for AI Agents