feat(python): add QuicConfig transport configuration (#3991)
Relates to #2835
diff --git a/.github/actions/python-maturin/pre-merge/action.yml b/.github/actions/python-maturin/pre-merge/action.yml
index 234c36e..3cca87f 100644
--- a/.github/actions/python-maturin/pre-merge/action.yml
+++ b/.github/actions/python-maturin/pre-merge/action.yml
@@ -180,6 +180,7 @@
# overwrite the coverage-instrumented .so with a non-instrumented one
IGGY_SERVER_HOST=127.0.0.1 \
IGGY_SERVER_TCP_PORT=8090 \
+ IGGY_SERVER_QUIC_PORT=8080 \
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 9daa683..d8f070b 100644
--- a/.github/workflows/coverage-baseline.yml
+++ b/.github/workflows/coverage-baseline.yml
@@ -349,6 +349,7 @@
cd foreign/python
IGGY_SERVER_HOST=127.0.0.1 \
IGGY_SERVER_TCP_PORT=8090 \
+ IGGY_SERVER_QUIC_PORT=8080 \
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 72e2503..10cfbc6 100644
--- a/core/sdk/src/prelude.rs
+++ b/core/sdk/src/prelude.rs
@@ -41,6 +41,7 @@
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::quic::quic_client::QuicClient;
pub use crate::stream_builder::IggyConsumerConfig;
pub use crate::stream_builder::IggyStreamConsumer;
pub use crate::stream_builder::{IggyProducerConfig, IggyStreamProducer};
diff --git a/examples/python/getting-started/consumer.py b/examples/python/getting-started/consumer.py
index 6510841..f3f7886 100755
--- a/examples/python/getting-started/consumer.py
+++ b/examples/python/getting-started/consumer.py
@@ -102,7 +102,22 @@
def build_config(args: ArgNamespace) -> TcpConfig:
- """Build a TCP client configuration with auto-login and reconnection."""
+ """Build the TCP client configuration 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:
+ #
+ # return QuicConfig(
+ # server_address="127.0.0.1:8080",
+ # server_name="localhost",
+ # auto_login=AutoLogin.username_password(args.username, args.password),
+ # reconnection=QuicReconnectionConfig(
+ # enabled=True, interval=timedelta(seconds=1)
+ # ),
+ # )
+ #
+ # main() logs args.tcp_server_address, so change that line too.
return TcpConfig(
server_address=args.tcp_server_address,
diff --git a/examples/python/getting-started/producer.py b/examples/python/getting-started/producer.py
index 80ab7c6..113bee8 100755
--- a/examples/python/getting-started/producer.py
+++ b/examples/python/getting-started/producer.py
@@ -101,7 +101,22 @@
def build_config(args: ArgNamespace) -> TcpConfig:
- """Build a TCP client configuration with auto-login and reconnection."""
+ """Build the TCP client configuration 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:
+ #
+ # return QuicConfig(
+ # server_address="127.0.0.1:8080",
+ # server_name="localhost",
+ # auto_login=AutoLogin.username_password(args.username, args.password),
+ # reconnection=QuicReconnectionConfig(
+ # enabled=True, interval=timedelta(seconds=1)
+ # ),
+ # )
+ #
+ # main() logs args.tcp_server_address, so change that line too.
return TcpConfig(
server_address=args.tcp_server_address,
diff --git a/foreign/python/README.md b/foreign/python/README.md
index e2a4fff..df62cfc 100644
--- a/foreign/python/README.md
+++ b/foreign/python/README.md
@@ -134,7 +134,7 @@
## Client Configuration
-`IggyClient` takes either a server address or a `TcpConfig`:
+`IggyClient` takes a server address, a `TcpConfig`, or a `QuicConfig`:
```python
import asyncio
@@ -168,6 +168,9 @@
asyncio.run(main())
```
+`IggyClient(...)` also accepts a `QuicConfig` for the QUIC transport; see
+`examples/python/getting-started/producer.py` for a config swap example.
+
## Examples
Refer to the [examples/python/](https://github.com/apache/iggy/tree/master/examples/python) directory for usage examples.
diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi
index ed15ed2..814549f 100644
--- a/foreign/python/apache_iggy.pyi
+++ b/foreign/python/apache_iggy.pyi
@@ -47,6 +47,8 @@
"Partition",
"Permissions",
"PollingStrategy",
+ "QuicConfig",
+ "QuicReconnectionConfig",
"ReceiveMessage",
"SendMessage",
"SendMessagesConfirmation",
@@ -897,23 +899,26 @@
A Python class representing the Iggy client.
It provides asynchronous functionality through the contained runtime.
"""
- def __new__(cls, conn: TcpConfig | builtins.str | None = None) -> IggyClient:
+ def __new__(
+ cls, conn: TcpConfig | QuicConfig | builtins.str | None = None
+ ) -> IggyClient:
r"""
- Constructs a new IggyClient from a TCP server address or a `TcpConfig`.
- This initializes a new runtime for asynchronous operations.
+ Constructs a new IggyClient from a TCP server address, a `TcpConfig`, or a
+ `QuicConfig`. This initializes a new runtime for asynchronous operations.
Future versions might utilize asyncio for more Pythonic async.
Args:
- conn: Either a `host:port` address, or a `TcpConfig` carrying the full
- transport configuration. Defaults to `127.0.0.1:8090` with auto-login
- disabled. A malformed address is reported differently by the two
- forms: the string form raises `RuntimeError` here, while `TcpConfig`
- raises `ValueError` when it is constructed, before it ever reaches
- this call. Neither exception is a subclass of the other.
+ 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.
Raises:
RuntimeError: If the address passed as a string is not a valid
- `host:port` pair.
+ `host:port` pair, or if a `QuicConfig` client cannot bind its
+ local UDP socket (for example the port is already in use).
"""
@classmethod
def from_connection_string(cls, connection_string: builtins.str) -> IggyClient:
@@ -1776,6 +1781,151 @@
...
@typing.final
+class QuicConfig:
+ r"""
+ Configuration for the QUIC transport, accepted by `IggyClient(...)`.
+
+ Every field is keyword-only and optional.
+ """
+ @property
+ def server_address(self) -> builtins.str: ...
+ @property
+ def client_address(self) -> builtins.str: ...
+ @property
+ def server_name(self) -> builtins.str: ...
+ @property
+ def auto_login(self) -> AutoLogin: ...
+ @property
+ def reconnection(self) -> QuicReconnectionConfig: ...
+ @property
+ def heartbeat_interval(self) -> datetime.timedelta: ...
+ @property
+ def response_buffer_size(self) -> builtins.int: ...
+ @property
+ def max_concurrent_bidi_streams(self) -> builtins.int: ...
+ @property
+ def datagram_send_buffer_size(self) -> builtins.int: ...
+ @property
+ def initial_mtu(self) -> builtins.int: ...
+ @property
+ def send_window(self) -> builtins.int: ...
+ @property
+ def receive_window(self) -> builtins.int: ...
+ @property
+ def keep_alive_interval(self) -> datetime.timedelta: ...
+ @property
+ def max_idle_timeout(self) -> datetime.timedelta: ...
+ @property
+ def validate_certificate(self) -> builtins.bool: ...
+ def __new__(
+ cls,
+ *,
+ server_address: builtins.str | None = None,
+ client_address: builtins.str | None = None,
+ server_name: builtins.str | None = None,
+ auto_login: AutoLogin | None = None,
+ reconnection: QuicReconnectionConfig | None = None,
+ heartbeat_interval: datetime.timedelta | None = None,
+ response_buffer_size: builtins.int | None = None,
+ max_concurrent_bidi_streams: builtins.int | None = None,
+ datagram_send_buffer_size: builtins.int | None = None,
+ initial_mtu: builtins.int | None = None,
+ send_window: builtins.int | None = None,
+ receive_window: builtins.int | None = None,
+ keep_alive_interval: datetime.timedelta | None = None,
+ max_idle_timeout: datetime.timedelta | None = None,
+ validate_certificate: builtins.bool | None = None,
+ ) -> QuicConfig:
+ r"""
+ Constructs a QUIC configuration.
+
+ Args:
+ server_address: `host:port` of the Iggy server. Defaults to `127.0.0.1:8080`.
+ client_address: `host:port` to bind the local UDP socket to. Defaults to
+ `127.0.0.1:0`, which binds to any available port. That exact value,
+ passed or defaulted, binds `[::1]:0` instead when `server_address`
+ resolves to IPv6, so the socket in use may not be the address read
+ back here. Any other value binds as given.
+ server_name: Server name used for the QUIC/TLS handshake. Defaults to
+ `localhost`.
+ auto_login: Credentials replayed on every connect. Defaults to `AutoLogin.disabled()`.
+ reconnection: Reconnection policy. Defaults to `QuicReconnectionConfig()`.
+ heartbeat_interval: Interval of heartbeats sent by the client. Defaults to 5 seconds.
+ response_buffer_size: Size of the response buffer in bytes. Defaults to 10 MB.
+ max_concurrent_bidi_streams: Maximum number of concurrent bidirectional
+ streams. Defaults to 10,000.
+ datagram_send_buffer_size: Size of the datagram send buffer in bytes.
+ Defaults to 100,000.
+ initial_mtu: Initial MTU in bytes. Defaults to 1200.
+ send_window: Send window size in bytes. Defaults to 100,000.
+ receive_window: Receive window size in bytes. Defaults to 100,000.
+ keep_alive_interval: Interval between QUIC keep-alive pings, or a zero
+ duration to disable them. Defaults to 5 seconds.
+ max_idle_timeout: How long the connection tolerates silence before it is
+ considered dead, or a zero duration to use quinn's own default (30
+ 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.
+
+ Raises:
+ ValueError: If `server_address` or `client_address` is not a valid
+ `host:port` pair, if a duration is negative, if
+ `heartbeat_interval` is zero, if `keep_alive_interval` or
+ `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.
+ """
+ def __repr__(self) -> builtins.str: ...
+
+@typing.final
+class QuicReconnectionConfig:
+ r"""
+ How the QUIC 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,
+ ) -> QuicReconnectionConfig:
+ 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, QUIC 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.
+ 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.
+ """
+ def __repr__(self) -> builtins.str: ...
+
+@typing.final
class ReceiveMessage:
r"""
A Python class representing a received message.
diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs
index 8252156..f15b6e2 100644
--- a/foreign/python/src/client.rs
+++ b/foreign/python/src/client.rs
@@ -28,6 +28,7 @@
use pyo3_stub_gen::define_stub_info_gatherer;
use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
use std::collections::BTreeMap;
+use std::fmt::Display;
use std::str::FromStr;
use std::sync::Arc;
@@ -58,6 +59,11 @@
inner: Arc<RustIggyClient>,
}
+/// Keeps the SDK's own message on the `RuntimeError` the Python surface raises.
+fn to_runtime_error<E: Display>(error: E) -> PyErr {
+ PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(error.to_string())
+}
+
/// Resolves the shared `create_topic`/`update_topic` parameters, applying
/// server defaults where the caller left them unset.
fn resolve_topic_params(
@@ -66,8 +72,7 @@
max_topic_size: Option<&MaxTopicSize>,
) -> PyResult<(CompressionAlgorithm, RustIggyExpiry, RustMaxTopicSize)> {
let compression_algorithm = match compression_algorithm {
- Some(algo) => CompressionAlgorithm::from_str(&algo)
- .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(e.to_string()))?,
+ Some(algo) => CompressionAlgorithm::from_str(&algo).map_err(to_runtime_error)?,
None => CompressionAlgorithm::default(),
};
@@ -87,44 +92,59 @@
#[gen_stub_pymethods]
#[pymethods]
impl IggyClient {
- /// Constructs a new IggyClient from a TCP server address or a `TcpConfig`.
- /// This initializes a new runtime for asynchronous operations.
+ /// Constructs a new IggyClient from a TCP server address, a `TcpConfig`, or a
+ /// `QuicConfig`. This initializes a new runtime for asynchronous operations.
/// Future versions might utilize asyncio for more Pythonic async.
///
/// Args:
- /// conn: Either a `host:port` address, or a `TcpConfig` carrying the full
- /// transport configuration. Defaults to `127.0.0.1:8090` with auto-login
- /// disabled. A malformed address is reported differently by the two
- /// forms: the string form raises `RuntimeError` here, while `TcpConfig`
- /// raises `ValueError` when it is constructed, before it ever reaches
- /// this call. Neither exception is a subclass of the other.
+ /// 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.
///
/// Raises:
/// RuntimeError: If the address passed as a string is not a valid
- /// `host:port` pair.
+ /// `host:port` pair, or if a `QuicConfig` client cannot bind its
+ /// local UDP socket (for example the port is already in use).
#[new]
#[pyo3(signature = (conn=None))]
fn new(
- #[gen_stub(override_type(type_repr = "TcpConfig | builtins.str | None"))] conn: Option<
- PyClientConfig,
- >,
+ #[gen_stub(override_type(type_repr = "TcpConfig | QuicConfig | builtins.str | None"))]
+ conn: Option<PyClientConfig>,
) -> PyResult<Self> {
- let config = match conn {
- Some(PyClientConfig::Config(config)) => config.client_config(),
- Some(PyClientConfig::ServerAddress(server_address)) => Arc::new(
- TcpClientConfigBuilder::new()
- .with_server_address(server_address)
- .build()
- .map_err(|e| {
- PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(e.to_string())
- })?,
+ let wrapper = match conn {
+ Some(PyClientConfig::Tcp(config)) => ClientWrapper::Tcp(
+ TcpClient::create(config.client_config()).map_err(to_runtime_error)?,
),
- None => Arc::new(TcpClientConfig::default()),
+ Some(PyClientConfig::ServerAddress(server_address)) => {
+ let config = Arc::new(
+ TcpClientConfigBuilder::new()
+ .with_server_address(server_address)
+ .build()
+ .map_err(to_runtime_error)?,
+ );
+ ClientWrapper::Tcp(TcpClient::create(config).map_err(to_runtime_error)?)
+ }
+ Some(PyClientConfig::Quic(config)) => {
+ // `quinn::Endpoint::client` (invoked eagerly by `QuicClient::create`) looks
+ // up the current Tokio runtime via `Handle::try_current()` and fails with
+ // `CannotCreateEndpoint` if none is active. This method runs synchronously
+ // from Python without one, so enter the runtime pyo3-async-runtimes uses
+ // for our own async methods before building the endpoint.
+ let _guard = pyo3_async_runtimes::tokio::get_runtime().enter();
+ ClientWrapper::Quic(
+ QuicClient::create(config.client_config()).map_err(to_runtime_error)?,
+ )
+ }
+ None => ClientWrapper::Tcp(
+ TcpClient::create(Arc::new(TcpClientConfig::default()))
+ .map_err(to_runtime_error)?,
+ ),
};
- let tcp_client = TcpClient::create(config)
- .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(e.to_string()))?;
- Ok(IggyClient {
- inner: Arc::new(RustIggyClient::new(ClientWrapper::Tcp(tcp_client))),
+ Ok(Self {
+ inner: Arc::new(RustIggyClient::new(wrapper)),
})
}
@@ -138,6 +158,11 @@
_cls: &Bound<'_, PyType>,
connection_string: String,
) -> PyResult<Self> {
+ // The QUIC transport builds its endpoint eagerly and needs a Tokio runtime context
+ // to do so (see the `QuicConfig` arm of `new()` above for details); entering it here
+ // is a no-op for the other transports since the protocol isn't known until the
+ // connection string is parsed.
+ let _guard = pyo3_async_runtimes::tokio::get_runtime().enter();
let client = RustIggyClient::from_connection_string(&connection_string)
.map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(e.to_string()))?;
Ok(Self {
diff --git a/foreign/python/src/config.rs b/foreign/python/src/config.rs
index ed6e2a1..cd98ef6 100644
--- a/foreign/python/src/config.rs
+++ b/foreign/python/src/config.rs
@@ -17,6 +17,8 @@
use iggy::prelude::{
AutoLogin as RustAutoLogin, Credentials as RustCredentials,
+ QuicClientConfig as RustQuicClientConfig, QuicClientConfigBuilder,
+ QuicClientReconnectionConfig as RustQuicClientReconnectionConfig,
TcpClientConfig as RustTcpClientConfig, TcpClientConfigBuilder,
TcpClientReconnectionConfig as RustTcpClientReconnectionConfig,
};
@@ -26,10 +28,12 @@
use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
use pyo3_stub_gen::impl_stub_type;
use secrecy::SecretString;
+use std::net::SocketAddr;
use std::sync::Arc;
use crate::duration::{
- duration_repr, iggy_duration_to_py_delta, py_delta_to_iggy_duration, reject_zero,
+ duration_repr, iggy_duration_to_py_delta, millis_repr, millis_to_py_delta,
+ py_delta_to_iggy_duration, py_delta_to_millis, reject_zero,
};
/// The credentials replayed by the client every time it (re)connects.
@@ -408,16 +412,469 @@
}
}
+/// How the QUIC client reconnects after the connection to the server is lost.
+#[gen_stub_pyclass]
+#[pyclass(from_py_object)]
+#[derive(Clone)]
+pub struct QuicReconnectionConfig {
+ pub(crate) inner: RustQuicClientReconnectionConfig,
+}
+
+#[gen_stub_pymethods]
+#[pymethods]
+impl QuicReconnectionConfig {
+ /// 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, QUIC 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.
+ /// 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.
+ #[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 = 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
+ ))
+ })
+ })
+ .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: RustQuicClientReconnectionConfig {
+ 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!(
+ "QuicReconnectionConfig(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),
+ )
+ }
+}
+
+/// quinn clamps `TransportConfig::initial_mtu` up to this floor rather than
+/// rejecting a smaller value, so `QuicConfig` rejects it instead: otherwise the
+/// getter would read back a value that is not the one actually in effect.
+const QUINN_MIN_INITIAL_MTU: u16 = 1200;
+
+/// Configuration for the QUIC transport, accepted by `IggyClient(...)`.
+///
+/// Every field is keyword-only and optional.
+#[gen_stub_pyclass]
+#[pyclass(from_py_object)]
+#[derive(Clone)]
+pub struct QuicConfig {
+ inner: Arc<RustQuicClientConfig>,
+}
+
+impl QuicConfig {
+ /// The configuration in the shape `QuicClient::create` expects.
+ pub(crate) fn client_config(&self) -> Arc<RustQuicClientConfig> {
+ self.inner.clone()
+ }
+}
+
+#[gen_stub_pymethods]
+#[pymethods]
+impl QuicConfig {
+ /// Constructs a QUIC configuration.
+ ///
+ /// Args:
+ /// server_address: `host:port` of the Iggy server. Defaults to `127.0.0.1:8080`.
+ /// client_address: `host:port` to bind the local UDP socket to. Defaults to
+ /// `127.0.0.1:0`, which binds to any available port. That exact value,
+ /// passed or defaulted, binds `[::1]:0` instead when `server_address`
+ /// resolves to IPv6, so the socket in use may not be the address read
+ /// back here. Any other value binds as given.
+ /// server_name: Server name used for the QUIC/TLS handshake. Defaults to
+ /// `localhost`.
+ /// auto_login: Credentials replayed on every connect. Defaults to `AutoLogin.disabled()`.
+ /// reconnection: Reconnection policy. Defaults to `QuicReconnectionConfig()`.
+ /// heartbeat_interval: Interval of heartbeats sent by the client. Defaults to 5 seconds.
+ /// response_buffer_size: Size of the response buffer in bytes. Defaults to 10 MB.
+ /// max_concurrent_bidi_streams: Maximum number of concurrent bidirectional
+ /// streams. Defaults to 10,000.
+ /// datagram_send_buffer_size: Size of the datagram send buffer in bytes.
+ /// Defaults to 100,000.
+ /// initial_mtu: Initial MTU in bytes. Defaults to 1200.
+ /// send_window: Send window size in bytes. Defaults to 100,000.
+ /// receive_window: Receive window size in bytes. Defaults to 100,000.
+ /// keep_alive_interval: Interval between QUIC keep-alive pings, or a zero
+ /// duration to disable them. Defaults to 5 seconds.
+ /// max_idle_timeout: How long the connection tolerates silence before it is
+ /// considered dead, or a zero duration to use quinn's own default (30
+ /// 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.
+ ///
+ /// Raises:
+ /// ValueError: If `server_address` or `client_address` is not a valid
+ /// `host:port` pair, if a duration is negative, if
+ /// `heartbeat_interval` is zero, if `keep_alive_interval` or
+ /// `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.
+ #[new]
+ #[pyo3(signature = (
+ *,
+ server_address=None,
+ client_address=None,
+ server_name=None,
+ auto_login=None,
+ reconnection=None,
+ heartbeat_interval=None,
+ response_buffer_size=None,
+ max_concurrent_bidi_streams=None,
+ datagram_send_buffer_size=None,
+ initial_mtu=None,
+ send_window=None,
+ receive_window=None,
+ keep_alive_interval=None,
+ max_idle_timeout=None,
+ 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 = "builtins.str | None"))] client_address: Option<
+ String,
+ >,
+ #[gen_stub(override_type(type_repr = "builtins.str | None"))] server_name: Option<String>,
+ #[gen_stub(override_type(type_repr = "AutoLogin | None"))] auto_login: Option<AutoLogin>,
+ #[gen_stub(override_type(type_repr = "QuicReconnectionConfig | None"))]
+ reconnection: Option<QuicReconnectionConfig>,
+ #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))]
+ heartbeat_interval: Option<Py<PyDelta>>,
+ #[gen_stub(override_type(type_repr = "builtins.int | None"))] response_buffer_size: Option<
+ i64,
+ >,
+ #[gen_stub(override_type(type_repr = "builtins.int | None"))]
+ max_concurrent_bidi_streams: Option<i64>,
+ #[gen_stub(override_type(type_repr = "builtins.int | None"))]
+ datagram_send_buffer_size: Option<i64>,
+ #[gen_stub(override_type(type_repr = "builtins.int | None"))] initial_mtu: Option<i64>,
+ #[gen_stub(override_type(type_repr = "builtins.int | None"))] send_window: Option<i64>,
+ #[gen_stub(override_type(type_repr = "builtins.int | None"))] receive_window: Option<i64>,
+ #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))]
+ keep_alive_interval: Option<Py<PyDelta>>,
+ #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))]
+ max_idle_timeout: Option<Py<PyDelta>>,
+ #[gen_stub(override_type(type_repr = "builtins.bool | None"))] validate_certificate: Option<
+ bool,
+ >,
+ ) -> PyResult<Self> {
+ // The builder starts from `QuicClientConfig::default()`, and its `build()`
+ // trims and validates the server address whether or not one was set here.
+ let mut builder = QuicClientConfigBuilder::new();
+ if let Some(server_address) = server_address {
+ builder = builder.with_server_address(server_address);
+ }
+ let mut inner = builder
+ .build()
+ .map_err(|e| PyValueError::new_err(e.to_string()))?;
+ 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;
+ }
+ if let Some(server_name) = server_name {
+ inner.server_name = server_name;
+ }
+ 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(response_buffer_size) = response_buffer_size {
+ inner.response_buffer_size = u64_param(response_buffer_size, "response_buffer_size")?;
+ }
+ if let Some(max_concurrent_bidi_streams) = max_concurrent_bidi_streams {
+ inner.max_concurrent_bidi_streams =
+ varint_param(max_concurrent_bidi_streams, "max_concurrent_bidi_streams")?;
+ }
+ if let Some(datagram_send_buffer_size) = datagram_send_buffer_size {
+ inner.datagram_send_buffer_size =
+ u64_param(datagram_send_buffer_size, "datagram_send_buffer_size")?;
+ }
+ if let Some(initial_mtu) = initial_mtu {
+ let initial_mtu = u16_param(initial_mtu, "initial_mtu")?;
+ if initial_mtu < QUINN_MIN_INITIAL_MTU {
+ return Err(PyValueError::new_err(format!(
+ "'initial_mtu' must be at least {QUINN_MIN_INITIAL_MTU}; quinn silently \
+ raises anything smaller to that floor, so the getter would no longer \
+ match the value actually in effect"
+ )));
+ }
+ inner.initial_mtu = initial_mtu;
+ }
+ if let Some(send_window) = send_window {
+ inner.send_window = u64_param(send_window, "send_window")?;
+ }
+ if let Some(receive_window) = receive_window {
+ inner.receive_window = varint_param(receive_window, "receive_window")?;
+ }
+ if let Some(keep_alive_interval) = keep_alive_interval {
+ inner.keep_alive_interval =
+ py_delta_to_millis(&keep_alive_interval, "keep_alive_interval")?;
+ }
+ if let Some(max_idle_timeout) = max_idle_timeout {
+ inner.max_idle_timeout = py_delta_to_millis(&max_idle_timeout, "max_idle_timeout")?;
+ }
+ if let Some(validate_certificate) = validate_certificate {
+ inner.validate_certificate = validate_certificate;
+ }
+
+ Ok(Self {
+ inner: Arc::new(inner),
+ })
+ }
+
+ #[getter]
+ fn server_address(&self) -> String {
+ self.inner.server_address.clone()
+ }
+
+ #[getter]
+ fn client_address(&self) -> String {
+ self.inner.client_address.clone()
+ }
+
+ #[getter]
+ fn server_name(&self) -> String {
+ self.inner.server_name.clone()
+ }
+
+ #[getter]
+ fn auto_login(&self) -> AutoLogin {
+ AutoLogin {
+ inner: self.inner.auto_login.clone(),
+ }
+ }
+
+ #[getter]
+ fn reconnection(&self) -> QuicReconnectionConfig {
+ QuicReconnectionConfig {
+ 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())
+ }
+
+ #[gen_stub(override_return_type(type_repr = "builtins.int"))]
+ #[getter]
+ fn response_buffer_size(&self) -> u64 {
+ self.inner.response_buffer_size
+ }
+
+ #[gen_stub(override_return_type(type_repr = "builtins.int"))]
+ #[getter]
+ fn max_concurrent_bidi_streams(&self) -> u64 {
+ self.inner.max_concurrent_bidi_streams
+ }
+
+ #[gen_stub(override_return_type(type_repr = "builtins.int"))]
+ #[getter]
+ fn datagram_send_buffer_size(&self) -> u64 {
+ self.inner.datagram_send_buffer_size
+ }
+
+ #[gen_stub(override_return_type(type_repr = "builtins.int"))]
+ #[getter]
+ fn initial_mtu(&self) -> u16 {
+ self.inner.initial_mtu
+ }
+
+ #[gen_stub(override_return_type(type_repr = "builtins.int"))]
+ #[getter]
+ fn send_window(&self) -> u64 {
+ self.inner.send_window
+ }
+
+ #[gen_stub(override_return_type(type_repr = "builtins.int"))]
+ #[getter]
+ fn receive_window(&self) -> u64 {
+ self.inner.receive_window
+ }
+
+ #[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))]
+ #[getter]
+ fn keep_alive_interval<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyDelta>> {
+ millis_to_py_delta(py, self.inner.keep_alive_interval)
+ }
+
+ #[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))]
+ #[getter]
+ fn max_idle_timeout<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyDelta>> {
+ millis_to_py_delta(py, self.inner.max_idle_timeout)
+ }
+
+ #[getter]
+ fn validate_certificate(&self) -> bool {
+ self.inner.validate_certificate
+ }
+
+ fn __repr__(&self) -> String {
+ format!(
+ "QuicConfig(server_address={:?}, client_address={:?}, server_name={:?}, auto_login={}, reconnection={}, heartbeat_interval={}, response_buffer_size={}, max_concurrent_bidi_streams={}, datagram_send_buffer_size={}, initial_mtu={}, send_window={}, receive_window={}, keep_alive_interval={}, max_idle_timeout={}, validate_certificate={})",
+ self.inner.server_address,
+ self.inner.client_address,
+ self.inner.server_name,
+ self.auto_login().__repr__(),
+ self.reconnection().__repr__(),
+ duration_repr(self.inner.heartbeat_interval.get()),
+ self.inner.response_buffer_size,
+ self.inner.max_concurrent_bidi_streams,
+ self.inner.datagram_send_buffer_size,
+ self.inner.initial_mtu,
+ self.inner.send_window,
+ self.inner.receive_window,
+ millis_repr(self.inner.keep_alive_interval),
+ millis_repr(self.inner.max_idle_timeout),
+ python_bool(self.inner.validate_certificate),
+ )
+ }
+}
+
fn python_bool(value: bool) -> &'static str {
if value { "True" } else { "False" }
}
-/// What `IggyClient(...)` accepts: a bare `host:port` or a full `TcpConfig`.
+/// 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`
+/// rather than `u64::MAX` because pyo3 extracts the argument as an `i64`
+/// first: anything above that never reaches here, raising `OverflowError`
+/// on the way in. Every one of these fields is a buffer or window size, so
+/// the unreachable half of the range has no practical use.
+fn u64_param(value: i64, parameter: &str) -> PyResult<u64> {
+ u64::try_from(value).map_err(|_| {
+ PyValueError::new_err(format!("'{parameter}' must be between 0 and {}", i64::MAX))
+ })
+}
+
+/// Converts a Python int to the unsigned 16-bit integer `initial_mtu` expects.
+fn u16_param(value: i64, parameter: &str) -> PyResult<u16> {
+ u16::try_from(value).map_err(|_| {
+ PyValueError::new_err(format!("'{parameter}' must be between 0 and {}", u16::MAX))
+ })
+}
+
+/// Converts a Python int to a `u64` that also fits `quinn::VarInt` (max
+/// `2^62 - 1`), which `max_concurrent_bidi_streams` and `receive_window` are
+/// narrowed into when the connection is configured. A `u64` in range for
+/// `u64::MAX` but not `VarInt::MAX` would otherwise only fail there, as an
+/// opaque `RuntimeError` instead of a `ValueError` naming the argument.
+fn varint_param(value: i64, parameter: &str) -> PyResult<u64> {
+ const VARINT_MAX: u64 = (1u64 << 62) - 1;
+ let value = u64_param(value, parameter)?;
+ if value > VARINT_MAX {
+ return Err(PyValueError::new_err(format!(
+ "'{parameter}' must be between 0 and {VARINT_MAX}"
+ )));
+ }
+ Ok(value)
+}
+
+/// What `IggyClient(...)` accepts: a bare `host:port`, a full `TcpConfig`, or a
+/// `QuicConfig` for the QUIC transport.
#[derive(FromPyObject)]
pub enum PyClientConfig {
#[pyo3(transparent)]
- Config(TcpConfig),
+ Tcp(TcpConfig),
+ #[pyo3(transparent)]
+ Quic(QuicConfig),
#[pyo3(transparent, annotation = "str")]
ServerAddress(String),
}
-impl_stub_type!(PyClientConfig = TcpConfig | String);
+impl_stub_type!(PyClientConfig = TcpConfig | QuicConfig | String);
diff --git a/foreign/python/src/duration.rs b/foreign/python/src/duration.rs
index 1d448ad..776df66 100644
--- a/foreign/python/src/duration.rs
+++ b/foreign/python/src/duration.rs
@@ -40,6 +40,34 @@
duration.get_duration().into_pyobject(py)
}
+/// Converts a Python timedelta to milliseconds, for fields the Rust SDK
+/// stores as a raw millisecond count rather than an `IggyDuration` (e.g.
+/// QUIC's `keep_alive_interval`/`max_idle_timeout`). Anything finer than a
+/// millisecond is rejected rather than truncated, so the getter always reads
+/// back the duration that is actually in effect. Zero is accepted for both of
+/// those fields as a magic value (disables the keep-alive, or falls back to
+/// quinn's own default), so a non-zero duration below 1ms is rejected with its
+/// own message rather than collapsing into it.
+pub fn py_delta_to_millis(delta: &Py<PyDelta>, parameter: &str) -> PyResult<u64> {
+ let duration = py_delta_to_iggy_duration(delta)?.get_duration();
+ if !duration.is_zero() && duration.as_millis() == 0 {
+ return Err(PyValueError::new_err(format!(
+ "'{parameter}' is non-zero but rounds down to 0ms; use a duration of at least 1ms, or exactly zero"
+ )));
+ }
+ if duration.subsec_nanos() % 1_000_000 != 0 {
+ return Err(PyValueError::new_err(format!(
+ "'{parameter}' must be a whole number of milliseconds; anything finer is dropped by the QUIC transport, which stores it as a millisecond count"
+ )));
+ }
+ Ok(duration.as_millis() as u64)
+}
+
+/// The inverse of `py_delta_to_millis`.
+pub fn millis_to_py_delta(py: Python<'_>, millis: u64) -> PyResult<Bound<'_, PyDelta>> {
+ Duration::from_millis(millis).into_pyobject(py)
+}
+
/// Renders a duration the way it would be written in Python, so that a `__repr__`
/// built from it can be pasted back into a constructor.
pub fn duration_repr(duration: IggyDuration) -> String {
@@ -53,6 +81,11 @@
}
}
+/// The `duration_repr` equivalent for a raw millisecond count.
+pub fn millis_repr(millis: u64) -> String {
+ duration_repr(IggyDuration::new(Duration::from_millis(millis)))
+}
+
/// Converts a duration for parameters that pace a loop, where zero means an
/// unthrottled loop rather than "disabled".
pub fn reject_zero(duration: IggyDuration, parameter: &str) -> PyResult<NonZeroIggyDuration> {
diff --git a/foreign/python/src/lib.rs b/foreign/python/src/lib.rs
index d4397d5..5da3487 100644
--- a/foreign/python/src/lib.rs
+++ b/foreign/python/src/lib.rs
@@ -31,7 +31,7 @@
mod user_headers;
use client::IggyClient;
-use config::{AutoLogin, TcpConfig, TcpReconnectionConfig};
+use config::{AutoLogin, QuicConfig, QuicReconnectionConfig, TcpConfig, TcpReconnectionConfig};
use consumer::{
AutoCommit, AutoCommitAfter, AutoCommitWhen, Consumer, ConsumerGroup, ConsumerGroupDetails,
ConsumerGroupMember, IggyConsumer, ReceiveMessageIterator,
@@ -58,6 +58,8 @@
m.add_class::<AutoLogin>()?;
m.add_class::<TcpConfig>()?;
m.add_class::<TcpReconnectionConfig>()?;
+ m.add_class::<QuicConfig>()?;
+ m.add_class::<QuicReconnectionConfig>()?;
m.add_class::<StreamDetails>()?;
m.add_class::<Stats>()?;
m.add_class::<CacheMetrics>()?;
diff --git a/foreign/python/tests/test_connectivity.py b/foreign/python/tests/test_connectivity.py
index 69516d7..3b42307 100644
--- a/foreign/python/tests/test_connectivity.py
+++ b/foreign/python/tests/test_connectivity.py
@@ -40,6 +40,7 @@
"iggy+http://iggy:iggy@127.0.0.1:3000?heartbeat_interval=5s&retries=3",
"iggy+ws://iggy:iggy@127.0.0.1:8092",
"iggy+ws://iggy:iggy@127.0.0.1:8092?heartbeat_interval=5s&reconnection_retries=3&reconnection_interval=1s&reestablish_after=5s&read_buffer_size=4096&write_buffer_size=4096&max_write_buffer_size=8192&max_message_size=16384&max_frame_size=16384&accept_unmasked_frames=false&tls_domain=localhost&tls_ca_file=unused.pem&tls_validate_certificate=false&tls=false",
+ "iggy+quic://iggy:iggy@127.0.0.1:8080?reconnection_max_retries=0",
],
)
@pytest.mark.asyncio
@@ -77,7 +78,6 @@
"iggy+tcp://iggy:iggy@{host}:{port}?invalid_option=value",
"Invalid connection string",
),
- ("iggy+quic://iggy:iggy@127.0.0.1:8080", "Cannot create endpoint"),
],
)
def test_invalid_connection_string(self, invalid_value: str, expected_error: str):
diff --git a/foreign/python/tests/test_quic_config.py b/foreign/python/tests/test_quic_config.py
new file mode 100644
index 0000000..d0fdc3f
--- /dev/null
+++ b/foreign/python/tests/test_quic_config.py
@@ -0,0 +1,434 @@
+# 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 QUIC client configuration surface.
+
+`QuicConfig` and `QuicReconnectionConfig` 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. `AutoLogin` is transport-agnostic and already
+covered by `test_client_config.py`.
+"""
+
+import ast
+from collections.abc import Callable
+from datetime import timedelta
+
+import pytest
+
+from apache_iggy import AutoLogin, IggyClient, QuicConfig, QuicReconnectionConfig
+
+from .utils import get_quic_server_config, wait_for_ping
+
+
+@pytest.mark.unit
+class TestQuicReconnectionConfig:
+ """Test the reconnection policy."""
+
+ def test_defaults_match_the_rust_sdk(self):
+ """Test that an unconfigured policy reconnects forever, one second apart."""
+ reconnection = QuicReconnectionConfig()
+
+ 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 = QuicReconnectionConfig(
+ 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
+ QuicReconnectionConfig(True)
+
+ @pytest.mark.parametrize(
+ "construct",
+ [
+ lambda duration: QuicReconnectionConfig(interval=duration),
+ lambda duration: QuicReconnectionConfig(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], QuicReconnectionConfig],
+ 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"):
+ QuicReconnectionConfig(max_retries=out_of_range)
+
+ def test_zero_reestablish_after_is_allowed(self):
+ """Test that a zero cooldown is legal and readable back."""
+ reconnection = QuicReconnectionConfig(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=r"interval.*must not be zero"):
+ QuicReconnectionConfig(interval=timedelta(0), **kwargs)
+
+ def test_very_long_interval_round_trips(self):
+ """Test that an interval beyond 68 years survives the i32 boundary."""
+ reconnection = QuicReconnectionConfig(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 = QuicReconnectionConfig(interval=timedelta(days=999_999_999))
+
+ assert reconnection.interval == timedelta(days=999_999_999)
+
+
+@pytest.mark.unit
+class TestQuicConfig:
+ """Test the transport configuration."""
+
+ def test_defaults_match_the_rust_sdk(self):
+ """Test that an unconfigured transport matches the Rust SDK defaults."""
+ config = QuicConfig()
+
+ assert config.server_address == "127.0.0.1:8080"
+ assert config.client_address == "127.0.0.1:0"
+ assert config.server_name == "localhost"
+ assert config.auto_login.enabled is False
+ assert config.reconnection.enabled is True
+ assert config.heartbeat_interval == timedelta(seconds=5)
+ assert config.response_buffer_size == 10_000_000
+ assert config.max_concurrent_bidi_streams == 10_000
+ assert config.datagram_send_buffer_size == 100_000
+ assert config.initial_mtu == 1200
+ assert config.send_window == 100_000
+ assert config.receive_window == 100_000
+ assert config.keep_alive_interval == timedelta(milliseconds=5000)
+ assert config.max_idle_timeout == timedelta(milliseconds=10_000)
+ assert config.validate_certificate is False
+
+ def test_every_field_round_trips(self):
+ """Test that each configured field is readable back unchanged."""
+ config = QuicConfig(
+ server_address="127.0.0.1:8081",
+ client_address="127.0.0.1:9000",
+ server_name="example.com",
+ auto_login=AutoLogin.username_password("iggy", "iggy"),
+ reconnection=QuicReconnectionConfig(max_retries=3),
+ heartbeat_interval=timedelta(seconds=15),
+ response_buffer_size=5_000_000,
+ max_concurrent_bidi_streams=500,
+ datagram_send_buffer_size=50_000,
+ initial_mtu=1400,
+ send_window=200_000,
+ receive_window=200_000,
+ keep_alive_interval=timedelta(seconds=2),
+ max_idle_timeout=timedelta(seconds=20),
+ validate_certificate=True,
+ )
+
+ assert config.server_address == "127.0.0.1:8081"
+ assert config.client_address == "127.0.0.1:9000"
+ assert config.server_name == "example.com"
+ assert config.auto_login.username == "iggy"
+ assert config.reconnection.max_retries == 3
+ assert config.heartbeat_interval == timedelta(seconds=15)
+ assert config.response_buffer_size == 5_000_000
+ assert config.max_concurrent_bidi_streams == 500
+ assert config.datagram_send_buffer_size == 50_000
+ assert config.initial_mtu == 1400
+ assert config.send_window == 200_000
+ assert config.receive_window == 200_000
+ assert config.keep_alive_interval == timedelta(seconds=2)
+ assert config.max_idle_timeout == timedelta(seconds=20)
+ assert config.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
+ QuicConfig("127.0.0.1:8080")
+
+ def test_repr_hides_the_password(self):
+ """Test that the password does not leak through repr."""
+ config = QuicConfig(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 QUIC-specific fields and parses as Python."""
+ config = QuicConfig(
+ heartbeat_interval=timedelta(seconds=15),
+ keep_alive_interval=timedelta(seconds=2),
+ max_idle_timeout=timedelta(seconds=20),
+ validate_certificate=True,
+ )
+
+ printed = repr(config)
+
+ 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
+ assert "max_idle_timeout=datetime.timedelta(seconds=20)" 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: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):
+ QuicConfig(server_address=invalid_address)
+
+ @pytest.mark.parametrize(
+ "invalid_address",
+ ["", "127.0.0.1", "127.0.0.1:not-a-port", "127.0.0.1:70000", "localhost:0"],
+ )
+ def test_invalid_client_address_is_rejected(self, invalid_address: str):
+ """Test that a malformed bind address fails at construction.
+
+ `QuicClient::create` parses this as a `SocketAddr`, so a hostname is
+ rejected alongside the malformed forms: without the eager check the
+ failure would surface as a `RuntimeError` from `IggyClient(...)`
+ instead, which is not a `ValueError` and so escapes the handler a
+ caller wraps construction in.
+ """
+ with pytest.raises(ValueError, match="client_address"):
+ QuicConfig(client_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"):
+ QuicConfig(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=r"heartbeat_interval.*must not be zero"):
+ QuicConfig(heartbeat_interval=timedelta(0))
+
+ @pytest.mark.parametrize(
+ ("field", "out_of_range"),
+ [
+ ("response_buffer_size", -1),
+ ("max_concurrent_bidi_streams", -1),
+ ("datagram_send_buffer_size", -1),
+ ("send_window", -1),
+ ("receive_window", -1),
+ ("initial_mtu", -1),
+ ("initial_mtu", 2**16),
+ ("max_concurrent_bidi_streams", 2**62),
+ ("receive_window", 2**62),
+ ],
+ )
+ def test_out_of_range_numeric_field_is_rejected(
+ self, field: str, out_of_range: int
+ ):
+ """Test that a numeric field outside its wire type's range names itself.
+
+ `max_concurrent_bidi_streams` and `receive_window` fit `u64`, but
+ quinn narrows them further into a `VarInt` (max `2**62 - 1`), so
+ `2**62` fits the wire type and must still be rejected.
+ """
+ with pytest.raises(ValueError, match=field):
+ # pyrefly: ignore # bad-argument-type
+ QuicConfig(**{field: out_of_range})
+
+ @pytest.mark.parametrize("field", ["keep_alive_interval", "max_idle_timeout"])
+ def test_duration_rounding_down_to_zero_millis_is_rejected(self, field: str):
+ """Test that a non-zero sub-millisecond duration names itself.
+
+ Both fields are raw millisecond counts to the Rust SDK where zero is a
+ magic value (disables the keep-alive, or falls back to quinn's own
+ default), so a duration that rounds down to zero would silently mean
+ something other than what was asked for.
+ """
+ with pytest.raises(ValueError, match=rf"{field}.*rounds down to 0ms"):
+ # pyrefly: ignore # bad-argument-type
+ QuicConfig(**{field: timedelta(microseconds=500)})
+
+ @pytest.mark.parametrize("field", ["keep_alive_interval", "max_idle_timeout"])
+ def test_sub_millisecond_precision_is_rejected(self, field: str):
+ """Test that a duration with a sub-millisecond remainder is refused.
+
+ The Rust SDK stores both fields as a millisecond count, so the
+ remainder would be dropped and the getter would read back a different
+ duration than the one that was passed in.
+ """
+ with pytest.raises(ValueError, match=rf"{field}.*whole number of milliseconds"):
+ # pyrefly: ignore # bad-argument-type
+ QuicConfig(**{field: timedelta(milliseconds=1, microseconds=500)})
+
+ @pytest.mark.parametrize("field", ["keep_alive_interval", "max_idle_timeout"])
+ def test_exact_zero_duration_is_allowed(self, field: str):
+ """Test that an exact zero duration is still legal for these fields."""
+ # pyrefly: ignore # bad-argument-type
+ config = QuicConfig(**{field: timedelta(0)})
+
+ assert getattr(config, field) == timedelta(0)
+
+ @pytest.mark.parametrize("field", ["keep_alive_interval", "max_idle_timeout"])
+ def test_whole_millisecond_duration_is_allowed(self, field: str):
+ """Test that a duration that is not a whole number of seconds round-trips.
+
+ Every other duration these fields accept here is second-aligned, so a
+ check tightened to whole seconds would otherwise pass the suite.
+ """
+ # pyrefly: ignore # bad-argument-type
+ config = QuicConfig(**{field: timedelta(milliseconds=1500)})
+
+ assert getattr(config, field) == timedelta(milliseconds=1500)
+
+ def test_initial_mtu_below_quinns_minimum_is_rejected(self):
+ """Test that an initial_mtu below 1200 fails at construction.
+
+ quinn silently raises anything smaller to that floor instead of
+ rejecting it, so accepting it here would let the getter read back a
+ value that is not the one actually in effect on the connection.
+ """
+ with pytest.raises(ValueError, match="initial_mtu"):
+ QuicConfig(initial_mtu=1199)
+
+ def test_initial_mtu_at_quinns_minimum_is_allowed(self):
+ """Test that exactly 1200, quinn's own floor, is accepted."""
+ config = QuicConfig(initial_mtu=1200)
+
+ assert config.initial_mtu == 1200
+
+
+@pytest.mark.unit
+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
+
+ def test_accepts_the_default_config(self):
+ """Test that an explicit default `QuicConfig` is accepted."""
+ assert IggyClient(QuicConfig()) is not None
+
+
+@pytest.mark.integration
+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_quic_server_config()
+
+ client = IggyClient(
+ QuicConfig(
+ server_address=f"{host}:{port}",
+ auto_login=AutoLogin.username_password("iggy", "iggy"),
+ # The default reconnection policy retries forever: a missing
+ # listener would stall this test for the full 30s pytest
+ # timeout instead of failing fast.
+ reconnection=QuicReconnectionConfig(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_quic_server_config()
+
+ client = IggyClient(
+ QuicConfig(
+ server_address=f"{host}:{port}",
+ # The default reconnection policy retries forever: a missing
+ # listener would stall this test for the full 30s pytest
+ # timeout instead of failing fast.
+ reconnection=QuicReconnectionConfig(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_quic_server_config()
+
+ client = IggyClient(
+ QuicConfig(
+ server_address=f"{host}:{port}",
+ auto_login=AutoLogin.username_password("iggy", "invalid-password"),
+ reconnection=QuicReconnectionConfig(enabled=False),
+ )
+ )
+
+ # A bare RuntimeError would also match "Cannot establish connection",
+ # which is what a missing listener raises on this reconnection policy.
+ with pytest.raises(RuntimeError, match="Invalid credentials"):
+ await client.connect()
diff --git a/foreign/python/tests/utils.py b/foreign/python/tests/utils.py
index b37a538..cdede9f 100644
--- a/foreign/python/tests/utils.py
+++ b/foreign/python/tests/utils.py
@@ -33,15 +33,19 @@
MAX_PASSWORD_BYTES = 100
-def get_server_config() -> tuple[str, int]:
+def get_transport_config(port_env_var: str, default_port: int) -> tuple[str, int]:
"""
- Get server configuration from environment variables or defaults.
+ Get transport-specific server configuration from environment variables or defaults.
+
+ Args:
+ port_env_var: Name of the environment variable holding the port.
+ default_port: Port to use if the environment variable is not set.
Returns:
tuple: (host, port) for the Iggy server
"""
host = os.environ.get("IGGY_SERVER_HOST", "127.0.0.1")
- port = int(os.environ.get("IGGY_SERVER_TCP_PORT", "8090"))
+ port = int(os.environ.get(port_env_var, str(default_port)))
# Convert hostname to IP address for the Rust client
if host not in ("127.0.0.1", "localhost"):
@@ -58,6 +62,26 @@
return host, port
+def get_server_config() -> tuple[str, int]:
+ """
+ Get TCP server configuration from environment variables or defaults.
+
+ Returns:
+ tuple: (host, port) for the Iggy server
+ """
+ return get_transport_config("IGGY_SERVER_TCP_PORT", 8090)
+
+
+def get_quic_server_config() -> tuple[str, int]:
+ """
+ Get QUIC server configuration from environment variables or defaults.
+
+ Returns:
+ tuple: (host, port) for the Iggy server
+ """
+ return get_transport_config("IGGY_SERVER_QUIC_PORT", 8080)
+
+
def wait_for_server(host: str, port: int, timeout: int = 60, interval: int = 2) -> None:
"""
Wait for the server to become available.