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

spetz 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 bd1a873fe feat(python): add HttpConfig transport configuration (#3992)
bd1a873fe is described below

commit bd1a873fe12887798aa045aa9fa8e7e2bafd2ea3
Author: saie-ch <[email protected]>
AuthorDate: Mon Sep 7 23:01:27 2026 +0530

    feat(python): add HttpConfig transport configuration (#3992)
    
    Relates to #2835
    
    ---------
    
    Co-authored-by: Hubert Gruszecki <[email protected]>
---
 .../actions/python-maturin/pre-merge/action.yml    |  14 +-
 .github/workflows/coverage-baseline.yml            |  13 +-
 core/sdk/src/prelude.rs                            |   1 +
 examples/python/getting-started/consumer.py        |  20 +-
 examples/python/getting-started/producer.py        |  20 +-
 foreign/python/README.md                           |  38 ++-
 foreign/python/apache_iggy.pyi                     | 114 ++++++-
 foreign/python/src/client.rs                       |  40 ++-
 foreign/python/src/config.rs                       | 212 ++++++++++--
 foreign/python/src/lib.rs                          |   5 +-
 foreign/python/tests/test_client_config.py         |   4 +-
 foreign/python/tests/test_http_config.py           | 363 +++++++++++++++++++++
 foreign/python/tests/test_quic_config.py           |  76 ++++-
 foreign/python/tests/utils.py                      |  18 +-
 14 files changed, 855 insertions(+), 83 deletions(-)

diff --git a/.github/actions/python-maturin/pre-merge/action.yml 
b/.github/actions/python-maturin/pre-merge/action.yml
index 3cca87fce..53bf430dc 100644
--- a/.github/actions/python-maturin/pre-merge/action.yml
+++ b/.github/actions/python-maturin/pre-merge/action.yml
@@ -173,14 +173,24 @@ runs:
       run: |
         cd foreign/python
 
-        echo "Running integration tests with Iggy server at ${{ 
steps.iggy.outputs.address }}..."
+        # 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.
+        tcp_port="${{ steps.iggy.outputs.tcp_address }}"
+        http_port="${{ steps.iggy.outputs.http_address }}"
+        tcp_port="${tcp_port##*:}"
+        http_port="${http_port##*:}"
+
+        echo "Running integration tests with Iggy server at ${{ 
steps.iggy.outputs.tcp_address }}..."
 
         # Run all tests with server connection
         # --no-sync prevents uv from re-syncing the venv which would
         # overwrite the coverage-instrumented .so with a non-instrumented one
         IGGY_SERVER_HOST=127.0.0.1 \
-        IGGY_SERVER_TCP_PORT=8090 \
+        IGGY_SERVER_TCP_PORT="$tcp_port" \
         IGGY_SERVER_QUIC_PORT=8080 \
+        IGGY_SERVER_HTTP_PORT="$http_port" \
         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 d8f070ba2..76d12d1b0 100644
--- a/.github/workflows/coverage-baseline.yml
+++ b/.github/workflows/coverage-baseline.yml
@@ -347,9 +347,20 @@ jobs:
       - name: Run tests
         run: |
           cd foreign/python
+
+          # 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.
+          tcp_port="${{ steps.iggy.outputs.tcp_address }}"
+          http_port="${{ steps.iggy.outputs.http_address }}"
+          tcp_port="${tcp_port##*:}"
+          http_port="${http_port##*:}"
+
           IGGY_SERVER_HOST=127.0.0.1 \
-          IGGY_SERVER_TCP_PORT=8090 \
+          IGGY_SERVER_TCP_PORT="$tcp_port" \
           IGGY_SERVER_QUIC_PORT=8080 \
+          IGGY_SERVER_HTTP_PORT="$http_port" \
           IGGY_SERVER_DOCKER_IMAGE=iggy-server:local \
             uv run --no-sync pytest tests/ -v \
               --junitxml=../../reports/python-junit.xml \
diff --git a/core/sdk/src/prelude.rs b/core/sdk/src/prelude.rs
index 10cfbc685..79c6774b2 100644
--- a/core/sdk/src/prelude.rs
+++ b/core/sdk/src/prelude.rs
@@ -41,6 +41,7 @@ pub use crate::clients::producer_builder::IggyProducerBuilder;
 pub use crate::clients::producer_config::{BackgroundConfig, DirectConfig};
 pub use crate::clients::producer_sharding::{BalancedSharding, OrderedSharding, 
Sharding};
 pub use crate::consumer_ext::IggyConsumerMessageExt;
+pub use crate::http::http_client::HttpClient;
 pub use crate::quic::quic_client::QuicClient;
 pub use crate::stream_builder::IggyConsumerConfig;
 pub use crate::stream_builder::IggyStreamConsumer;
diff --git a/examples/python/getting-started/consumer.py 
b/examples/python/getting-started/consumer.py
index f3f788640..3a48f2a2e 100755
--- a/examples/python/getting-started/consumer.py
+++ b/examples/python/getting-started/consumer.py
@@ -24,8 +24,10 @@ from datetime import timedelta
 from apache_iggy import (
     AutoLogin,
     Consumer,
+    HttpConfig,
     IggyClient,
     PollingStrategy,
+    QuicConfig,
     ReceiveMessage,
     TcpConfig,
     TcpReconnectionConfig,
@@ -101,12 +103,12 @@ def parse_args() -> ArgNamespace:
     return ArgNamespace(**vars(args))
 
 
-def build_config(args: ArgNamespace) -> TcpConfig:
-    """Build the TCP client configuration with auto-login and reconnection."""
+def build_config(args: ArgNamespace) -> TcpConfig | QuicConfig | HttpConfig:
+    """Build the client configuration, TCP with auto-login and reconnection."""
 
     # IggyClient(...) also accepts a QuicConfig for the QUIC transport. To use
-    # it, import QuicConfig and QuicReconnectionConfig above, change the return
-    # annotation to QuicConfig, and replace the return statement with:
+    # it, uncomment the return below and import QuicReconnectionConfig, which 
is
+    # left out above because only the commented block names it:
     #
     # return QuicConfig(
     #     server_address="127.0.0.1:8080",
@@ -116,8 +118,12 @@ def build_config(args: ArgNamespace) -> TcpConfig:
     #         enabled=True, interval=timedelta(seconds=1)
     #     ),
     # )
-    #
-    # main() logs args.tcp_server_address, so change that line too.
+
+    # IggyClient(...) also accepts an HttpConfig for the HTTP transport. HTTP
+    # has no AutoLogin or reconnection policy, so main() below would also need
+    # an explicit `await client.login_user(args.username, args.password)`
+    # after connecting:
+    # return HttpConfig(api_url="http://127.0.0.1:3000";)
 
     return TcpConfig(
         server_address=args.tcp_server_address,
@@ -138,7 +144,7 @@ async def main():
     except ValueError as error:
         logger.error(f"Invalid client configuration: {error}")
         return
-    logger.info(f"Connecting to {args.tcp_server_address} (TLS: {args.tls})")
+    logger.info(f"Connecting with {config}")
 
     client = IggyClient(config)
     try:
diff --git a/examples/python/getting-started/producer.py 
b/examples/python/getting-started/producer.py
index 113bee858..e8a8fea35 100755
--- a/examples/python/getting-started/producer.py
+++ b/examples/python/getting-started/producer.py
@@ -23,7 +23,9 @@ from datetime import timedelta
 
 from apache_iggy import (
     AutoLogin,
+    HttpConfig,
     IggyClient,
+    QuicConfig,
     StreamDetails,
     TcpConfig,
     TcpReconnectionConfig,
@@ -100,12 +102,12 @@ def parse_args() -> ArgNamespace:
     return ArgNamespace(**vars(args))
 
 
-def build_config(args: ArgNamespace) -> TcpConfig:
-    """Build the TCP client configuration with auto-login and reconnection."""
+def build_config(args: ArgNamespace) -> TcpConfig | QuicConfig | HttpConfig:
+    """Build the client configuration, TCP with auto-login and reconnection."""
 
     # IggyClient(...) also accepts a QuicConfig for the QUIC transport. To use
-    # it, import QuicConfig and QuicReconnectionConfig above, change the return
-    # annotation to QuicConfig, and replace the return statement with:
+    # it, uncomment the return below and import QuicReconnectionConfig, which 
is
+    # left out above because only the commented block names it:
     #
     # return QuicConfig(
     #     server_address="127.0.0.1:8080",
@@ -115,8 +117,12 @@ def build_config(args: ArgNamespace) -> TcpConfig:
     #         enabled=True, interval=timedelta(seconds=1)
     #     ),
     # )
-    #
-    # main() logs args.tcp_server_address, so change that line too.
+
+    # IggyClient(...) also accepts an HttpConfig for the HTTP transport. HTTP
+    # has no AutoLogin or reconnection policy, so main() below would also need
+    # an explicit `await client.login_user(args.username, args.password)`
+    # after connecting:
+    # return HttpConfig(api_url="http://127.0.0.1:3000";)
 
     return TcpConfig(
         server_address=args.tcp_server_address,
@@ -137,7 +143,7 @@ async def main():
     except ValueError as error:
         logger.error(f"Invalid client configuration: {error}")
         return
-    logger.info(f"Connecting to {args.tcp_server_address} (TLS: {args.tls})")
+    logger.info(f"Connecting with {config}")
 
     client = IggyClient(config)
     logger.info("Connecting to IggyClient")
diff --git a/foreign/python/README.md b/foreign/python/README.md
index df62cfc77..293bbdfbc 100644
--- a/foreign/python/README.md
+++ b/foreign/python/README.md
@@ -134,7 +134,8 @@ running prek / committing / pushing. This list is not 
exhaustive and other hook
 
 ## Client Configuration
 
-`IggyClient` takes a server address, a `TcpConfig`, or a `QuicConfig`:
+`IggyClient` takes a server address, a `TcpConfig`, a `QuicConfig`, or an
+`HttpConfig`:
 
 ```python
 import asyncio
@@ -168,8 +169,39 @@ async def main():
 asyncio.run(main())
 ```
 
-`IggyClient(...)` also accepts a `QuicConfig` for the QUIC transport; see
-`examples/python/getting-started/producer.py` for a config swap example.
+`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.
+
+`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
+heartbeat that `heartbeat_interval` configures, so call it and then
+`login_user(...)`. And HTTP is single-consumer only: the `consumer_group(...)`
+path always fails with `Feature is unavailable`, at the join by default and at
+the returned consumer's first poll if you disable `auto_join_consumer_group`,
+so disabling it is not a workaround. A direct
+`poll_messages(consumer=Consumer.Group(...))` fails the same way unless you
+pass an explicit `partition_id`, and with one it degrades silently instead: the
+consumer kind is not carried on the HTTP wire, so the group is served as an
+ordinary consumer named after it, with no membership or partition assignment
+behind it. Use `Consumer.Single(...)` with `poll_messages(...)`. Delivery is
+also at-least-once: the default `retries=3` replays the full request body, so a
+send whose response was lost is applied twice, and only `retries=0` opts out.
+
+```python
+import asyncio
+
+from apache_iggy import HttpConfig, IggyClient
+
+
+async def main():
+    client = IggyClient(HttpConfig(api_url="http://127.0.0.1:3000";))
+    await client.connect()
+    await client.login_user("iggy", "iggy")
+
+
+asyncio.run(main())
+```
 
 ## Examples
 
diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi
index e543869e9..0400a1a51 100644
--- a/foreign/python/apache_iggy.pyi
+++ b/foreign/python/apache_iggy.pyi
@@ -39,6 +39,7 @@ __all__ = [
     "GlobalPermissions",
     "HeaderKey",
     "HeaderValue",
+    "HttpConfig",
     "IggyClient",
     "IggyConsumer",
     "IggyExpiry",
@@ -894,6 +895,78 @@ class HeaderValue:
         def value(self) -> builtins.float: ...
         def __new__(cls, value: builtins.float) -> HeaderValue.Float64: ...
 
[email protected]
+class HttpConfig:
+    r"""
+    Configuration for the HTTP transport, accepted by `IggyClient(...)`.
+
+    Every field is keyword-only and optional.
+
+    There is no `AutoLogin` and no reconnection policy, and `connect()` does 
not
+    dial: it only starts the heartbeat, so `login_user(...)` has to follow it.
+
+    HTTP is single-consumer only. `consumer_group(...)` fails with
+    `Feature is unavailable`, and so does a `Consumer.Group(...)` poll unless 
it
+    names an explicit `partition_id`. With one, the consumer kind is not 
carried
+    on the HTTP wire, so the poll is served as an ordinary consumer named after
+    the group, with no membership, no partition assignment, and no rebalancing
+    behind it. Pass `Consumer.Single(...)` explicitly.
+    """
+    @property
+    def api_url(self) -> builtins.str: ...
+    @property
+    def retries(self) -> builtins.int: ...
+    @property
+    def has_jwt(self) -> builtins.bool:
+        r"""
+        Whether a JWT is configured, without exposing the token itself.
+        """
+    @property
+    def heartbeat_interval(self) -> datetime.timedelta: ...
+    def __new__(
+        cls,
+        *,
+        api_url: builtins.str | None = None,
+        retries: builtins.int | None = None,
+        jwt: builtins.str | None = None,
+        heartbeat_interval: datetime.timedelta | None = None,
+    ) -> HttpConfig:
+        r"""
+        Constructs an HTTP configuration.
+
+        Args:
+            api_url: Base URL of the Iggy HTTP API, as `scheme://host[:port]`
+                only - no path, query, fragment, or credentials. Defaults to
+                `http://127.0.0.1:3000`.
+            retries: Number of retries to perform on transient errors, each one
+                replaying the full request (including its body) via automatic
+                middleware. Defaults to 3. Delivery is therefore at-least-once:
+                if the original request actually committed but its response
+                was lost (e.g. to a timeout), a retried call applies the same
+                operation again. Set to 0 to disable automatic replay and match
+                the other transports, which surface the failure instead of
+                silently resending.
+            jwt: JWT token for A2A (Agent-to-Agent) authentication. Defaults to
+                `None`. Stored trimmed, since a token read from a file carries 
a
+                trailing newline that the `Authorization` header value rejects.
+                Rejected if empty or whitespace-only: accepting it would make
+                `has_jwt` report `True` while every call still fails
+                `Unauthenticated`.
+            heartbeat_interval: Interval between the client's liveness probes
+                (a bare `GET /ping`). Defaults to 5 seconds. Unlike TCP/QUIC,
+                HTTP has no persistent connection or session for this to keep
+                alive; it only proves the server is reachable.
+
+        Raises:
+            ValueError: If `api_url` is not a valid URL, if `retries` is 
outside
+                the range of an unsigned 32-bit integer, if `jwt` is empty or
+                whitespace-only, if a duration is negative, or if
+                `heartbeat_interval` is zero.
+            OverflowError: If `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 IggyClient:
     r"""
@@ -901,20 +974,22 @@ class IggyClient:
     It provides asynchronous functionality through the contained runtime.
     """
     def __new__(
-        cls, conn: TcpConfig | QuicConfig | builtins.str | None = None
+        cls, conn: TcpConfig | QuicConfig | HttpConfig | builtins.str | None = 
None
     ) -> IggyClient:
         r"""
-        Constructs a new IggyClient from a TCP server address, a `TcpConfig`, 
or a
-        `QuicConfig`. This initializes a new runtime for asynchronous 
operations.
+        Constructs a new IggyClient from a TCP server address, a `TcpConfig`, a
+        `QuicConfig`, or an `HttpConfig`. 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`, or a `QuicConfig`. 
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`
-                raise `ValueError` when they are constructed, before either 
ever
-                reaches this call. Neither exception is a subclass of the 
other.
+            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
+                exception is a subclass of the other.
 
         Raises:
             RuntimeError: If the address passed as a string is not a valid
@@ -1114,8 +1189,10 @@ class IggyClient:
         """
     def connect(self) -> collections.abc.Awaitable[None]:
         r"""
-        Connects the IggyClient to its service.
-        Raises `RuntimeError` if the connection fails.
+        Connects the IggyClient to its service and starts the heartbeat task.
+        Raises `RuntimeError` if the connection fails. Over HTTP there is no
+        connection to establish, so only the heartbeat starts and this call
+        succeeds even against an unreachable server.
         """
     def create_stream(self, name: builtins.str) -> 
collections.abc.Awaitable[None]:
         r"""
@@ -1545,6 +1622,15 @@ class IggyClient:
         `poll_interval`, `polling_retry_interval`, `init_retry_interval` or an
         `AutoCommit` interval is negative, or if any of those except 
`poll_interval`
         is zero.
+
+        Consumer groups are not available over HTTP. With 
`auto_join_consumer_group`
+        left on, this call fails at the join with `Feature is unavailable`.
+        Turning it off is not a workaround: the join is skipped, but a group
+        member always polls without a partition, so the first poll fails with
+        the same error. Use `Consumer.Single(...)` with `poll_messages(...)`
+        instead - a `Consumer.Group(...)` poll with an explicit `partition_id`
+        does reach the server, but is served as an ordinary consumer named
+        after the group.
         """
     def send_binary_request(
         self, code: builtins.int, payload: builtins.bytes
@@ -1963,6 +2049,8 @@ class QuicConfig:
                 `max_idle_timeout` is not a whole number of milliseconds, if
                 `initial_mtu` is below quinn's minimum of 1200, or if a numeric
                 field is outside the range of its underlying wire type.
+            OverflowError: If a numeric field does not fit a signed 64-bit 
integer,
+                raised by the underlying conversion before this constructor 
runs.
         """
     def __repr__(self) -> builtins.str: ...
 
@@ -2010,6 +2098,8 @@ class QuicReconnectionConfig:
         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: ...
 
@@ -2578,6 +2668,8 @@ class TcpReconnectionConfig:
         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: ...
 
diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs
index fa0e9e3ac..7d19de995 100644
--- a/foreign/python/src/client.rs
+++ b/foreign/python/src/client.rs
@@ -92,17 +92,19 @@ fn resolve_topic_params(
 #[gen_stub_pymethods]
 #[pymethods]
 impl IggyClient {
-    /// Constructs a new IggyClient from a TCP server address, a `TcpConfig`, 
or a
-    /// `QuicConfig`. This initializes a new runtime for asynchronous 
operations.
+    /// Constructs a new IggyClient from a TCP server address, a `TcpConfig`, a
+    /// `QuicConfig`, or an `HttpConfig`. 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`, or a `QuicConfig`. 
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`
-    ///         raise `ValueError` when they are constructed, before either 
ever
-    ///         reaches this call. Neither exception is a subclass of the 
other.
+    ///     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
+    ///         exception is a subclass of the other.
     ///
     /// Raises:
     ///     RuntimeError: If the address passed as a string is not a valid
@@ -111,7 +113,9 @@ impl IggyClient {
     #[new]
     #[pyo3(signature = (conn=None))]
     fn new(
-        #[gen_stub(override_type(type_repr = "TcpConfig | QuicConfig | 
builtins.str | None"))]
+        #[gen_stub(override_type(
+            type_repr = "TcpConfig | QuicConfig | HttpConfig | builtins.str | 
None"
+        ))]
         conn: Option<PyClientConfig>,
     ) -> PyResult<Self> {
         let wrapper = match conn {
@@ -138,6 +142,9 @@ impl IggyClient {
                     
QuicClient::create(config.client_config()).map_err(to_runtime_error)?,
                 )
             }
+            Some(PyClientConfig::Http(config)) => ClientWrapper::Http(
+                
HttpClient::create(config.client_config()).map_err(to_runtime_error)?,
+            ),
             None => ClientWrapper::Tcp(
                 TcpClient::create(Arc::new(TcpClientConfig::default()))
                     .map_err(to_runtime_error)?,
@@ -500,8 +507,10 @@ impl IggyClient {
         })
     }
 
-    /// Connects the IggyClient to its service.
-    /// Raises `RuntimeError` if the connection fails.
+    /// Connects the IggyClient to its service and starts the heartbeat task.
+    /// Raises `RuntimeError` if the connection fails. Over HTTP there is no
+    /// connection to establish, so only the heartbeat starts and this call
+    /// succeeds even against an unreachable server.
     
#[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", 
imports=("collections.abc")))]
     fn connect<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyAny>> {
         let inner = self.inner.clone();
@@ -1279,6 +1288,15 @@ impl IggyClient {
     /// `poll_interval`, `polling_retry_interval`, `init_retry_interval` or an
     /// `AutoCommit` interval is negative, or if any of those except 
`poll_interval`
     /// is zero.
+    ///
+    /// Consumer groups are not available over HTTP. With 
`auto_join_consumer_group`
+    /// left on, this call fails at the join with `Feature is unavailable`.
+    /// Turning it off is not a workaround: the join is skipped, but a group
+    /// member always polls without a partition, so the first poll fails with
+    /// the same error. Use `Consumer.Single(...)` with `poll_messages(...)`
+    /// instead - a `Consumer.Group(...)` poll with an explicit `partition_id`
+    /// does reach the server, but is served as an ordinary consumer named
+    /// after the group.
     #[allow(clippy::too_many_arguments)]
     #[pyo3(signature = (
         name,
diff --git a/foreign/python/src/config.rs b/foreign/python/src/config.rs
index cd98ef626..f699a2359 100644
--- a/foreign/python/src/config.rs
+++ b/foreign/python/src/config.rs
@@ -17,6 +17,7 @@
 
 use iggy::prelude::{
     AutoLogin as RustAutoLogin, Credentials as RustCredentials,
+    HttpClientConfig as RustHttpClientConfig, HttpClientConfigBuilder,
     QuicClientConfig as RustQuicClientConfig, QuicClientConfigBuilder,
     QuicClientReconnectionConfig as RustQuicClientReconnectionConfig,
     TcpClientConfig as RustTcpClientConfig, TcpClientConfigBuilder,
@@ -28,6 +29,7 @@ use pyo3::types::PyDelta;
 use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
 use pyo3_stub_gen::impl_stub_type;
 use secrecy::SecretString;
+use std::fmt::Display;
 use std::net::SocketAddr;
 use std::sync::Arc;
 
@@ -144,6 +146,8 @@ impl TcpReconnectionConfig {
     /// 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(
@@ -157,14 +161,7 @@ impl TcpReconnectionConfig {
         let defaults = RustTcpClientReconnectionConfig::default();
         let enabled = enabled.unwrap_or(defaults.enabled);
         let max_retries = max_retries
-            .map(|max_retries| {
-                u32::try_from(max_retries).map_err(|_| {
-                    PyValueError::new_err(format!(
-                        "'max_retries' must be between 0 and {}",
-                        u32::MAX
-                    ))
-                })
-            })
+            .map(|max_retries| u32_param(max_retries, "max_retries"))
             .transpose()?;
         let interval = interval
             .as_ref()
@@ -308,7 +305,7 @@ impl TcpConfig {
         }
         let mut inner = builder
             .build()
-            .map_err(|e| PyValueError::new_err(e.to_string()))?;
+            .map_err(|e| invalid_address("server_address", e))?;
         if let Some(auto_login) = auto_login {
             inner.auto_login = auto_login.inner;
         }
@@ -445,6 +442,8 @@ impl QuicReconnectionConfig {
     /// 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(
@@ -458,14 +457,7 @@ impl QuicReconnectionConfig {
         let defaults = RustQuicClientReconnectionConfig::default();
         let enabled = enabled.unwrap_or(defaults.enabled);
         let max_retries = max_retries
-            .map(|max_retries| {
-                u32::try_from(max_retries).map_err(|_| {
-                    PyValueError::new_err(format!(
-                        "'max_retries' must be between 0 and {}",
-                        u32::MAX
-                    ))
-                })
-            })
+            .map(|max_retries| u32_param(max_retries, "max_retries"))
             .transpose()?;
         let interval = interval
             .as_ref()
@@ -588,6 +580,8 @@ impl QuicConfig {
     ///         `max_idle_timeout` is not a whole number of milliseconds, if
     ///         `initial_mtu` is below quinn's minimum of 1200, or if a numeric
     ///         field is outside the range of its underlying wire type.
+    ///     OverflowError: If a numeric field does not fit a signed 64-bit 
integer,
+    ///         raised by the underlying conversion before this constructor 
runs.
     #[new]
     #[pyo3(signature = (
         *,
@@ -647,15 +641,18 @@ impl QuicConfig {
         }
         let mut inner = builder
             .build()
-            .map_err(|e| PyValueError::new_err(e.to_string()))?;
+            .map_err(|e| invalid_address("server_address", e))?;
         if let Some(client_address) = client_address {
-            // Kept verbatim rather than normalized: `QuicClient::create` 
compares
-            // this against the literal default to decide whether to bind an 
IPv6
-            // socket for an IPv6 server, and a rewritten string would not 
match.
-            client_address.parse::<SocketAddr>().map_err(|e| {
-                PyValueError::new_err(format!("'client_address' is not a valid 
'host:port': {e}"))
-            })?;
-            inner.client_address = client_address;
+            // Trimmed like the server address, but otherwise kept verbatim 
rather
+            // than re-serialized from the parsed `SocketAddr`: 
`QuicClient::create`
+            // compares this against the literal default to decide whether to 
bind
+            // an IPv6 socket for an IPv6 server, and a rewritten string would 
not
+            // match.
+            let client_address = client_address.trim();
+            client_address
+                .parse::<SocketAddr>()
+                .map_err(|e| invalid_address("client_address", e))?;
+            inner.client_address = client_address.to_owned();
         }
         if let Some(server_name) = server_name {
             inner.server_name = server_name;
@@ -826,10 +823,167 @@ impl QuicConfig {
     }
 }
 
+/// Configuration for the HTTP transport, accepted by `IggyClient(...)`.
+///
+/// Every field is keyword-only and optional.
+///
+/// There is no `AutoLogin` and no reconnection policy, and `connect()` does 
not
+/// dial: it only starts the heartbeat, so `login_user(...)` has to follow it.
+///
+/// HTTP is single-consumer only. `consumer_group(...)` fails with
+/// `Feature is unavailable`, and so does a `Consumer.Group(...)` poll unless 
it
+/// names an explicit `partition_id`. With one, the consumer kind is not 
carried
+/// on the HTTP wire, so the poll is served as an ordinary consumer named after
+/// the group, with no membership, no partition assignment, and no rebalancing
+/// behind it. Pass `Consumer.Single(...)` explicitly.
+#[gen_stub_pyclass]
+#[pyclass(from_py_object)]
+#[derive(Clone)]
+pub struct HttpConfig {
+    inner: Arc<RustHttpClientConfig>,
+}
+
+impl HttpConfig {
+    /// The configuration in the shape `HttpClient::create` expects.
+    pub(crate) fn client_config(&self) -> Arc<RustHttpClientConfig> {
+        self.inner.clone()
+    }
+}
+
+#[gen_stub_pymethods]
+#[pymethods]
+impl HttpConfig {
+    /// Constructs an HTTP configuration.
+    ///
+    /// Args:
+    ///     api_url: Base URL of the Iggy HTTP API, as `scheme://host[:port]`
+    ///         only - no path, query, fragment, or credentials. Defaults to
+    ///         `http://127.0.0.1:3000`.
+    ///     retries: Number of retries to perform on transient errors, each one
+    ///         replaying the full request (including its body) via automatic
+    ///         middleware. Defaults to 3. Delivery is therefore at-least-once:
+    ///         if the original request actually committed but its response
+    ///         was lost (e.g. to a timeout), a retried call applies the same
+    ///         operation again. Set to 0 to disable automatic replay and match
+    ///         the other transports, which surface the failure instead of
+    ///         silently resending.
+    ///     jwt: JWT token for A2A (Agent-to-Agent) authentication. Defaults to
+    ///         `None`. Stored trimmed, since a token read from a file carries 
a
+    ///         trailing newline that the `Authorization` header value rejects.
+    ///         Rejected if empty or whitespace-only: accepting it would make
+    ///         `has_jwt` report `True` while every call still fails
+    ///         `Unauthenticated`.
+    ///     heartbeat_interval: Interval between the client's liveness probes
+    ///         (a bare `GET /ping`). Defaults to 5 seconds. Unlike TCP/QUIC,
+    ///         HTTP has no persistent connection or session for this to keep
+    ///         alive; it only proves the server is reachable.
+    ///
+    /// Raises:
+    ///     ValueError: If `api_url` is not a valid URL, if `retries` is 
outside
+    ///         the range of an unsigned 32-bit integer, if `jwt` is empty or
+    ///         whitespace-only, if a duration is negative, or if
+    ///         `heartbeat_interval` is zero.
+    ///     OverflowError: If `retries` does not fit a signed 64-bit integer,
+    ///         raised by the underlying conversion before this constructor 
runs.
+    #[new]
+    #[pyo3(signature = (*, api_url=None, retries=None, jwt=None, 
heartbeat_interval=None))]
+    fn new(
+        #[gen_stub(override_type(type_repr = "builtins.str | None"))] api_url: 
Option<String>,
+        #[gen_stub(override_type(type_repr = "builtins.int | None"))] retries: 
Option<i64>,
+        #[gen_stub(override_type(type_repr = "builtins.str | None"))] jwt: 
Option<String>,
+        #[gen_stub(override_type(type_repr = "datetime.timedelta | None", 
imports=("datetime")))]
+        heartbeat_interval: Option<Py<PyDelta>>,
+    ) -> PyResult<Self> {
+        // The builder starts from `HttpClientConfig::default()`, and its 
`build()`
+        // trims and validates the API URL whether or not one was set here.
+        let mut builder = HttpClientConfigBuilder::new();
+        if let Some(api_url) = api_url {
+            builder = builder.with_api_url(api_url);
+        }
+        let mut inner = builder
+            .build()
+            .map_err(|e| PyValueError::new_err(format!("'api_url' is not a 
valid URL: {e}")))?;
+        if let Some(retries) = retries {
+            inner.retries = u32_param(retries, "retries")?;
+        }
+        if let Some(jwt) = jwt {
+            let jwt = jwt.trim();
+            if jwt.is_empty() {
+                return Err(PyValueError::new_err(
+                    "'jwt' must not be empty or whitespace-only",
+                ));
+            }
+            inner.jwt = Some(jwt.to_owned());
+        }
+        if let Some(heartbeat_interval) = heartbeat_interval {
+            inner.heartbeat_interval = reject_zero(
+                py_delta_to_iggy_duration(&heartbeat_interval)?,
+                "heartbeat_interval",
+            )?;
+        }
+
+        Ok(Self {
+            inner: Arc::new(inner),
+        })
+    }
+
+    #[getter]
+    fn api_url(&self) -> String {
+        self.inner.api_url.clone()
+    }
+
+    #[getter]
+    fn retries(&self) -> u32 {
+        self.inner.retries
+    }
+
+    /// Whether a JWT is configured, without exposing the token itself.
+    #[getter]
+    fn has_jwt(&self) -> bool {
+        self.inner.jwt.is_some()
+    }
+
+    #[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())
+    }
+
+    fn __repr__(&self) -> String {
+        let jwt = if self.inner.jwt.is_some() {
+            "..."
+        } else {
+            "None"
+        };
+        format!(
+            "HttpConfig(api_url={:?}, retries={}, jwt={jwt}, 
heartbeat_interval={})",
+            self.inner.api_url,
+            self.inner.retries,
+            duration_repr(self.inner.heartbeat_interval.get()),
+        )
+    }
+}
+
 fn python_bool(value: bool) -> &'static str {
     if value { "True" } else { "False" }
 }
 
+/// Rejects an address that is not a valid `host:port`, naming the argument it
+/// came from: neither the builder's error nor `SocketAddr`'s mentions which 
one.
+fn invalid_address(parameter: &str, error: impl Display) -> PyErr {
+    PyValueError::new_err(format!("'{parameter}' is not a valid 'host:port': 
{error}"))
+}
+
+/// Converts a Python int to the unsigned 32-bit integer 
`max_retries`/`retries`
+/// expect, naming the parameter in the error so a caller can tell which
+/// argument was out of range. A value too large even for `i64` still raises
+/// pyo3's own unnamed `OverflowError` before this ever runs.
+fn u32_param(value: i64, parameter: &str) -> PyResult<u32> {
+    u32::try_from(value).map_err(|_| {
+        PyValueError::new_err(format!("'{parameter}' must be between 0 and 
{}", u32::MAX))
+    })
+}
+
 /// Converts a Python int to the unsigned 64-bit integer a QUIC transport
 /// field expects, naming the parameter in the error so a caller can tell
 /// which argument was out of range. The bound in the message is `i64::MAX`
@@ -866,15 +1020,17 @@ fn varint_param(value: i64, parameter: &str) -> 
PyResult<u64> {
     Ok(value)
 }
 
-/// What `IggyClient(...)` accepts: a bare `host:port`, a full `TcpConfig`, or 
a
-/// `QuicConfig` for the QUIC transport.
+/// What `IggyClient(...)` accepts: a bare `host:port`, a full `TcpConfig`, a
+/// `QuicConfig` for the QUIC transport, or an `HttpConfig` for the HTTP 
transport.
 #[derive(FromPyObject)]
 pub enum PyClientConfig {
     #[pyo3(transparent)]
     Tcp(TcpConfig),
     #[pyo3(transparent)]
     Quic(QuicConfig),
+    #[pyo3(transparent)]
+    Http(HttpConfig),
     #[pyo3(transparent, annotation = "str")]
     ServerAddress(String),
 }
-impl_stub_type!(PyClientConfig = TcpConfig | QuicConfig | String);
+impl_stub_type!(PyClientConfig = TcpConfig | QuicConfig | HttpConfig | String);
diff --git a/foreign/python/src/lib.rs b/foreign/python/src/lib.rs
index e39b3339e..80a123ee0 100644
--- a/foreign/python/src/lib.rs
+++ b/foreign/python/src/lib.rs
@@ -31,7 +31,9 @@ mod user;
 mod user_headers;
 
 use client::IggyClient;
-use config::{AutoLogin, QuicConfig, QuicReconnectionConfig, TcpConfig, 
TcpReconnectionConfig};
+use config::{
+    AutoLogin, HttpConfig, QuicConfig, QuicReconnectionConfig, TcpConfig, 
TcpReconnectionConfig,
+};
 use consumer::{
     AutoCommit, AutoCommitAfter, AutoCommitWhen, Consumer, ConsumerGroup, 
ConsumerGroupDetails,
     ConsumerGroupMember, IggyConsumer, ReceiveMessageIterator,
@@ -60,6 +62,7 @@ fn apache_iggy(_py: Python, m: &Bound<'_, PyModule>) -> 
PyResult<()> {
     m.add_class::<TcpReconnectionConfig>()?;
     m.add_class::<QuicConfig>()?;
     m.add_class::<QuicReconnectionConfig>()?;
+    m.add_class::<HttpConfig>()?;
     m.add_class::<StreamDetails>()?;
     m.add_class::<Stream>()?;
     m.add_class::<Stats>()?;
diff --git a/foreign/python/tests/test_client_config.py 
b/foreign/python/tests/test_client_config.py
index 26d9f4430..7e80fe065 100644
--- a/foreign/python/tests/test_client_config.py
+++ b/foreign/python/tests/test_client_config.py
@@ -257,8 +257,8 @@ class TestTcpConfig:
         ["", "127.0.0.1", "127.0.0.1:not-a-port", "127.0.0.1:70000", 
"::1:8090"],
     )
     def test_invalid_server_address_is_rejected(self, invalid_address: str):
-        """Test that a malformed address fails at construction, not at 
connect."""
-        with pytest.raises(ValueError):
+        """Test that a malformed address fails at construction, naming 
itself."""
+        with pytest.raises(ValueError, match="server_address"):
             TcpConfig(server_address=invalid_address)
 
     def test_negative_heartbeat_interval_is_rejected(self):
diff --git a/foreign/python/tests/test_http_config.py 
b/foreign/python/tests/test_http_config.py
new file mode 100644
index 000000000..d2237aa35
--- /dev/null
+++ b/foreign/python/tests/test_http_config.py
@@ -0,0 +1,363 @@
+# 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 HTTP client configuration surface.
+
+`HttpConfig` mirrors the Rust SDK's `HttpClientConfig` the same way
+`TcpConfig` does, 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. Unlike TCP there is no `AutoLogin` or reconnection policy to
+configure.
+"""
+
+import ast
+import json
+import urllib.request
+from datetime import timedelta
+
+import pytest
+
+from apache_iggy import Consumer, HttpConfig, IggyClient, PollingStrategy
+from apache_iggy import SendMessage as Message
+
+from .utils import get_http_server_config, wait_for_ping
+
+
[email protected]
+class TestHttpConfig:
+    """Test the transport configuration."""
+
+    def test_defaults_match_the_rust_sdk(self):
+        """Test that an unconfigured transport matches the Rust SDK 
defaults."""
+        config = HttpConfig()
+
+        assert config.api_url == "http://127.0.0.1:3000";
+        assert config.retries == 3
+        assert config.has_jwt is False
+        assert config.heartbeat_interval == timedelta(seconds=5)
+
+    def test_every_field_round_trips(self):
+        """Test that each configured field is readable back unchanged."""
+        config = HttpConfig(
+            api_url="http://127.0.0.1:3001";,
+            retries=5,
+            jwt="a-token",
+            heartbeat_interval=timedelta(seconds=15),
+        )
+
+        assert config.api_url == "http://127.0.0.1:3001";
+        assert config.retries == 5
+        assert config.has_jwt is True
+        assert config.heartbeat_interval == timedelta(seconds=15)
+
+    def test_arguments_are_keyword_only(self):
+        """Test that the API URL cannot be passed positionally."""
+        with pytest.raises(TypeError):
+            # pyrefly: ignore  # bad-argument-count
+            HttpConfig("http://127.0.0.1:3000";)
+
+    def test_repr_hides_the_jwt(self):
+        """Test that the JWT does not leak through repr but still parses as 
Python."""
+        config = HttpConfig(jwt="a-secret-token")
+
+        printed = repr(config)
+
+        assert "a-secret-token" not in printed
+        ast.parse(printed)
+
+    def test_repr_shows_every_field_as_python(self):
+        """Test that repr covers the configured fields and parses as Python.
+
+        `heartbeat_interval` is included: its repr is built from a duration,
+        the one format-fragile field here, and `ast.parse` alone would not
+        catch a regression that renders it as something other than a
+        `datetime.timedelta` call.
+        """
+        config = HttpConfig(
+            api_url="http://127.0.0.1:3001";,
+            retries=5,
+            heartbeat_interval=timedelta(seconds=15),
+        )
+
+        printed = repr(config)
+
+        assert 'api_url="http://127.0.0.1:3001";' in printed
+        assert "retries=5" in printed
+        assert "heartbeat_interval=datetime.timedelta(seconds=15)" in printed
+        ast.parse(printed)
+
+    @pytest.mark.parametrize(
+        "invalid_url",
+        [
+            "",
+            "not-a-url",
+            "http://127.0.0.1:0";,
+            "http://127.0.0.1:3000/iggy";,
+            "http://user:[email protected]:3000";,
+        ],
+    )
+    def test_invalid_api_url_is_rejected(self, invalid_url: str):
+        """Test that a malformed API URL fails at construction, not at connect.
+
+        Only `scheme://host[:port]` is accepted: a path, query, fragment, or
+        embedded credentials are all rejected, not just a missing/zero port.
+        """
+        with pytest.raises(ValueError, match="api_url"):
+            HttpConfig(api_url=invalid_url)
+
+    @pytest.mark.parametrize("bad_jwt", ["", "   ", "\t"])
+    def test_empty_or_whitespace_jwt_is_rejected(self, bad_jwt: str):
+        """Test that an empty or whitespace-only JWT fails at construction.
+
+        Accepting it would make `has_jwt` report `True` while every call
+        still fails `Unauthenticated`, since the stored token is blank.
+        """
+        with pytest.raises(ValueError, match="jwt"):
+            HttpConfig(jwt=bad_jwt)
+
+    @pytest.mark.parametrize("out_of_range", [-1, 2**32])
+    def test_out_of_range_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="retries"):
+            HttpConfig(retries=out_of_range)
+
+    @pytest.mark.parametrize(
+        "negative",
+        [timedelta(microseconds=-1), timedelta(seconds=-1), 
timedelta(days=-1)],
+    )
+    def test_negative_heartbeat_interval_is_rejected(self, negative: 
timedelta):
+        """Test that a negative heartbeat interval fails at construction."""
+        with pytest.raises(ValueError, match="negative"):
+            HttpConfig(heartbeat_interval=negative)
+
+    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=r"heartbeat_interval.*must not be 
zero"):
+            HttpConfig(heartbeat_interval=timedelta(0))
+
+    def test_maximum_heartbeat_interval_round_trips(self):
+        """Test that the largest timedelta survives the duration conversion.
+
+        The repr is asserted in seconds rather than days: it is rendered from
+        a microsecond count, so the maximum comes back as a whole-second
+        `timedelta` instead of the `days=` form it was constructed with.
+        """
+        maximum = timedelta(days=999_999_999)
+
+        config = HttpConfig(heartbeat_interval=maximum)
+
+        printed = repr(config)
+
+        assert config.heartbeat_interval == maximum
+        assert (
+            "heartbeat_interval=datetime.timedelta(seconds=86399999913600)" in 
printed
+        )
+        ast.parse(printed)
+
+
[email protected]
+class TestHttpClientConstruction:
+    """Test that `IggyClient(...)` builds an HTTP client from an 
`HttpConfig`."""
+
+    @pytest.mark.asyncio
+    async def test_accepts_a_config(self):
+        """Test that the resulting client is actually HTTP, not silently TCP.
+
+        `IggyClient(...)` is not None for either union arm, so that alone
+        never pinned the transport, and a bare `RuntimeError` does not either:
+        a client that regressed to the TCP arm raises too, just after hanging
+        until the pytest timeout. `Invalid HTTP request` is the HTTP
+        transport's own send failure, so matching it pins the transport.
+        `retries=0` keeps the failure immediate instead of working through
+        the default retry/backoff first.
+        """
+        client = IggyClient(HttpConfig(api_url="http://127.0.0.1:1";, 
retries=0))
+
+        with pytest.raises(RuntimeError, match="Invalid HTTP request"):
+            await client.ping()
+
+    def test_accepts_the_default_config(self):
+        """Test that an explicit default `HttpConfig` is accepted."""
+        assert IggyClient(HttpConfig()) is not None
+
+    @pytest.mark.asyncio
+    async def test_without_a_jwt_a_privileged_call_is_unauthenticated(self):
+        """Test that a privileged call fails when no token is configured.
+
+        `HttpClient` seeds its access token from `jwt`, so with none configured
+        and no `login_user()` it stays empty and the client rejects the call
+        itself. The dead port is what proves that: nothing is dialled, so the
+        failure cannot be the server's.
+        """
+        client = IggyClient(HttpConfig(api_url="http://127.0.0.1:1";, 
retries=0))
+        await client.connect()
+
+        with pytest.raises(RuntimeError, match="Unauthenticated"):
+            await client.create_stream("never-created")
+
+
[email protected]
+class TestHttpConfigAgainstServer:
+    """Test that a client built from `HttpConfig` actually connects."""
+
+    @pytest.mark.asyncio
+    async def test_client_connects_and_pings(self):
+        """Test that a client built with a custom config reaches the server."""
+        host, port = get_http_server_config()
+
+        client = IggyClient(HttpConfig(api_url=f"http://{host}:{port}";))
+        await client.connect()
+        await wait_for_ping(client)
+
+    @pytest.mark.asyncio
+    async def test_client_sends_and_polls_a_message(self, unique_name):
+        """Test a full round trip: login, create stream/topic, send, poll.
+
+        This is the part `test_client_connects_and_pings` above does not
+        cover: that a client built from `HttpConfig` can carry a real
+        workload, not just answer a ping.
+        """
+        host, port = get_http_server_config()
+        stream_name = unique_name()
+        topic_name = unique_name()
+        payload = f"payload-{unique_name()}"
+
+        client = IggyClient(HttpConfig(api_url=f"http://{host}:{port}";))
+        await client.connect()
+        await wait_for_ping(client)
+        await client.login_user("iggy", "iggy")
+
+        await client.create_stream(stream_name)
+        await client.create_topic(
+            stream=stream_name, name=topic_name, partitions_count=1
+        )
+        await client.send_messages(
+            stream=stream_name,
+            topic=topic_name,
+            partitioning=0,
+            messages=[Message(payload)],
+        )
+
+        polled_messages = await client.poll_messages(
+            stream=stream_name,
+            topic=topic_name,
+            consumer=Consumer.Single("http-round-trip"),
+            partition_id=0,
+            polling_strategy=PollingStrategy.First(),
+            count=1,
+            auto_commit=True,
+        )
+
+        assert [message.payload().decode() for message in polled_messages] == 
[payload]
+
+    @pytest.mark.asyncio
+    async def test_jwt_config_actually_authenticates(self, unique_name):
+        """Test that a JWT passed to `HttpConfig` reaches `access_token`.
+
+        `has_jwt` only proves a token is configured, not that it works.
+        `/users/login` is unauthenticated, so a token minted out-of-band via
+        stdlib `urllib` (bypassing `HttpConfig` and `login_user()` entirely)
+        proves the client actually authenticates with the token it was given.
+
+        The trailing newline is deliberate, and is the only coverage of the
+        trim in `HttpConfig::new`: it reproduces a token read from a file, and
+        untrimmed it builds a `Bearer <token>\\n` header value that
+        `HeaderValue` rejects, failing every call with `Invalid HTTP request`.
+        Do not remove it.
+        """
+        host, port = get_http_server_config()
+        api_url = f"http://{host}:{port}";
+
+        request = urllib.request.Request(  # noqa: S310
+            f"{api_url}/users/login",
+            data=json.dumps({"username": "iggy", "password": "iggy"}).encode(),
+            headers={"Content-Type": "application/json"},
+            method="POST",
+        )
+        with urllib.request.urlopen(request) as response:  # noqa: S310
+            identity = json.loads(response.read())
+        token = identity["access_token"]["token"]
+
+        client = IggyClient(HttpConfig(api_url=api_url, jwt=f"{token}\n"))
+        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_wrong_jwt_is_rejected_by_the_server(self, unique_name):
+        """Test that a JWT the server cannot decode fails the call, not 
connect.
+
+        `connect()` does not dial over HTTP, so a bad token can only surface
+        later. `wait_for_ping` runs first because ping needs no credentials:
+        it proves the server is reachable, which pins the failure below on the
+        token rather than on a missing listener. The server answers an
+        undecodable token with 401, which the HTTP client maps back to
+        `Unauthenticated`.
+        """
+        host, port = get_http_server_config()
+
+        client = IggyClient(
+            HttpConfig(api_url=f"http://{host}:{port}";, jwt="not-a-real-token")
+        )
+        await client.connect()
+        await wait_for_ping(client)
+
+        with pytest.raises(RuntimeError, match="Unauthenticated"):
+            await client.create_stream(unique_name())
+
+    @pytest.mark.asyncio
+    async def test_consumer_group_is_rejected(self, unique_name):
+        """Test that a consumer group fails loudly over HTTP, not silently.
+
+        `join_consumer_group` answers `Feature is unavailable` over HTTP, and
+        `consumer_group(...)` awaits that join before returning, so the
+        failure surfaces at construction. `auto_join_consumer_group=False`
+        is not a way around it: the join is skipped, but the first poll
+        raises the same error, because a group member is pinned to no
+        partition and a consumer-group poll without one is rejected
+        client-side.
+        """
+        host, port = get_http_server_config()
+        stream_name = unique_name()
+        topic_name = unique_name()
+
+        client = IggyClient(HttpConfig(api_url=f"http://{host}:{port}";))
+        await client.connect()
+        await wait_for_ping(client)
+        await client.login_user("iggy", "iggy")
+
+        await client.create_stream(stream_name)
+        await client.create_topic(
+            stream=stream_name, name=topic_name, partitions_count=1
+        )
+
+        with pytest.raises(RuntimeError, match="Feature is unavailable"):
+            await client.consumer_group(
+                name=unique_name(), stream=stream_name, topic=topic_name
+            )
diff --git a/foreign/python/tests/test_quic_config.py 
b/foreign/python/tests/test_quic_config.py
index d0fdc3f7b..eb4089a15 100644
--- a/foreign/python/tests/test_quic_config.py
+++ b/foreign/python/tests/test_quic_config.py
@@ -26,6 +26,7 @@ covered by `test_client_config.py`.
 """
 
 import ast
+import socket
 from collections.abc import Callable
 from datetime import timedelta
 
@@ -131,10 +132,21 @@ class TestQuicReconnectionConfig:
         assert reconnection.interval == timedelta(days=30_000)
 
     def test_maximum_interval_round_trips(self):
-        """Test that the largest timedelta survives the day conversion."""
-        reconnection = 
QuicReconnectionConfig(interval=timedelta(days=999_999_999))
+        """Test that the largest timedelta survives the day conversion.
 
-        assert reconnection.interval == timedelta(days=999_999_999)
+        The repr is asserted in seconds rather than days: it is rendered from
+        a microsecond count, so the maximum comes back as a whole-second
+        `timedelta` instead of the `days=` form it was constructed with.
+        """
+        maximum = timedelta(days=999_999_999)
+
+        reconnection = QuicReconnectionConfig(interval=maximum)
+
+        printed = repr(reconnection)
+
+        assert reconnection.interval == maximum
+        assert "interval=datetime.timedelta(seconds=86399999913600)" in printed
+        ast.parse(printed)
 
 
 @pytest.mark.unit
@@ -210,8 +222,15 @@ class TestQuicConfig:
         assert "secret" not in repr(config)
 
     def test_repr_shows_every_field_as_python(self):
-        """Test that repr covers the QUIC-specific fields and parses as 
Python."""
+        """Test that repr covers the configured fields and parses as Python.
+
+        The three string fields are asserted too: `ast.parse` alone still
+        passes on a repr that dropped one from the format string.
+        """
         config = QuicConfig(
+            server_address="127.0.0.1:8081",
+            client_address="127.0.0.1:9000",
+            server_name="example.com",
             heartbeat_interval=timedelta(seconds=15),
             keep_alive_interval=timedelta(seconds=2),
             max_idle_timeout=timedelta(seconds=20),
@@ -220,6 +239,9 @@ class TestQuicConfig:
 
         printed = repr(config)
 
+        assert 'server_address="127.0.0.1:8081"' in printed
+        assert 'client_address="127.0.0.1:9000"' in printed
+        assert 'server_name="example.com"' in printed
         assert "validate_certificate=True" in printed
         assert "heartbeat_interval=datetime.timedelta(seconds=15)" in printed
         assert "keep_alive_interval=datetime.timedelta(seconds=2)" in printed
@@ -231,8 +253,8 @@ class TestQuicConfig:
         ["", "127.0.0.1", "127.0.0.1:not-a-port", "127.0.0.1:70000", 
"::1:8080"],
     )
     def test_invalid_server_address_is_rejected(self, invalid_address: str):
-        """Test that a malformed address fails at construction, not at 
connect."""
-        with pytest.raises(ValueError):
+        """Test that a malformed address fails at construction, naming 
itself."""
+        with pytest.raises(ValueError, match="server_address"):
             QuicConfig(server_address=invalid_address)
 
     @pytest.mark.parametrize(
@@ -251,6 +273,20 @@ class TestQuicConfig:
         with pytest.raises(ValueError, match="client_address"):
             QuicConfig(client_address=invalid_address)
 
+    def test_surrounding_whitespace_in_the_addresses_is_trimmed(self):
+        """Test that both addresses tolerate whitespace, like HTTP's `api_url`.
+
+        `client_address` is stored as the string `QuicClient::create` compares
+        against the literal default to pick an IPv6 bind address, so storing
+        the trimmed form is what keeps a padded default matching that sentinel.
+        """
+        config = QuicConfig(
+            server_address=" 127.0.0.1:8080 ", client_address=" 127.0.0.1:0 "
+        )
+
+        assert config.server_address == "127.0.0.1:8080"
+        assert config.client_address == "127.0.0.1:0"
+
     def test_negative_heartbeat_interval_is_rejected(self):
         """Test that a negative heartbeat interval fails at construction."""
         with pytest.raises(ValueError, match="negative"):
@@ -359,8 +395,32 @@ class TestQuicClientConstruction:
     """Test that `IggyClient(...)` accepts a `QuicConfig`."""
 
     def test_accepts_a_config(self):
-        """Test that a client can be built from a config object."""
-        assert IggyClient(QuicConfig(server_address="127.0.0.1:8080")) is not 
None
+        """Test that the resulting client is actually QUIC, not silently TCP.
+
+        `IggyClient(...)` is not None for either union arm, so that alone never
+        pinned the transport. `client_address` is a QUIC-only field that
+        `QuicClient::create` binds eagerly here, so a port already held fails
+        the bind synchronously with `Cannot create endpoint`, with no server
+        and no privileged port involved. A client that regressed to the TCP arm
+        has no such field and would construct fine, and no other error maps to
+        that message.
+
+        The held port has to be a real one: an unroutable address would only
+        fail the bind while `net.ipv4.ip_nonlocal_bind` is 0, and a host
+        running keepalived or a container setting it alone would bind
+        successfully and assert nothing. Neither the socket below nor quinn
+        sets `SO_REUSEADDR`, so the second bind is EADDRINUSE regardless.
+        """
+        # A bindable client_address of its own, so the failure below is the
+        # collision and not the field being set at all.
+        assert IggyClient(QuicConfig(client_address="127.0.0.1:0")) is not None
+
+        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as held:
+            held.bind(("127.0.0.1", 0))
+            port = held.getsockname()[1]
+
+            with pytest.raises(RuntimeError, match="Cannot create endpoint"):
+                IggyClient(QuicConfig(client_address=f"127.0.0.1:{port}"))
 
     def test_accepts_the_default_config(self):
         """Test that an explicit default `QuicConfig` is accepted."""
diff --git a/foreign/python/tests/utils.py b/foreign/python/tests/utils.py
index cdede9f32..5d7654cbf 100644
--- a/foreign/python/tests/utils.py
+++ b/foreign/python/tests/utils.py
@@ -32,6 +32,10 @@ MAX_USERNAME_BYTES = 50
 MIN_PASSWORD_BYTES = 3
 MAX_PASSWORD_BYTES = 100
 
+DEFAULT_TCP_PORT = 8090
+DEFAULT_QUIC_PORT = 8080
+DEFAULT_HTTP_PORT = 3000
+
 
 def get_transport_config(port_env_var: str, default_port: int) -> tuple[str, 
int]:
     """
@@ -69,7 +73,7 @@ def get_server_config() -> tuple[str, int]:
     Returns:
         tuple: (host, port) for the Iggy server
     """
-    return get_transport_config("IGGY_SERVER_TCP_PORT", 8090)
+    return get_transport_config("IGGY_SERVER_TCP_PORT", DEFAULT_TCP_PORT)
 
 
 def get_quic_server_config() -> tuple[str, int]:
@@ -79,7 +83,17 @@ def get_quic_server_config() -> tuple[str, int]:
     Returns:
         tuple: (host, port) for the Iggy server
     """
-    return get_transport_config("IGGY_SERVER_QUIC_PORT", 8080)
+    return get_transport_config("IGGY_SERVER_QUIC_PORT", DEFAULT_QUIC_PORT)
+
+
+def get_http_server_config() -> tuple[str, int]:
+    """
+    Get HTTP server configuration from environment variables or defaults.
+
+    Returns:
+        tuple: (host, port) for the Iggy HTTP API
+    """
+    return get_transport_config("IGGY_SERVER_HTTP_PORT", DEFAULT_HTTP_PORT)
 
 
 def wait_for_server(host: str, port: int, timeout: int = 60, interval: int = 
2) -> None:

Reply via email to