Repository: PrefectHQ/prefect

Issue:
Events worker fails to connect to websockets when PREFECT_API_URL ends with a slash.
### First check

- [X] I added a descriptive title to this issue.
- [X] I used the GitHub search to find a similar issue and didn't find it.
- [X] I searched the Prefect documentation for this issue.
- [X] I checked that this issue is related to Prefect and not one of its dependencies.

### Bug summary

When prefect is configured with a `PREFECT_API_URL` that ends in a slash (`/`) the url used for connecting to the events websocket API ends up with a double slash (`//`) this results in a 403.

### Reproduction

```python3
You can reproduce this by updating your settings with (notice the trailing slash):

`prefect config set PREFECT_API_URL=https://api.prefect.cloud/api/accounts/<uuid>/workspaces/<uuid>/`

Then run a flow that would send events from the client, such as one that uses blocks. You'll see that the events worker fails to connect because it receives a 403.
```


### Error

```python3
(most recent call last):
  File "/Users/chrispickett/src/prefect/src/prefect/_internal/concurrency/services.py", line 119, in _run
    async with self._lifespan():
  File "/opt/homebrew/Cellar/python@3.10/3.10.9/Frameworks/Python.framework/Versions/3.10/lib/python3.10/contextlib.py", line 208, in __aenter__
    return await anext(self.gen)
  File "/Users/chrispickett/src/prefect/src/prefect/events/worker.py", line 30, in _lifespan
    async with self._client:
  File "/Users/chrispickett/src/prefect/src/prefect/events/clients.py", line 118, in __aenter__
    await self._reconnect()
  File "/Users/chrispickett/src/prefect/src/prefect/events/clients.py", line 136, in _reconnect
    self._websocket = await self._connect.__aenter__()
  File "/Users/chrispickett/.virtualenvs/prefect/lib/python3.10/site-packages/websockets/legacy/client.py", line 642, in __aenter__
    return await self
  File "/Users/chrispickett/.virtualenvs/prefect/lib/python3.10/site-packages/websockets/legacy/client.py", line 659, in __await_impl_timeout__
    return await asyncio.wait_for(self.__await_impl__(), self.open_timeout)
  File "/opt/homebrew/Cellar/python@3.10/3.10.9/Frameworks/Python.framework/Versions/3.10/lib/python3.10/asyncio/tasks.py", line 445, in wait_for
    return fut.result()
  File "/Users/chrispickett/.virtualenvs/prefect/lib/python3.10/site-packages/websockets/legacy/client.py", line 666, in __await_impl__
    await protocol.handshake(
  File "/Users/chrispickett/.virtualenvs/prefect/lib/python3.10/site-packages/websockets/legacy/client.py", line 332, in handshake
    raise InvalidStatusCode(status_code, response_headers)
websockets.exceptions.InvalidStatusCode: server rejected WebSocket connection: HTTP 403
```


### Versions

```Text
Version:             2.10.10
API version:         0.8.4
Python version:      3.10.9
Git commit:          15757edc
Built:               Fri, May 19, 2023 2:19 PM
OS/Arch:             darwin/arm64
Profile:             cloud
Server type:         cloud
```


### Additional context

_No response_


Discussion:

Resolving patch:
diff --git a/src/prefect/events/clients.py b/src/prefect/events/clients.py
--- a/src/prefect/events/clients.py
+++ b/src/prefect/events/clients.py
@@ -101,7 +101,12 @@ def __init__(
             checkpoint_every: How often the client should sync with the server to
                 confirm receipt of all previously sent events
         """
-        socket_url = api_url.replace("https://", "wss://").replace("http://", "ws://")
+        socket_url = (
+            api_url.replace("https://", "wss://")
+            .replace("http://", "ws://")
+            .rstrip("/")
+        )
+
         self._connect = connect(
             socket_url + "/events/in",
             extra_headers={"Authorization": f"bearer {api_key}"},


Test patch:
diff --git a/tests/events/test_cloud_events_client.py b/tests/events/test_cloud_events_client.py
--- a/tests/events/test_cloud_events_client.py
+++ b/tests/events/test_cloud_events_client.py
@@ -109,3 +109,20 @@ async def test_giving_up_after_negative_one_tries_is_a_noop(
 
     assert recorder.connections == 1
     assert recorder.events == []
+
+
+async def test_handles_api_url_with_trailing_slash(
+    events_api_url: str, example_event_1: Event, recorder: Recorder
+):
+    # Regression test for https://github.com/PrefectHQ/prefect/issues/9662
+    # where a configuration that has a trailing slash on the PREFECT_API_URL
+    # would cause the client to fail to connect with a 403 as the path would
+    # contain a double slash, ie: `wss:api.prefect.cloud/...//events/in`
+
+    events_api_url += "/"
+    async with PrefectCloudEventsClient(events_api_url, "my-token") as client:
+        await client.emit(example_event_1)
+
+    assert recorder.connections == 1
+    assert recorder.path == "/accounts/A/workspaces/W/events/in"
+    assert recorder.events == [example_event_1]