blob: eb4089a154163e1b08ff7f78795e1d26a1c2904b [file]
# 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
import socket
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.
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
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 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),
validate_certificate=True,
)
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
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, naming itself."""
with pytest.raises(ValueError, match="server_address"):
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_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"):
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 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."""
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()