This is an automated email from the ASF dual-hosted git repository.

mmodzelewski pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new 3c1a8ccf6 feat(python): add WebSocketConfig transport configuration 
(#4000)
3c1a8ccf6 is described below

commit 3c1a8ccf65eb67a57f4e92ffaeb92dd2df506765
Author: saie-ch <[email protected]>
AuthorDate: Tue Sep 8 00:56:38 2026 +0530

    feat(python): add WebSocketConfig transport configuration (#4000)
---
 .../actions/python-maturin/pre-merge/action.yml    |   5 +-
 .github/workflows/coverage-baseline.yml            |   5 +-
 .../websocket_config/websocket_client_config.rs    |  75 ++-
 core/sdk/src/prelude.rs                            |   4 +-
 examples/python/getting-started/consumer.py        |  17 +-
 examples/python/getting-started/producer.py        |  17 +-
 foreign/python/README.md                           |  11 +-
 foreign/python/apache_iggy.pyi                     | 209 +++++++-
 foreign/python/docker-compose.test.yml             |   2 +
 foreign/python/src/client.rs                       |  21 +-
 foreign/python/src/config.rs                       | 529 ++++++++++++++++++++-
 foreign/python/src/lib.rs                          |   4 +
 foreign/python/tests/test_websocket_config.py      | 522 ++++++++++++++++++++
 foreign/python/tests/utils.py                      |  11 +
 14 files changed, 1388 insertions(+), 44 deletions(-)

diff --git a/.github/actions/python-maturin/pre-merge/action.yml 
b/.github/actions/python-maturin/pre-merge/action.yml
index 53bf430dc..d8ab37ad6 100644
--- a/.github/actions/python-maturin/pre-merge/action.yml
+++ b/.github/actions/python-maturin/pre-merge/action.yml
@@ -175,8 +175,8 @@ runs:
 
         # TCP and HTTP ports come from server-start's own outputs rather than
         # duplicated literals, so a future default change here can't silently
-        # desync from what the server actually started with. QUIC stays a
-        # literal below because server-start exposes no output for it.
+        # desync from what the server actually started with. QUIC and WebSocket
+        # stay literals below because server-start exposes no output for them.
         tcp_port="${{ steps.iggy.outputs.tcp_address }}"
         http_port="${{ steps.iggy.outputs.http_address }}"
         tcp_port="${tcp_port##*:}"
@@ -191,6 +191,7 @@ runs:
         IGGY_SERVER_TCP_PORT="$tcp_port" \
         IGGY_SERVER_QUIC_PORT=8080 \
         IGGY_SERVER_HTTP_PORT="$http_port" \
+        IGGY_SERVER_WS_PORT=8092 \
         IGGY_SERVER_DOCKER_IMAGE=iggy-server:local \
           uv run --no-sync pytest tests/ -v \
             --junitxml=../../reports/python-junit.xml \
diff --git a/.github/workflows/coverage-baseline.yml 
b/.github/workflows/coverage-baseline.yml
index 76d12d1b0..b4bd7c315 100644
--- a/.github/workflows/coverage-baseline.yml
+++ b/.github/workflows/coverage-baseline.yml
@@ -350,8 +350,8 @@ jobs:
 
           # TCP and HTTP ports come from server-start's own outputs rather than
           # duplicated literals, so a future default change here can't silently
-          # desync from what the server actually started with. QUIC stays a
-          # literal below because server-start exposes no output for it.
+          # desync from what the server actually started with. QUIC and 
WebSocket
+          # stay literals below because server-start exposes no output for 
them.
           tcp_port="${{ steps.iggy.outputs.tcp_address }}"
           http_port="${{ steps.iggy.outputs.http_address }}"
           tcp_port="${tcp_port##*:}"
@@ -361,6 +361,7 @@ jobs:
           IGGY_SERVER_TCP_PORT="$tcp_port" \
           IGGY_SERVER_QUIC_PORT=8080 \
           IGGY_SERVER_HTTP_PORT="$http_port" \
+          IGGY_SERVER_WS_PORT=8092 \
           IGGY_SERVER_DOCKER_IMAGE=iggy-server:local \
             uv run --no-sync pytest tests/ -v \
               --junitxml=../../reports/python-junit.xml \
diff --git 
a/core/common/src/types/configuration/websocket_config/websocket_client_config.rs
 
b/core/common/src/types/configuration/websocket_config/websocket_client_config.rs
index 4295ab527..6fdd79179 100644
--- 
a/core/common/src/types/configuration/websocket_config/websocket_client_config.rs
+++ 
b/core/common/src/types/configuration/websocket_config/websocket_client_config.rs
@@ -55,9 +55,13 @@ pub struct WebSocketConfig {
     pub write_buffer_size: Option<usize>,
     /// Maximum write buffer size.
     pub max_write_buffer_size: Option<usize>,
-    /// Maximum message size.
+    /// Maximum message size, or `None` for no limit. Defaults to tungstenite's
+    /// own limit rather than `None`, so `None` always means the limit was 
lifted
+    /// deliberately.
     pub max_message_size: Option<usize>,
-    /// Maximum frame size.
+    /// Maximum frame size, or `None` for no limit. Defaults to tungstenite's 
own
+    /// limit rather than `None`, so `None` always means the limit was lifted
+    /// deliberately.
     pub max_frame_size: Option<usize>,
     /// Accept unmasked frames (client should typically keep as false).
     pub accept_unmasked_frames: bool,
@@ -111,13 +115,13 @@ impl WebSocketConfig {
             config = config.max_write_buffer_size(max_write_buf_size);
         }
 
-        if let Some(max_msg_size) = self.max_message_size {
-            config = config.max_message_size(Some(max_msg_size));
-        }
-
-        if let Some(max_frame_size) = self.max_frame_size {
-            config = config.max_frame_size(Some(max_frame_size));
-        }
+        // Set unconditionally, unlike the buffer sizes above: `None` here 
means
+        // "no limit", not "unset". `Default` seeds both from tungstenite's own
+        // values, so a `None` can only come from a caller that asked for the
+        // limit to be lifted, and skipping the setter would silently leave
+        // tungstenite's default in force instead.
+        config = config.max_message_size(self.max_message_size);
+        config = config.max_frame_size(self.max_frame_size);
 
         config = config.accept_unmasked_frames(self.accept_unmasked_frames);
 
@@ -189,3 +193,56 @@ impl Display for WebSocketConfig {
         )
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn default_should_carry_tungstenites_own_size_limits() {
+        let config = WebSocketConfig::default();
+        let tungstenite = TungsteniteConfig::default();
+
+        assert_eq!(config.max_message_size, tungstenite.max_message_size);
+        assert_eq!(config.max_frame_size, tungstenite.max_frame_size);
+        assert!(config.max_message_size.is_some());
+        assert!(config.max_frame_size.is_some());
+    }
+
+    #[test]
+    fn none_size_limits_should_reach_tungstenite_as_no_limit() {
+        let config = WebSocketConfig {
+            max_message_size: None,
+            max_frame_size: None,
+            ..Default::default()
+        };
+
+        let tungstenite = config.to_tungstenite_config();
+
+        assert_eq!(tungstenite.max_message_size, None);
+        assert_eq!(tungstenite.max_frame_size, None);
+    }
+
+    #[test]
+    fn default_size_limits_should_reach_tungstenite_unchanged() {
+        let tungstenite = WebSocketConfig::default().to_tungstenite_config();
+        let expected = TungsteniteConfig::default();
+
+        assert_eq!(tungstenite.max_message_size, expected.max_message_size);
+        assert_eq!(tungstenite.max_frame_size, expected.max_frame_size);
+    }
+
+    #[test]
+    fn explicit_size_limits_should_reach_tungstenite_unchanged() {
+        let config = WebSocketConfig {
+            max_message_size: Some(1024),
+            max_frame_size: Some(512),
+            ..Default::default()
+        };
+
+        let tungstenite = config.to_tungstenite_config();
+
+        assert_eq!(tungstenite.max_message_size, Some(1024));
+        assert_eq!(tungstenite.max_frame_size, Some(512));
+    }
+}
diff --git a/core/sdk/src/prelude.rs b/core/sdk/src/prelude.rs
index 79c6774b2..f239eec67 100644
--- a/core/sdk/src/prelude.rs
+++ b/core/sdk/src/prelude.rs
@@ -67,8 +67,8 @@ pub use iggy_common::{
     TcpClientConfigBuilder, TcpClientReconnectionConfig, Topic, 
TopicCreateOptions, TopicDetails,
     TopicPermissions, TopicUpdateOptions, TransportEndpoints, 
TransportProtocol, UserId, UserInfo,
     UserInfoDetails, UserStatus, UserUpdateOptions, Validatable, 
WebSocketClientConfig,
-    WebSocketClientConfigBuilder, WebSocketClientReconnectionConfig, defaults, 
locking,
-    topic_option_keys,
+    WebSocketClientConfigBuilder, WebSocketClientReconnectionConfig, 
WebSocketConfig, defaults,
+    locking, topic_option_keys,
 };
 pub use iggy_common::{
     Client, ClusterClient, ConsumerGroupClient, ConsumerOffsetClient, 
MessageClient,
diff --git a/examples/python/getting-started/consumer.py 
b/examples/python/getting-started/consumer.py
index 3a48f2a2e..16405d9d4 100755
--- a/examples/python/getting-started/consumer.py
+++ b/examples/python/getting-started/consumer.py
@@ -31,6 +31,7 @@ from apache_iggy import (
     ReceiveMessage,
     TcpConfig,
     TcpReconnectionConfig,
+    WebSocketConfig,
 )
 from loguru import logger
 
@@ -103,7 +104,9 @@ def parse_args() -> ArgNamespace:
     return ArgNamespace(**vars(args))
 
 
-def build_config(args: ArgNamespace) -> TcpConfig | QuicConfig | HttpConfig:
+def build_config(
+    args: ArgNamespace,
+) -> TcpConfig | QuicConfig | HttpConfig | WebSocketConfig:
     """Build the client configuration, TCP with auto-login and reconnection."""
 
     # IggyClient(...) also accepts a QuicConfig for the QUIC transport. To use
@@ -125,6 +128,18 @@ def build_config(args: ArgNamespace) -> TcpConfig | 
QuicConfig | HttpConfig:
     # after connecting:
     # return HttpConfig(api_url="http://127.0.0.1:3000";)
 
+    # IggyClient(...) also accepts a WebSocketConfig for the WebSocket 
transport.
+    # To use it, uncomment the return below and import 
WebSocketReconnectionConfig,
+    # which is left out above because only the commented block names it:
+    #
+    # return WebSocketConfig(
+    #     server_address="127.0.0.1:8092",
+    #     auto_login=AutoLogin.username_password(args.username, args.password),
+    #     reconnection=WebSocketReconnectionConfig(
+    #         enabled=True, interval=timedelta(seconds=1)
+    #     ),
+    # )
+
     return TcpConfig(
         server_address=args.tcp_server_address,
         auto_login=AutoLogin.username_password(args.username, args.password),
diff --git a/examples/python/getting-started/producer.py 
b/examples/python/getting-started/producer.py
index e8a8fea35..2cde76e57 100755
--- a/examples/python/getting-started/producer.py
+++ b/examples/python/getting-started/producer.py
@@ -30,6 +30,7 @@ from apache_iggy import (
     TcpConfig,
     TcpReconnectionConfig,
     TopicDetails,
+    WebSocketConfig,
 )
 from apache_iggy import SendMessage as Message
 from loguru import logger
@@ -102,7 +103,9 @@ def parse_args() -> ArgNamespace:
     return ArgNamespace(**vars(args))
 
 
-def build_config(args: ArgNamespace) -> TcpConfig | QuicConfig | HttpConfig:
+def build_config(
+    args: ArgNamespace,
+) -> TcpConfig | QuicConfig | HttpConfig | WebSocketConfig:
     """Build the client configuration, TCP with auto-login and reconnection."""
 
     # IggyClient(...) also accepts a QuicConfig for the QUIC transport. To use
@@ -124,6 +127,18 @@ def build_config(args: ArgNamespace) -> TcpConfig | 
QuicConfig | HttpConfig:
     # after connecting:
     # return HttpConfig(api_url="http://127.0.0.1:3000";)
 
+    # IggyClient(...) also accepts a WebSocketConfig for the WebSocket 
transport.
+    # To use it, uncomment the return below and import 
WebSocketReconnectionConfig,
+    # which is left out above because only the commented block names it:
+    #
+    # return WebSocketConfig(
+    #     server_address="127.0.0.1:8092",
+    #     auto_login=AutoLogin.username_password(args.username, args.password),
+    #     reconnection=WebSocketReconnectionConfig(
+    #         enabled=True, interval=timedelta(seconds=1)
+    #     ),
+    # )
+
     return TcpConfig(
         server_address=args.tcp_server_address,
         auto_login=AutoLogin.username_password(args.username, args.password),
diff --git a/foreign/python/README.md b/foreign/python/README.md
index 293bbdfbc..b4a33a116 100644
--- a/foreign/python/README.md
+++ b/foreign/python/README.md
@@ -134,8 +134,8 @@ running prek / committing / pushing. This list is not 
exhaustive and other hook
 
 ## Client Configuration
 
-`IggyClient` takes a server address, a `TcpConfig`, a `QuicConfig`, or an
-`HttpConfig`:
+`IggyClient` takes a server address, a `TcpConfig`, a `QuicConfig`, an
+`HttpConfig`, or a `WebSocketConfig`:
 
 ```python
 import asyncio
@@ -169,9 +169,10 @@ async def main():
 asyncio.run(main())
 ```
 
-`IggyClient(...)` also accepts a `QuicConfig` for the QUIC transport and an
-`HttpConfig` for the HTTP transport;
-`examples/python/getting-started/producer.py` shows either swap in context.
+`IggyClient(...)` also accepts a `QuicConfig` for the QUIC transport, an
+`HttpConfig` for the HTTP transport, and a `WebSocketConfig` for the WebSocket
+transport. `examples/python/getting-started/producer.py` shows each swap in
+context.
 
 `HttpConfig` differs from TCP in two ways. There is no reconnection policy and 
no
 `AutoLogin`: `connect()` does not dial over HTTP, but it does start the
diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi
index ffed2857d..901af64aa 100644
--- a/foreign/python/apache_iggy.pyi
+++ b/foreign/python/apache_iggy.pyi
@@ -68,6 +68,9 @@ __all__ = [
     "UserInfo",
     "UserInfoDetails",
     "UserStatus",
+    "WebSocketConfig",
+    "WebSocketFramingConfig",
+    "WebSocketReconnectionConfig",
 ]
 
 class AutoCommit:
@@ -975,21 +978,27 @@ class IggyClient:
     It provides asynchronous functionality through the contained runtime.
     """
     def __new__(
-        cls, conn: TcpConfig | QuicConfig | HttpConfig | builtins.str | None = 
None
+        cls,
+        conn: TcpConfig
+        | QuicConfig
+        | HttpConfig
+        | WebSocketConfig
+        | builtins.str
+        | None = None,
     ) -> IggyClient:
         r"""
         Constructs a new IggyClient from a TCP server address, a `TcpConfig`, a
-        `QuicConfig`, or an `HttpConfig`. This initializes a new runtime for
-        asynchronous operations.
+        `QuicConfig`, an `HttpConfig`, or a `WebSocketConfig`. This 
initializes a
+        new runtime for asynchronous operations.
         Future versions might utilize asyncio for more Pythonic async.
 
         Args:
-            conn: A `host:port` address, a `TcpConfig`, a `QuicConfig`, or an
-                `HttpConfig`. Defaults to `127.0.0.1:8090` over TCP with 
auto-login
-                disabled. A malformed address is reported differently 
depending on
-                the form: the string form raises `RuntimeError` here, while
-                `TcpConfig`/`QuicConfig`/`HttpConfig` raise `ValueError` when 
they
-                are constructed, before any of them ever reaches this call. 
Neither
+            conn: A `host:port` address, a `TcpConfig`, a `QuicConfig`, an
+                `HttpConfig`, or a `WebSocketConfig`. Defaults to 
`127.0.0.1:8090`
+                over TCP with auto-login disabled. A malformed address is 
reported
+                differently depending on the form: the string form raises
+                `RuntimeError` here, while every config type raises 
`ValueError`
+                when it is constructed, before any of them reaches this call. 
Neither
                 exception is a subclass of the other.
 
         Raises:
@@ -2094,7 +2103,7 @@ class QuicConfig:
                 seconds) instead, since `configure()` skips the setter 
entirely when
                 zero. Defaults to 10 seconds.
             validate_certificate: Whether to validate the server certificate. 
Defaults
-                to disabled, unlike the TCP and WebSocket transports.
+                to disabled; only the TCP transport validates by default.
 
         Raises:
             ValueError: If `server_address` or `client_address` is not a valid
@@ -3002,6 +3011,186 @@ class UserInfoDetails:
         The permissions of the user, or `None` when the user has none assigned.
         """
 
[email protected]
+class WebSocketConfig:
+    r"""
+    Configuration for the WebSocket transport, accepted by `IggyClient(...)`.
+
+    Every field is keyword-only and optional.
+    """
+    @property
+    def server_address(self) -> builtins.str: ...
+    @property
+    def auto_login(self) -> AutoLogin: ...
+    @property
+    def reconnection(self) -> WebSocketReconnectionConfig: ...
+    @property
+    def heartbeat_interval(self) -> datetime.timedelta: ...
+    @property
+    def framing(self) -> WebSocketFramingConfig: ...
+    @property
+    def tls_enabled(self) -> builtins.bool: ...
+    @property
+    def tls_domain(self) -> builtins.str: ...
+    @property
+    def tls_ca_file(self) -> builtins.str | None: ...
+    @property
+    def tls_validate_certificate(self) -> builtins.bool: ...
+    def __new__(
+        cls,
+        *,
+        server_address: builtins.str | None = None,
+        auto_login: AutoLogin | None = None,
+        reconnection: WebSocketReconnectionConfig | None = None,
+        heartbeat_interval: datetime.timedelta | None = None,
+        framing: WebSocketFramingConfig | None = None,
+        tls_enabled: builtins.bool | None = None,
+        tls_domain: builtins.str | None = None,
+        tls_ca_file: builtins.str | None = None,
+        tls_validate_certificate: builtins.bool | None = None,
+    ) -> WebSocketConfig:
+        r"""
+        Constructs a WebSocket configuration.
+
+        Args:
+            server_address: `host:port` of the Iggy server. Defaults to 
`127.0.0.1:8092`.
+            auto_login: Credentials replayed on every connect. Defaults to 
`AutoLogin.disabled()`.
+            reconnection: Reconnection policy. Defaults to 
`WebSocketReconnectionConfig()`.
+            heartbeat_interval: Interval of heartbeats sent by the client. 
Defaults to 5 seconds.
+            framing: Frame- and buffer-level options. Defaults to 
`WebSocketFramingConfig()`.
+            tls_enabled: Whether to connect over TLS. Defaults to disabled.
+            tls_domain: Domain to validate the certificate against. Defaults to
+                `localhost`. Empty means it is taken from the IP 
`server_address`
+                resolves to.
+            tls_ca_file: Path to the CA file for TLS. Read only when 
`tls_enabled`
+                and `tls_validate_certificate` are both on; with either one 
off it
+                is kept but never consulted, so pairing it with
+                `tls_validate_certificate=False` pins nothing.
+            tls_validate_certificate: Whether to validate the server 
certificate.
+                Defaults to `False`; only the TCP transport validates by 
default.
+                Disabling this accepts any certificate the server presents,
+                including self-signed and mismatched ones, and takes precedence
+                over `tls_ca_file`.
+
+        Raises:
+            ValueError: If `server_address` is not a valid `host:port` pair, 
if a
+                duration is negative, or if `heartbeat_interval` is zero.
+        """
+    def __repr__(self) -> builtins.str: ...
+
[email protected]
+class WebSocketFramingConfig:
+    r"""
+    Frame- and buffer-level options passed through to the underlying WebSocket
+    implementation, accepted by `WebSocketConfig`'s `framing` argument.
+
+    Every field is keyword-only and optional; unset fields fall back to the
+    underlying WebSocket library's own defaults.
+    """
+    @property
+    def read_buffer_size(self) -> builtins.int | None: ...
+    @property
+    def write_buffer_size(self) -> builtins.int | None: ...
+    @property
+    def max_write_buffer_size(self) -> builtins.int | None: ...
+    @property
+    def max_message_size(self) -> builtins.int | None: ...
+    @property
+    def max_frame_size(self) -> builtins.int | None: ...
+    @property
+    def accept_unmasked_frames(self) -> builtins.bool: ...
+    def __new__(
+        cls,
+        *,
+        read_buffer_size: builtins.int | None = None,
+        write_buffer_size: builtins.int | None = None,
+        max_write_buffer_size: builtins.int | None = None,
+        max_message_size: builtins.int | None = 64 << 20,
+        max_frame_size: builtins.int | None = 16 << 20,
+        accept_unmasked_frames: builtins.bool | None = None,
+    ) -> WebSocketFramingConfig:
+        r"""
+        Constructs a WebSocket framing configuration.
+
+        Args:
+            read_buffer_size: Read buffer size in bytes. Defaults to 128 KiB.
+            write_buffer_size: Write buffer size in bytes. Defaults to 128 KiB.
+            max_write_buffer_size: Maximum write buffer size in bytes. 
Defaults to
+                unbounded, which reads back as the largest value a 
pointer-sized
+                unsigned integer holds rather than as `None`.
+            max_message_size: Maximum message size in bytes, or an explicit 
`None`
+                to lift the limit entirely. Omitting the argument is not the 
same
+                as passing `None`: it keeps the underlying default of 64 MiB.
+                Lifting the limit lets a peer queue an arbitrarily large 
message
+                in memory, so prefer a finite value.
+            max_frame_size: Maximum frame size in bytes, or an explicit `None` 
to
+                lift the limit entirely. Omitting the argument keeps the
+                underlying default of 16 MiB, with the same caveat as
+                `max_message_size`.
+            accept_unmasked_frames: Whether to accept unmasked frames. 
Defaults to
+                `False`; clients should typically keep this off for RFC 
compliance.
+
+        Raises:
+            ValueError: If a numeric field is outside the range of a 
pointer-sized
+                unsigned integer, or if `max_write_buffer_size` does not come 
out
+                greater than `write_buffer_size`. tungstenite enforces the same
+                invariant with an `assert!` at connect time, which would 
otherwise
+                surface as an unrecoverable Rust panic instead of a 
`ValueError`.
+            OverflowError: If a numeric field does not fit a signed 128-bit 
integer,
+                raised by the underlying conversion before this constructor 
runs.
+        """
+    def __repr__(self) -> builtins.str: ...
+
[email protected]
+class WebSocketReconnectionConfig:
+    r"""
+    How the WebSocket client reconnects after the connection to the server is 
lost.
+    """
+    @property
+    def enabled(self) -> builtins.bool: ...
+    @property
+    def max_retries(self) -> builtins.int | None: ...
+    @property
+    def interval(self) -> datetime.timedelta: ...
+    @property
+    def reestablish_after(self) -> datetime.timedelta: ...
+    def __new__(
+        cls,
+        *,
+        enabled: builtins.bool | None = None,
+        max_retries: builtins.int | None = None,
+        interval: datetime.timedelta | None = None,
+        reestablish_after: datetime.timedelta | None = None,
+    ) -> WebSocketReconnectionConfig:
+        r"""
+        Constructs a reconnection policy.
+
+        Args:
+            enabled: Whether to reconnect at all. Defaults to enabled.
+            max_retries: Redials of the configured server address after the 
first
+                attempt, or `None` for unlimited; `0` still makes that first
+                attempt. Unlike the TCP transport, WebSocket redials the one
+                address it was configured with rather than walking a cluster
+                roster, so this counts dials. Defaults to unlimited, which 
means
+                a call awaited while the server is down never returns:
+                `connect()` waits inside the retry loop, as do 
`send_messages()`
+                and `poll_messages()` once auto-login is configured. Set a 
finite
+                number for request/reply style usage, so a call fails instead.
+            interval: Delay before each redial. Defaults to 1 second.
+            reestablish_after: Cooldown before redialing after a previously
+                successful connection, measured from when it was established, 
so
+                a session that outlived the interval is redialed at once. 
Applied
+                from the first redial onward, not to the initial connect.
+                Defaults to 5 seconds.
+
+        Raises:
+            ValueError: If a duration is negative, if `max_retries` is outside 
the
+                range of an unsigned 32-bit integer, or if `interval` is zero.
+            OverflowError: If `max_retries` does not fit a signed 64-bit 
integer,
+                raised by the underlying conversion before this constructor 
runs.
+        """
+    def __repr__(self) -> builtins.str: ...
+
 @typing.final
 class UserStatus(enum.Enum):
     r"""
diff --git a/foreign/python/docker-compose.test.yml 
b/foreign/python/docker-compose.test.yml
index 665df9121..c16eded3c 100644
--- a/foreign/python/docker-compose.test.yml
+++ b/foreign/python/docker-compose.test.yml
@@ -33,6 +33,7 @@ services:
       - "3000:3000"
       - "8080:8080"
       - "8090:8090"
+      - "8092:8092"
     environment:
       - IGGY_HTTP_ADDRESS=0.0.0.0:3000
       - IGGY_TCP_ADDRESS=0.0.0.0:8090
@@ -65,6 +66,7 @@ services:
       - IGGY_SERVER_TCP_PORT=8090
       - IGGY_SERVER_HTTP_PORT=3000
       - IGGY_SERVER_QUIC_PORT=8080
+      - IGGY_SERVER_WS_PORT=8092
       - PYTHONPATH=/workspace/foreign/python
       - PYTEST_ARGS=-v --tb=short
     volumes:
diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs
index 168ef2f6d..0fdef39d9 100644
--- a/foreign/python/src/client.rs
+++ b/foreign/python/src/client.rs
@@ -94,17 +94,17 @@ fn resolve_topic_params(
 #[pymethods]
 impl IggyClient {
     /// Constructs a new IggyClient from a TCP server address, a `TcpConfig`, a
-    /// `QuicConfig`, or an `HttpConfig`. This initializes a new runtime for
-    /// asynchronous operations.
+    /// `QuicConfig`, an `HttpConfig`, or a `WebSocketConfig`. This 
initializes a
+    /// new runtime for asynchronous operations.
     /// Future versions might utilize asyncio for more Pythonic async.
     ///
     /// Args:
-    ///     conn: A `host:port` address, a `TcpConfig`, a `QuicConfig`, or an
-    ///         `HttpConfig`. Defaults to `127.0.0.1:8090` over TCP with 
auto-login
-    ///         disabled. A malformed address is reported differently 
depending on
-    ///         the form: the string form raises `RuntimeError` here, while
-    ///         `TcpConfig`/`QuicConfig`/`HttpConfig` raise `ValueError` when 
they
-    ///         are constructed, before any of them ever reaches this call. 
Neither
+    ///     conn: A `host:port` address, a `TcpConfig`, a `QuicConfig`, an
+    ///         `HttpConfig`, or a `WebSocketConfig`. Defaults to 
`127.0.0.1:8090`
+    ///         over TCP with auto-login disabled. A malformed address is 
reported
+    ///         differently depending on the form: the string form raises
+    ///         `RuntimeError` here, while every config type raises 
`ValueError`
+    ///         when it is constructed, before any of them reaches this call. 
Neither
     ///         exception is a subclass of the other.
     ///
     /// Raises:
@@ -115,7 +115,7 @@ impl IggyClient {
     #[pyo3(signature = (conn=None))]
     fn new(
         #[gen_stub(override_type(
-            type_repr = "TcpConfig | QuicConfig | HttpConfig | builtins.str | 
None"
+            type_repr = "TcpConfig | QuicConfig | HttpConfig | WebSocketConfig 
| builtins.str | None"
         ))]
         conn: Option<PyClientConfig>,
     ) -> PyResult<Self> {
@@ -146,6 +146,9 @@ impl IggyClient {
             Some(PyClientConfig::Http(config)) => ClientWrapper::Http(
                 
HttpClient::create(config.client_config()).map_err(to_runtime_error)?,
             ),
+            Some(PyClientConfig::WebSocket(config)) => 
ClientWrapper::WebSocket(
+                
WebSocketClient::create(config.client_config()).map_err(to_runtime_error)?,
+            ),
             None => ClientWrapper::Tcp(
                 TcpClient::create(Arc::new(TcpClientConfig::default()))
                     .map_err(to_runtime_error)?,
diff --git a/foreign/python/src/config.rs b/foreign/python/src/config.rs
index f699a2359..e685d048c 100644
--- a/foreign/python/src/config.rs
+++ b/foreign/python/src/config.rs
@@ -22,6 +22,9 @@ use iggy::prelude::{
     QuicClientReconnectionConfig as RustQuicClientReconnectionConfig,
     TcpClientConfig as RustTcpClientConfig, TcpClientConfigBuilder,
     TcpClientReconnectionConfig as RustTcpClientReconnectionConfig,
+    WebSocketClientConfig as RustWebSocketClientConfig, 
WebSocketClientConfigBuilder,
+    WebSocketClientReconnectionConfig as RustWebSocketClientReconnectionConfig,
+    WebSocketConfig as RustWebSocketFramingConfig,
 };
 use pyo3::exceptions::PyValueError;
 use pyo3::prelude::*;
@@ -571,7 +574,7 @@ impl QuicConfig {
     ///         seconds) instead, since `configure()` skips the setter 
entirely when
     ///         zero. Defaults to 10 seconds.
     ///     validate_certificate: Whether to validate the server certificate. 
Defaults
-    ///         to disabled, unlike the TCP and WebSocket transports.
+    ///         to disabled; only the TCP transport validates by default.
     ///
     /// Raises:
     ///     ValueError: If `server_address` or `client_address` is not a valid
@@ -964,6 +967,466 @@ impl HttpConfig {
     }
 }
 
+/// How the WebSocket client reconnects after the connection to the server is 
lost.
+#[gen_stub_pyclass]
+#[pyclass(from_py_object)]
+#[derive(Clone)]
+pub struct WebSocketReconnectionConfig {
+    pub(crate) inner: RustWebSocketClientReconnectionConfig,
+}
+
+#[gen_stub_pymethods]
+#[pymethods]
+impl WebSocketReconnectionConfig {
+    /// Constructs a reconnection policy.
+    ///
+    /// Args:
+    ///     enabled: Whether to reconnect at all. Defaults to enabled.
+    ///     max_retries: Redials of the configured server address after the 
first
+    ///         attempt, or `None` for unlimited; `0` still makes that first
+    ///         attempt. Unlike the TCP transport, WebSocket redials the one
+    ///         address it was configured with rather than walking a cluster
+    ///         roster, so this counts dials. Defaults to unlimited, which 
means
+    ///         a call awaited while the server is down never returns:
+    ///         `connect()` waits inside the retry loop, as do 
`send_messages()`
+    ///         and `poll_messages()` once auto-login is configured. Set a 
finite
+    ///         number for request/reply style usage, so a call fails instead.
+    ///     interval: Delay before each redial. Defaults to 1 second.
+    ///     reestablish_after: Cooldown before redialing after a previously
+    ///         successful connection, measured from when it was established, 
so
+    ///         a session that outlived the interval is redialed at once. 
Applied
+    ///         from the first redial onward, not to the initial connect.
+    ///         Defaults to 5 seconds.
+    ///
+    /// Raises:
+    ///     ValueError: If a duration is negative, if `max_retries` is outside 
the
+    ///         range of an unsigned 32-bit integer, or if `interval` is zero.
+    ///     OverflowError: If `max_retries` does not fit a signed 64-bit 
integer,
+    ///         raised by the underlying conversion before this constructor 
runs.
+    #[new]
+    #[pyo3(signature = (*, enabled=None, max_retries=None, interval=None, 
reestablish_after=None))]
+    fn new(
+        #[gen_stub(override_type(type_repr = "builtins.bool | None"))] 
enabled: Option<bool>,
+        #[gen_stub(override_type(type_repr = "builtins.int | None"))] 
max_retries: Option<i64>,
+        #[gen_stub(override_type(type_repr = "datetime.timedelta | None", 
imports=("datetime")))]
+        interval: Option<Py<PyDelta>>,
+        #[gen_stub(override_type(type_repr = "datetime.timedelta | None", 
imports=("datetime")))]
+        reestablish_after: Option<Py<PyDelta>>,
+    ) -> PyResult<Self> {
+        let defaults = RustWebSocketClientReconnectionConfig::default();
+        let enabled = enabled.unwrap_or(defaults.enabled);
+        let max_retries = max_retries
+            .map(|max_retries| u32_param(max_retries, "max_retries"))
+            .transpose()?;
+        let interval = interval
+            .as_ref()
+            .map(py_delta_to_iggy_duration)
+            .transpose()?
+            .map(|interval| reject_zero(interval, "interval"))
+            .transpose()?
+            .unwrap_or(defaults.interval);
+        Ok(Self {
+            inner: RustWebSocketClientReconnectionConfig {
+                enabled,
+                max_retries,
+                interval,
+                reestablish_after: reestablish_after
+                    .as_ref()
+                    .map(py_delta_to_iggy_duration)
+                    .transpose()?
+                    .unwrap_or(defaults.reestablish_after),
+            },
+        })
+    }
+
+    #[getter]
+    fn enabled(&self) -> bool {
+        self.inner.enabled
+    }
+
+    #[gen_stub(override_return_type(type_repr = "builtins.int | None"))]
+    #[getter]
+    fn max_retries(&self) -> Option<u32> {
+        self.inner.max_retries
+    }
+
+    #[gen_stub(override_return_type(type_repr = "datetime.timedelta", 
imports=("datetime")))]
+    #[getter]
+    fn interval<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyDelta>> {
+        iggy_duration_to_py_delta(py, self.inner.interval.get())
+    }
+
+    #[gen_stub(override_return_type(type_repr = "datetime.timedelta", 
imports=("datetime")))]
+    #[getter]
+    fn reestablish_after<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, 
PyDelta>> {
+        iggy_duration_to_py_delta(py, self.inner.reestablish_after)
+    }
+
+    fn __repr__(&self) -> String {
+        let max_retries = match self.inner.max_retries {
+            Some(max_retries) => max_retries.to_string(),
+            None => "None".to_owned(),
+        };
+        format!(
+            "WebSocketReconnectionConfig(enabled={}, 
max_retries={max_retries}, interval={}, reestablish_after={})",
+            python_bool(self.inner.enabled),
+            duration_repr(self.inner.interval.get()),
+            duration_repr(self.inner.reestablish_after),
+        )
+    }
+}
+
+/// Frame- and buffer-level options passed through to the underlying WebSocket
+/// implementation, accepted by `WebSocketConfig`'s `framing` argument.
+///
+/// Every field is keyword-only and optional; unset fields fall back to the
+/// underlying WebSocket library's own defaults.
+#[gen_stub_pyclass]
+#[pyclass(from_py_object)]
+#[derive(Clone)]
+pub struct WebSocketFramingConfig {
+    pub(crate) inner: RustWebSocketFramingConfig,
+}
+
+#[gen_stub_pymethods]
+#[pymethods]
+impl WebSocketFramingConfig {
+    /// Constructs a WebSocket framing configuration.
+    ///
+    /// Args:
+    ///     read_buffer_size: Read buffer size in bytes. Defaults to 128 KiB.
+    ///     write_buffer_size: Write buffer size in bytes. Defaults to 128 KiB.
+    ///     max_write_buffer_size: Maximum write buffer size in bytes. 
Defaults to
+    ///         unbounded, which reads back as the largest value a 
pointer-sized
+    ///         unsigned integer holds rather than as `None`.
+    ///     max_message_size: Maximum message size in bytes, or an explicit 
`None`
+    ///         to lift the limit entirely. Omitting the argument is not the 
same
+    ///         as passing `None`: it keeps the underlying default of 64 MiB.
+    ///         Lifting the limit lets a peer queue an arbitrarily large 
message
+    ///         in memory, so prefer a finite value.
+    ///     max_frame_size: Maximum frame size in bytes, or an explicit `None` 
to
+    ///         lift the limit entirely. Omitting the argument keeps the
+    ///         underlying default of 16 MiB, with the same caveat as
+    ///         `max_message_size`.
+    ///     accept_unmasked_frames: Whether to accept unmasked frames. 
Defaults to
+    ///         `False`; clients should typically keep this off for RFC 
compliance.
+    ///
+    /// Raises:
+    ///     ValueError: If a numeric field is outside the range of a 
pointer-sized
+    ///         unsigned integer, or if `max_write_buffer_size` does not come 
out
+    ///         greater than `write_buffer_size`. tungstenite enforces the same
+    ///         invariant with an `assert!` at connect time, which would 
otherwise
+    ///         surface as an unrecoverable Rust panic instead of a 
`ValueError`.
+    ///     OverflowError: If a numeric field does not fit a signed 128-bit 
integer,
+    ///         raised by the underlying conversion before this constructor 
runs.
+    #[new]
+    #[pyo3(signature = (
+        *,
+        read_buffer_size=None,
+        write_buffer_size=None,
+        max_write_buffer_size=None,
+        max_message_size=64 << 20,
+        max_frame_size=16 << 20,
+        accept_unmasked_frames=None,
+    ))]
+    fn new(
+        #[gen_stub(override_type(type_repr = "builtins.int | None"))] 
read_buffer_size: Option<
+            i128,
+        >,
+        #[gen_stub(override_type(type_repr = "builtins.int | None"))] 
write_buffer_size: Option<
+            i128,
+        >,
+        #[gen_stub(override_type(type_repr = "builtins.int | None"))] 
max_write_buffer_size: Option<
+            i128,
+        >,
+        #[gen_stub(override_type(type_repr = "builtins.int | None"))] 
max_message_size: Option<
+            i128,
+        >,
+        #[gen_stub(override_type(type_repr = "builtins.int | None"))] 
max_frame_size: Option<i128>,
+        #[gen_stub(override_type(type_repr = "builtins.bool | None"))]
+        accept_unmasked_frames: Option<bool>,
+    ) -> PyResult<Self> {
+        let mut inner = RustWebSocketFramingConfig::default();
+        if let Some(read_buffer_size) = read_buffer_size {
+            inner.read_buffer_size = Some(usize_param(read_buffer_size, 
"read_buffer_size")?);
+        }
+        if let Some(write_buffer_size) = write_buffer_size {
+            inner.write_buffer_size = Some(usize_param(write_buffer_size, 
"write_buffer_size")?);
+        }
+        if let Some(max_write_buffer_size) = max_write_buffer_size {
+            inner.max_write_buffer_size =
+                Some(usize_param(max_write_buffer_size, 
"max_write_buffer_size")?);
+        }
+        // Assigned unconditionally, unlike the buffer sizes above: `None` here
+        // means "no limit", and pyo3 cannot tell an omitted argument from an
+        // explicit `None` on its own. The signature defaults carry the
+        // underlying limits instead, so omission lands on `Some(default)` and
+        // only an explicit `None` reaches this as `None`.
+        inner.max_message_size = max_message_size
+            .map(|max_message_size| usize_param(max_message_size, 
"max_message_size"))
+            .transpose()?;
+        inner.max_frame_size = max_frame_size
+            .map(|max_frame_size| usize_param(max_frame_size, 
"max_frame_size"))
+            .transpose()?;
+        if let Some(accept_unmasked_frames) = accept_unmasked_frames {
+            inner.accept_unmasked_frames = accept_unmasked_frames;
+        }
+        if let (Some(write_buffer_size), Some(max_write_buffer_size)) =
+            (inner.write_buffer_size, inner.max_write_buffer_size)
+            && max_write_buffer_size <= write_buffer_size
+        {
+            return Err(PyValueError::new_err(format!(
+                "'max_write_buffer_size' ({max_write_buffer_size}) must be 
greater than \
+                 'write_buffer_size' ({write_buffer_size})"
+            )));
+        }
+
+        Ok(Self { inner })
+    }
+
+    #[gen_stub(override_return_type(type_repr = "builtins.int | None"))]
+    #[getter]
+    fn read_buffer_size(&self) -> Option<usize> {
+        self.inner.read_buffer_size
+    }
+
+    #[gen_stub(override_return_type(type_repr = "builtins.int | None"))]
+    #[getter]
+    fn write_buffer_size(&self) -> Option<usize> {
+        self.inner.write_buffer_size
+    }
+
+    #[gen_stub(override_return_type(type_repr = "builtins.int | None"))]
+    #[getter]
+    fn max_write_buffer_size(&self) -> Option<usize> {
+        self.inner.max_write_buffer_size
+    }
+
+    #[gen_stub(override_return_type(type_repr = "builtins.int | None"))]
+    #[getter]
+    fn max_message_size(&self) -> Option<usize> {
+        self.inner.max_message_size
+    }
+
+    #[gen_stub(override_return_type(type_repr = "builtins.int | None"))]
+    #[getter]
+    fn max_frame_size(&self) -> Option<usize> {
+        self.inner.max_frame_size
+    }
+
+    #[getter]
+    fn accept_unmasked_frames(&self) -> bool {
+        self.inner.accept_unmasked_frames
+    }
+
+    fn __repr__(&self) -> String {
+        let optional_usize = |value: Option<usize>| match value {
+            Some(value) => value.to_string(),
+            None => "None".to_owned(),
+        };
+        format!(
+            "WebSocketFramingConfig(read_buffer_size={}, write_buffer_size={}, 
max_write_buffer_size={}, max_message_size={}, max_frame_size={}, 
accept_unmasked_frames={})",
+            optional_usize(self.inner.read_buffer_size),
+            optional_usize(self.inner.write_buffer_size),
+            optional_usize(self.inner.max_write_buffer_size),
+            optional_usize(self.inner.max_message_size),
+            optional_usize(self.inner.max_frame_size),
+            python_bool(self.inner.accept_unmasked_frames),
+        )
+    }
+}
+
+/// Configuration for the WebSocket transport, accepted by `IggyClient(...)`.
+///
+/// Every field is keyword-only and optional.
+#[gen_stub_pyclass]
+#[pyclass(from_py_object)]
+#[derive(Clone)]
+pub struct WebSocketConfig {
+    inner: Arc<RustWebSocketClientConfig>,
+}
+
+impl WebSocketConfig {
+    /// The configuration in the shape `WebSocketClient::create` expects.
+    pub(crate) fn client_config(&self) -> Arc<RustWebSocketClientConfig> {
+        self.inner.clone()
+    }
+}
+
+#[gen_stub_pymethods]
+#[pymethods]
+impl WebSocketConfig {
+    /// Constructs a WebSocket configuration.
+    ///
+    /// Args:
+    ///     server_address: `host:port` of the Iggy server. Defaults to 
`127.0.0.1:8092`.
+    ///     auto_login: Credentials replayed on every connect. Defaults to 
`AutoLogin.disabled()`.
+    ///     reconnection: Reconnection policy. Defaults to 
`WebSocketReconnectionConfig()`.
+    ///     heartbeat_interval: Interval of heartbeats sent by the client. 
Defaults to 5 seconds.
+    ///     framing: Frame- and buffer-level options. Defaults to 
`WebSocketFramingConfig()`.
+    ///     tls_enabled: Whether to connect over TLS. Defaults to disabled.
+    ///     tls_domain: Domain to validate the certificate against. Defaults to
+    ///         `localhost`. Empty means it is taken from the IP 
`server_address`
+    ///         resolves to.
+    ///     tls_ca_file: Path to the CA file for TLS. Read only when 
`tls_enabled`
+    ///         and `tls_validate_certificate` are both on; with either one 
off it
+    ///         is kept but never consulted, so pairing it with
+    ///         `tls_validate_certificate=False` pins nothing.
+    ///     tls_validate_certificate: Whether to validate the server 
certificate.
+    ///         Defaults to `False`; only the TCP transport validates by 
default.
+    ///         Disabling this accepts any certificate the server presents,
+    ///         including self-signed and mismatched ones, and takes precedence
+    ///         over `tls_ca_file`.
+    ///
+    /// Raises:
+    ///     ValueError: If `server_address` is not a valid `host:port` pair, 
if a
+    ///         duration is negative, or if `heartbeat_interval` is zero.
+    #[new]
+    #[pyo3(signature = (
+        *,
+        server_address=None,
+        auto_login=None,
+        reconnection=None,
+        heartbeat_interval=None,
+        framing=None,
+        tls_enabled=None,
+        tls_domain=None,
+        tls_ca_file=None,
+        tls_validate_certificate=None,
+    ))]
+    #[allow(clippy::too_many_arguments)]
+    fn new(
+        #[gen_stub(override_type(type_repr = "builtins.str | None"))] 
server_address: Option<
+            String,
+        >,
+        #[gen_stub(override_type(type_repr = "AutoLogin | None"))] auto_login: 
Option<AutoLogin>,
+        #[gen_stub(override_type(type_repr = "WebSocketReconnectionConfig | 
None"))]
+        reconnection: Option<WebSocketReconnectionConfig>,
+        #[gen_stub(override_type(type_repr = "datetime.timedelta | None", 
imports=("datetime")))]
+        heartbeat_interval: Option<Py<PyDelta>>,
+        #[gen_stub(override_type(type_repr = "WebSocketFramingConfig | 
None"))] framing: Option<
+            WebSocketFramingConfig,
+        >,
+        #[gen_stub(override_type(type_repr = "builtins.bool | None"))] 
tls_enabled: Option<bool>,
+        #[gen_stub(override_type(type_repr = "builtins.str | None"))] 
tls_domain: Option<String>,
+        #[gen_stub(override_type(type_repr = "builtins.str | None"))] 
tls_ca_file: Option<String>,
+        #[gen_stub(override_type(type_repr = "builtins.bool | None"))]
+        tls_validate_certificate: Option<bool>,
+    ) -> PyResult<Self> {
+        // The builder starts from `WebSocketClientConfig::default()`, and its
+        // `build()` trims and validates the address whether or not one was 
set here.
+        let mut builder = WebSocketClientConfigBuilder::new();
+        if let Some(server_address) = server_address {
+            builder = builder.with_server_address(server_address);
+        }
+        let mut inner = builder
+            .build()
+            .map_err(|e| invalid_address("server_address", e))?;
+        if let Some(auto_login) = auto_login {
+            inner.auto_login = auto_login.inner;
+        }
+        if let Some(reconnection) = reconnection {
+            inner.reconnection = reconnection.inner;
+        }
+        if let Some(heartbeat_interval) = heartbeat_interval {
+            inner.heartbeat_interval = reject_zero(
+                py_delta_to_iggy_duration(&heartbeat_interval)?,
+                "heartbeat_interval",
+            )?;
+        }
+        if let Some(framing) = framing {
+            inner.ws_config = framing.inner;
+        }
+        if let Some(tls_enabled) = tls_enabled {
+            inner.tls_enabled = tls_enabled;
+        }
+        if let Some(tls_domain) = tls_domain {
+            inner.tls_domain = tls_domain;
+        }
+        if tls_ca_file.is_some() {
+            inner.tls_ca_file = tls_ca_file;
+        }
+        if let Some(tls_validate_certificate) = tls_validate_certificate {
+            inner.tls_validate_certificate = tls_validate_certificate;
+        }
+
+        Ok(Self {
+            inner: Arc::new(inner),
+        })
+    }
+
+    #[getter]
+    fn server_address(&self) -> String {
+        self.inner.server_address.clone()
+    }
+
+    #[getter]
+    fn auto_login(&self) -> AutoLogin {
+        AutoLogin {
+            inner: self.inner.auto_login.clone(),
+        }
+    }
+
+    #[getter]
+    fn reconnection(&self) -> WebSocketReconnectionConfig {
+        WebSocketReconnectionConfig {
+            inner: self.inner.reconnection.clone(),
+        }
+    }
+
+    #[gen_stub(override_return_type(type_repr = "datetime.timedelta", 
imports=("datetime")))]
+    #[getter]
+    fn heartbeat_interval<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, 
PyDelta>> {
+        iggy_duration_to_py_delta(py, self.inner.heartbeat_interval.get())
+    }
+
+    #[getter]
+    fn framing(&self) -> WebSocketFramingConfig {
+        WebSocketFramingConfig {
+            inner: self.inner.ws_config.clone(),
+        }
+    }
+
+    #[getter]
+    fn tls_enabled(&self) -> bool {
+        self.inner.tls_enabled
+    }
+
+    #[getter]
+    fn tls_domain(&self) -> String {
+        self.inner.tls_domain.clone()
+    }
+
+    #[gen_stub(override_return_type(type_repr = "builtins.str | None"))]
+    #[getter]
+    fn tls_ca_file(&self) -> Option<String> {
+        self.inner.tls_ca_file.clone()
+    }
+
+    #[getter]
+    fn tls_validate_certificate(&self) -> bool {
+        self.inner.tls_validate_certificate
+    }
+
+    fn __repr__(&self) -> String {
+        let tls_ca_file = match &self.inner.tls_ca_file {
+            Some(tls_ca_file) => format!("{tls_ca_file:?}"),
+            None => "None".to_owned(),
+        };
+        format!(
+            "WebSocketConfig(server_address={:?}, auto_login={}, 
reconnection={}, heartbeat_interval={}, framing={}, tls_enabled={}, 
tls_domain={:?}, tls_ca_file={tls_ca_file}, tls_validate_certificate={})",
+            self.inner.server_address,
+            self.auto_login().__repr__(),
+            self.reconnection().__repr__(),
+            duration_repr(self.inner.heartbeat_interval.get()),
+            self.framing().__repr__(),
+            python_bool(self.inner.tls_enabled),
+            self.inner.tls_domain,
+            python_bool(self.inner.tls_validate_certificate),
+        )
+    }
+}
+
 fn python_bool(value: bool) -> &'static str {
     if value { "True" } else { "False" }
 }
@@ -1020,8 +1483,24 @@ fn varint_param(value: i64, parameter: &str) -> 
PyResult<u64> {
     Ok(value)
 }
 
+/// Converts a Python int to the unsigned pointer-sized integer a WebSocket
+/// framing field expects, naming the parameter in the error so a caller can
+/// tell which argument was out of range. Extracted as an `i128` rather than an
+/// `i64` so the whole `usize` range survives the way in: 
`max_write_buffer_size`
+/// defaults to `usize::MAX`, which an `i64` parameter would refuse to take 
back
+/// with an unnamed `OverflowError`, breaking `eval(repr(config))`.
+fn usize_param(value: i128, parameter: &str) -> PyResult<usize> {
+    usize::try_from(value).map_err(|_| {
+        PyValueError::new_err(format!(
+            "'{parameter}' must be between 0 and {}",
+            usize::MAX
+        ))
+    })
+}
+
 /// What `IggyClient(...)` accepts: a bare `host:port`, a full `TcpConfig`, a
-/// `QuicConfig` for the QUIC transport, or an `HttpConfig` for the HTTP 
transport.
+/// `QuicConfig` for the QUIC transport, an `HttpConfig` for the HTTP 
transport,
+/// or a `WebSocketConfig` for the WebSocket transport.
 #[derive(FromPyObject)]
 pub enum PyClientConfig {
     #[pyo3(transparent)]
@@ -1030,7 +1509,51 @@ pub enum PyClientConfig {
     Quic(QuicConfig),
     #[pyo3(transparent)]
     Http(HttpConfig),
+    #[pyo3(transparent)]
+    WebSocket(WebSocketConfig),
     #[pyo3(transparent, annotation = "str")]
     ServerAddress(String),
 }
-impl_stub_type!(PyClientConfig = TcpConfig | QuicConfig | HttpConfig | String);
+impl_stub_type!(PyClientConfig = TcpConfig | QuicConfig | HttpConfig | 
WebSocketConfig | String);
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Mirrors the literal in `WebSocketFramingConfig::new`'s signature.
+    const DEFAULT_MAX_MESSAGE_SIZE: usize = 64 << 20;
+
+    /// Mirrors the literal in `WebSocketFramingConfig::new`'s signature.
+    const DEFAULT_MAX_FRAME_SIZE: usize = 16 << 20;
+
+    /// The signature defaults have to be literals for the generated stub to 
stay
+    /// valid Python, so nothing but this test stops them drifting from the SDK
+    /// (and so from tungstenite) on a dependency bump.
+    #[test]
+    fn defaults_should_match_the_sdk() {
+        let defaults = RustWebSocketFramingConfig::default();
+
+        assert_eq!(
+            defaults.max_message_size,
+            Some(DEFAULT_MAX_MESSAGE_SIZE),
+            "'max_message_size' drifted from the SDK, update the literal in \
+             WebSocketFramingConfig::new's signature too"
+        );
+        assert_eq!(
+            defaults.max_frame_size,
+            Some(DEFAULT_MAX_FRAME_SIZE),
+            "'max_frame_size' drifted from the SDK, update the literal in \
+             WebSocketFramingConfig::new's signature too"
+        );
+        assert!(
+            defaults.write_buffer_size.is_some(),
+            "'write_buffer_size' lost its default, so the write buffer 
invariant \
+             check in WebSocketFramingConfig::new would stop running"
+        );
+        assert!(
+            defaults.max_write_buffer_size.is_some(),
+            "'max_write_buffer_size' lost its default, so the write buffer \
+             invariant check in WebSocketFramingConfig::new would stop running"
+        );
+    }
+}
diff --git a/foreign/python/src/lib.rs b/foreign/python/src/lib.rs
index 78b777918..dc238836e 100644
--- a/foreign/python/src/lib.rs
+++ b/foreign/python/src/lib.rs
@@ -34,6 +34,7 @@ mod user_headers;
 use client::IggyClient;
 use config::{
     AutoLogin, HttpConfig, QuicConfig, QuicReconnectionConfig, TcpConfig, 
TcpReconnectionConfig,
+    WebSocketConfig, WebSocketFramingConfig, WebSocketReconnectionConfig,
 };
 use consumer::{
     AutoCommit, AutoCommitAfter, AutoCommitWhen, Consumer, ConsumerGroup, 
ConsumerGroupDetails,
@@ -65,6 +66,9 @@ fn apache_iggy(_py: Python, m: &Bound<'_, PyModule>) -> 
PyResult<()> {
     m.add_class::<QuicConfig>()?;
     m.add_class::<QuicReconnectionConfig>()?;
     m.add_class::<HttpConfig>()?;
+    m.add_class::<WebSocketConfig>()?;
+    m.add_class::<WebSocketReconnectionConfig>()?;
+    m.add_class::<WebSocketFramingConfig>()?;
     m.add_class::<StreamDetails>()?;
     m.add_class::<Stream>()?;
     m.add_class::<Stats>()?;
diff --git a/foreign/python/tests/test_websocket_config.py 
b/foreign/python/tests/test_websocket_config.py
new file mode 100644
index 000000000..555e758fa
--- /dev/null
+++ b/foreign/python/tests/test_websocket_config.py
@@ -0,0 +1,522 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+Tests for the WebSocket client configuration surface.
+
+`WebSocketConfig` and `WebSocketReconnectionConfig` mirror the Rust SDK
+types the same way `TcpConfig`/`TcpReconnectionConfig` do, so most of these
+assert that a value set from Python survives to the getters and that unset
+fields fall back to the Rust defaults. `WebSocketFramingConfig` is new: it
+wraps the tungstenite frame- and buffer-level options the Rust SDK nests
+under `ws_config`, exposed here as a `framing` argument rather than flat
+kwargs, mirroring the Rust struct's own nesting. `AutoLogin` is
+transport-agnostic and already covered by `test_client_config.py`.
+"""
+
+import ast
+import socket
+import sys
+import threading
+from collections.abc import Callable
+from datetime import timedelta
+
+import pytest
+
+from apache_iggy import (
+    AutoLogin,
+    IggyClient,
+    WebSocketConfig,
+    WebSocketFramingConfig,
+    WebSocketReconnectionConfig,
+)
+
+from .utils import get_websocket_server_config, wait_for_ping, wait_for_server
+
+# tungstenite's own `WebSocketConfig::default()` values, mirrored here so a
+# dependency bump that moves one fails loudly instead of silently redefining 
the
+# Python surface.
+TUNGSTENITE_READ_BUFFER_SIZE = 128 * 1024
+TUNGSTENITE_WRITE_BUFFER_SIZE = 128 * 1024
+# `usize::MAX`, tungstenite's "unbounded" write buffer.
+TUNGSTENITE_MAX_WRITE_BUFFER_SIZE = sys.maxsize * 2 + 1
+TUNGSTENITE_MAX_MESSAGE_SIZE = 64 << 20
+TUNGSTENITE_MAX_FRAME_SIZE = 16 << 20
+
+# The accept thread only has to come back from a connection the client already
+# dialed, so anything beyond this is a hang, not slowness.
+ACCEPT_JOIN_TIMEOUT_SECONDS = 5.0
+
+
+def _accept_and_close(listener: socket.socket) -> None:
+    """Accept one connection and drop it, failing any WebSocket handshake."""
+    try:
+        connection, _ = listener.accept()
+    except OSError:
+        return
+    connection.close()
+
+
[email protected]
+class TestWebSocketReconnectionConfig:
+    """Test the reconnection policy."""
+
+    def test_defaults_match_the_rust_sdk(self):
+        """Test that an unconfigured policy reconnects forever, one second 
apart."""
+        reconnection = WebSocketReconnectionConfig()
+
+        assert reconnection.enabled is True
+        assert reconnection.max_retries is None
+        assert reconnection.interval == timedelta(seconds=1)
+        assert reconnection.reestablish_after == timedelta(seconds=5)
+
+    def test_every_field_round_trips(self):
+        """Test that each configured field is readable back unchanged."""
+        reconnection = WebSocketReconnectionConfig(
+            enabled=False,
+            max_retries=10,
+            interval=timedelta(milliseconds=250),
+            reestablish_after=timedelta(seconds=30),
+        )
+
+        assert reconnection.enabled is False
+        assert reconnection.max_retries == 10
+        assert reconnection.interval == timedelta(milliseconds=250)
+        assert reconnection.reestablish_after == timedelta(seconds=30)
+
+    def test_arguments_are_keyword_only(self):
+        """Test that the adjacent flags cannot be passed positionally."""
+        with pytest.raises(TypeError):
+            # pyrefly: ignore  # bad-argument-count
+            WebSocketReconnectionConfig(True)
+
+    @pytest.mark.parametrize(
+        "construct",
+        [
+            lambda duration: WebSocketReconnectionConfig(interval=duration),
+            lambda duration: 
WebSocketReconnectionConfig(reestablish_after=duration),
+        ],
+        ids=["interval", "reestablish_after"],
+    )
+    @pytest.mark.parametrize(
+        "negative",
+        [timedelta(microseconds=-1), timedelta(seconds=-1), 
timedelta(days=-1)],
+    )
+    def test_negative_duration_is_rejected(
+        self,
+        construct: Callable[[timedelta], WebSocketReconnectionConfig],
+        negative: timedelta,
+    ):
+        """Test that a negative duration fails at construction, not at 
connect."""
+        with pytest.raises(ValueError, match="negative"):
+            construct(negative)
+
+    @pytest.mark.parametrize("out_of_range", [-1, 2**32])
+    def test_out_of_range_max_retries_is_rejected(self, out_of_range: int):
+        """Test that a retry count outside the wire range names the argument.
+
+        The conversion pyo3 does on its own raises OverflowError, which is not 
a
+        ValueError and so escapes the handler a caller wraps construction in.
+        """
+        with pytest.raises(ValueError, match="max_retries"):
+            WebSocketReconnectionConfig(max_retries=out_of_range)
+
+    def test_zero_reestablish_after_is_allowed(self):
+        """Test that a zero cooldown is legal and readable back."""
+        reconnection = 
WebSocketReconnectionConfig(reestablish_after=timedelta(0))
+
+        assert reconnection.reestablish_after == timedelta(0)
+
+    @pytest.mark.parametrize(
+        "kwargs",
+        [
+            {},
+            {"max_retries": 5},
+            {"enabled": False},
+        ],
+        ids=["unlimited_retries", "bounded_retries", "reconnection_disabled"],
+    )
+    def test_zero_interval_is_rejected(self, kwargs: dict):
+        """Test that a zero interval fails whatever the retry policy is.
+
+        The interval is a delay between passes, so zero reconnects in a
+        continuous loop.
+        """
+        with pytest.raises(ValueError, match="zero"):
+            WebSocketReconnectionConfig(interval=timedelta(0), **kwargs)
+
+    def test_very_long_interval_round_trips(self):
+        """Test that an interval beyond 68 years survives the i32 boundary."""
+        reconnection = 
WebSocketReconnectionConfig(interval=timedelta(days=30_000))
+
+        assert reconnection.interval == timedelta(days=30_000)
+
+    def test_maximum_interval_round_trips(self):
+        """Test that the largest timedelta survives the day conversion."""
+        reconnection = 
WebSocketReconnectionConfig(interval=timedelta(days=999_999_999))
+
+        assert reconnection.interval == timedelta(days=999_999_999)
+
+
[email protected]
+class TestWebSocketFramingConfig:
+    """Test the frame- and buffer-level options."""
+
+    def test_defaults_match_tungstenite(self):
+        """Test that unconfigured sizes fall back to the tungstenite defaults.
+
+        Every size defaults to a concrete value; an omitted argument keeps that
+        default, while an explicit `None` clears the limit. See
+        `test_explicit_none_clears_the_limit`.
+        """
+        framing = WebSocketFramingConfig()
+
+        assert framing.read_buffer_size == TUNGSTENITE_READ_BUFFER_SIZE
+        assert framing.write_buffer_size == TUNGSTENITE_WRITE_BUFFER_SIZE
+        assert framing.max_write_buffer_size == 
TUNGSTENITE_MAX_WRITE_BUFFER_SIZE
+        assert framing.max_message_size == TUNGSTENITE_MAX_MESSAGE_SIZE
+        assert framing.max_frame_size == TUNGSTENITE_MAX_FRAME_SIZE
+        assert framing.accept_unmasked_frames is False
+
+    def test_every_field_round_trips(self):
+        """Test that each configured field is readable back unchanged."""
+        framing = WebSocketFramingConfig(
+            read_buffer_size=4096,
+            write_buffer_size=4096,
+            max_write_buffer_size=8192,
+            max_message_size=16384,
+            max_frame_size=16384,
+            accept_unmasked_frames=True,
+        )
+
+        assert framing.read_buffer_size == 4096
+        assert framing.write_buffer_size == 4096
+        assert framing.max_write_buffer_size == 8192
+        assert framing.max_message_size == 16384
+        assert framing.max_frame_size == 16384
+        assert framing.accept_unmasked_frames is True
+
+    @pytest.mark.parametrize("field", ["max_message_size", "max_frame_size"])
+    def test_explicit_none_clears_the_limit(self, field: str):
+        """Test that an explicit `None` lifts the size limit.
+
+        An omitted argument and an explicit `None` both reach Rust as
+        `Option::None`, so without a sentinel the constructor cannot tell them
+        apart and a caller asking for no limit would silently keep the default.
+        """
+        framing = WebSocketFramingConfig(**{field: None})
+
+        assert getattr(framing, field) is None
+
+    @pytest.mark.parametrize("field", ["max_message_size", "max_frame_size"])
+    def test_omitting_the_argument_keeps_the_default_limit(self, field: str):
+        """Test that omitting the argument is not the same as passing 
`None`."""
+        framing = WebSocketFramingConfig()
+
+        assert getattr(framing, field) is not None
+
+    def test_clearing_one_limit_leaves_the_other_alone(self):
+        """Test that the two sentinels are independent."""
+        framing = WebSocketFramingConfig(max_message_size=None)
+
+        assert framing.max_message_size is None
+        assert framing.max_frame_size is not None
+
+    def test_arguments_are_keyword_only(self):
+        """Test that the first field cannot be passed positionally."""
+        with pytest.raises(TypeError):
+            # pyrefly: ignore  # bad-argument-count
+            WebSocketFramingConfig(4096)
+
+    def test_repr_shows_every_field_as_python(self):
+        """Test that repr covers every field and parses as Python."""
+        framing = WebSocketFramingConfig(
+            read_buffer_size=4096,
+            accept_unmasked_frames=True,
+        )
+
+        printed = repr(framing)
+
+        assert "read_buffer_size=4096" in printed
+        assert "accept_unmasked_frames=True" in printed
+        ast.parse(printed)
+
+    def test_default_repr_is_constructible(self):
+        """Test that the default repr survives being fed back to the 
constructor.
+
+        `max_write_buffer_size` defaults to `usize::MAX`, so this only holds
+        because the constructor takes the sizes as `i128`. An `i64` parameter
+        rejects its own default with an `OverflowError`, which `ast.parse`
+        alone would not catch.
+        """
+        printed = repr(WebSocketFramingConfig())
+
+        assert repr(eval(printed)) == printed  # noqa: S307
+
+    @pytest.mark.parametrize(
+        "field",
+        [
+            "read_buffer_size",
+            "write_buffer_size",
+            "max_write_buffer_size",
+            "max_message_size",
+            "max_frame_size",
+        ],
+    )
+    def test_negative_size_is_rejected(self, field: str):
+        """Test that a negative size names the argument that caused it."""
+        with pytest.raises(ValueError, match=field):
+            # pyrefly: ignore  # bad-argument-type
+            WebSocketFramingConfig(**{field: -1})
+
+    def 
test_max_write_buffer_size_not_greater_than_write_buffer_size_is_rejected(self):
+        """Test that a non-increasing write buffer pair fails at construction.
+
+        tungstenite enforces `max_write_buffer_size > write_buffer_size` with 
an
+        `assert!` when the connection is established, which would otherwise
+        surface as an unrecoverable Rust panic instead of a catchable error.
+        """
+        with pytest.raises(ValueError, match="max_write_buffer_size"):
+            WebSocketFramingConfig(write_buffer_size=1000, 
max_write_buffer_size=1000)
+
+    def 
test_max_write_buffer_size_below_the_default_write_buffer_size_is_rejected(
+        self,
+    ):
+        """Test that the invariant is checked against the default too.
+
+        Setting only `max_write_buffer_size` below the untouched default
+        `write_buffer_size` (128 KiB) must fail the same way as setting both.
+        """
+        with pytest.raises(ValueError, match="max_write_buffer_size"):
+            WebSocketFramingConfig(max_write_buffer_size=1000)
+
+
[email protected]
+class TestWebSocketConfig:
+    """Test the transport configuration."""
+
+    def test_defaults_match_the_rust_sdk(self):
+        """Test that an unconfigured transport matches the Rust SDK 
defaults."""
+        config = WebSocketConfig()
+
+        assert config.server_address == "127.0.0.1:8092"
+        assert config.auto_login.enabled is False
+        assert config.reconnection.enabled is True
+        assert config.heartbeat_interval == timedelta(seconds=5)
+        assert config.tls_enabled is False
+        assert config.tls_domain == "localhost"
+        assert config.tls_ca_file is None
+        # Unlike TCP and QUIC, WebSocket does not validate the server
+        # certificate by default.
+        assert config.tls_validate_certificate is False
+
+    def test_every_field_round_trips(self):
+        """Test that each configured field is readable back unchanged."""
+        config = WebSocketConfig(
+            server_address="127.0.0.1:8093",
+            auto_login=AutoLogin.username_password("iggy", "iggy"),
+            reconnection=WebSocketReconnectionConfig(max_retries=3),
+            heartbeat_interval=timedelta(seconds=15),
+            framing=WebSocketFramingConfig(read_buffer_size=4096),
+            tls_enabled=True,
+            tls_domain="example.com",
+            tls_ca_file="ca.pem",
+            tls_validate_certificate=True,
+        )
+
+        assert config.server_address == "127.0.0.1:8093"
+        assert config.auto_login.username == "iggy"
+        assert config.reconnection.max_retries == 3
+        assert config.heartbeat_interval == timedelta(seconds=15)
+        assert config.framing.read_buffer_size == 4096
+        assert config.tls_enabled is True
+        assert config.tls_domain == "example.com"
+        assert config.tls_ca_file == "ca.pem"
+        assert config.tls_validate_certificate is True
+
+    def test_arguments_are_keyword_only(self):
+        """Test that the address cannot be passed positionally."""
+        with pytest.raises(TypeError):
+            # pyrefly: ignore  # bad-argument-count
+            WebSocketConfig("127.0.0.1:8092")
+
+    def test_repr_hides_the_password(self):
+        """Test that the password does not leak through repr."""
+        config = WebSocketConfig(
+            auto_login=AutoLogin.username_password("iggy", "secret")
+        )
+
+        assert "secret" not in repr(config)
+
+    def test_repr_shows_every_field_as_python(self):
+        """Test that repr covers the TLS fields and parses as Python."""
+        config = WebSocketConfig(
+            heartbeat_interval=timedelta(seconds=15),
+            tls_enabled=True,
+            tls_domain="example.com",
+            tls_ca_file="ca.pem",
+            tls_validate_certificate=True,
+        )
+
+        printed = repr(config)
+
+        assert 'tls_domain="example.com"' in printed
+        assert 'tls_ca_file="ca.pem"' in printed
+        assert "tls_validate_certificate=True" in printed
+        assert "heartbeat_interval=datetime.timedelta(seconds=15)" in printed
+        ast.parse(printed)
+
+    @pytest.mark.parametrize(
+        "invalid_address",
+        ["", "127.0.0.1", "127.0.0.1:not-a-port", "127.0.0.1:70000", 
"::1:8092"],
+    )
+    def test_invalid_server_address_is_rejected(self, invalid_address: str):
+        """Test that a malformed address fails at construction, naming 
itself."""
+        with pytest.raises(ValueError, match="server_address"):
+            WebSocketConfig(server_address=invalid_address)
+
+    def test_negative_heartbeat_interval_is_rejected(self):
+        """Test that a negative heartbeat interval fails at construction."""
+        with pytest.raises(ValueError, match="negative"):
+            WebSocketConfig(heartbeat_interval=timedelta(seconds=-3))
+
+    def test_zero_heartbeat_interval_is_rejected(self):
+        """Test that a zero heartbeat interval fails at construction.
+
+        Nothing downstream reads zero as "disabled"; it heartbeats in a
+        continuous loop for as long as the client lives.
+        """
+        with pytest.raises(ValueError, match="zero"):
+            WebSocketConfig(heartbeat_interval=timedelta(0))
+
+
[email protected]
+class TestWebSocketClientConstruction:
+    """Test that `IggyClient(...)` accepts a `WebSocketConfig`."""
+
+    @pytest.mark.asyncio
+    async def test_accepts_a_config(self):
+        """Test that the resulting client is actually WebSocket, not silently 
TCP.
+
+        `IggyClient(...)` is not None for either union arm, so that alone never
+        pinned the transport, and neither does the error text: both arms report
+        a failed dial as `Cannot establish connection`. What separates them is
+        the outcome against a plain TCP listener. WebSocket has to complete an
+        HTTP upgrade handshake, which the listener below refuses by closing at
+        once, while a client that regressed to the TCP arm needs nothing beyond
+        the accepted socket and would connect. So the raise itself is the 
proof.
+
+        The listener is a real socket on an ephemeral loopback port, so nothing
+        here depends on a sysctl, a privileged port, or a running server.
+        `max_retries=0` keeps the failure immediate instead of retrying
+        forever, which is the default.
+
+        The accept thread is joined before the block ends so it is out of
+        `accept()` before the listener closes under it.
+        """
+        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
+            listener.bind(("127.0.0.1", 0))
+            listener.listen(1)
+            port = listener.getsockname()[1]
+            accept_thread = threading.Thread(
+                target=lambda: _accept_and_close(listener), daemon=True
+            )
+            accept_thread.start()
+
+            client = IggyClient(
+                WebSocketConfig(
+                    server_address=f"127.0.0.1:{port}",
+                    reconnection=WebSocketReconnectionConfig(max_retries=0),
+                )
+            )
+
+            with pytest.raises(RuntimeError, match="Cannot establish 
connection"):
+                await client.connect()
+
+            accept_thread.join(timeout=ACCEPT_JOIN_TIMEOUT_SECONDS)
+
+    def test_accepts_the_default_config(self):
+        """Test that an explicit default `WebSocketConfig` is accepted."""
+        assert IggyClient(WebSocketConfig()) is not None
+
+
[email protected]
+class TestAutoLoginAgainstServer:
+    """Test that configured credentials are actually replayed on connect."""
+
+    @pytest.mark.asyncio
+    async def test_auto_login_authenticates_without_login_user(self, 
unique_name):
+        """Test that a privileged call succeeds without a manual 
login_user()."""
+        host, port = get_websocket_server_config()
+        wait_for_server(host, port)
+
+        client = IggyClient(
+            WebSocketConfig(
+                server_address=f"{host}:{port}",
+                auto_login=AutoLogin.username_password("iggy", "iggy"),
+                # The default reconnection policy retries forever: a missing
+                # listener would hang this test until the CI timeout instead
+                # of failing.
+                reconnection=WebSocketReconnectionConfig(enabled=False),
+            )
+        )
+        await client.connect()
+        await wait_for_ping(client)
+
+        stream_name = unique_name()
+        await client.create_stream(stream_name)
+        assert await client.get_stream(stream_name) is not None
+
+    @pytest.mark.asyncio
+    async def test_without_auto_login_a_privileged_call_is_unauthenticated(
+        self, unique_name
+    ):
+        """Test that the same call fails when no credentials are configured."""
+        host, port = get_websocket_server_config()
+        wait_for_server(host, port)
+
+        client = IggyClient(
+            WebSocketConfig(
+                server_address=f"{host}:{port}",
+                # The default reconnection policy retries forever: a missing
+                # listener would hang this test until the CI timeout instead
+                # of failing.
+                reconnection=WebSocketReconnectionConfig(enabled=False),
+            )
+        )
+        await client.connect()
+        await wait_for_ping(client)
+
+        with pytest.raises(RuntimeError):
+            await client.create_stream(unique_name())
+
+    @pytest.mark.asyncio
+    async def test_wrong_auto_login_credentials_fail(self):
+        """Test that bad configured credentials surface as a connect 
failure."""
+        host, port = get_websocket_server_config()
+        wait_for_server(host, port)
+
+        client = IggyClient(
+            WebSocketConfig(
+                server_address=f"{host}:{port}",
+                auto_login=AutoLogin.username_password("iggy", 
"invalid-password"),
+                reconnection=WebSocketReconnectionConfig(enabled=False),
+            )
+        )
+
+        with pytest.raises(RuntimeError):
+            await client.connect()
diff --git a/foreign/python/tests/utils.py b/foreign/python/tests/utils.py
index 5d7654cbf..b005dcabe 100644
--- a/foreign/python/tests/utils.py
+++ b/foreign/python/tests/utils.py
@@ -35,6 +35,7 @@ MAX_PASSWORD_BYTES = 100
 DEFAULT_TCP_PORT = 8090
 DEFAULT_QUIC_PORT = 8080
 DEFAULT_HTTP_PORT = 3000
+DEFAULT_WEBSOCKET_PORT = 8092
 
 
 def get_transport_config(port_env_var: str, default_port: int) -> tuple[str, 
int]:
@@ -96,6 +97,16 @@ def get_http_server_config() -> tuple[str, int]:
     return get_transport_config("IGGY_SERVER_HTTP_PORT", DEFAULT_HTTP_PORT)
 
 
+def get_websocket_server_config() -> tuple[str, int]:
+    """
+    Get WebSocket server configuration from environment variables or defaults.
+
+    Returns:
+        tuple: (host, port) for the Iggy server
+    """
+    return get_transport_config("IGGY_SERVER_WS_PORT", DEFAULT_WEBSOCKET_PORT)
+
+
 def wait_for_server(host: str, port: int, timeout: int = 60, interval: int = 
2) -> None:
     """
     Wait for the server to become available.

Reply via email to