Skip to content

Commit c53c3f9

Browse files
mhusbynflowclaude
andcommitted
feat: add connection retry support to v2 client
Large file uploads can fail partway through due to transient network issues. This adds automatic connection-level retries using httpx's built-in HTTPTransport(retries=N), which only retries on ConnectError and ConnectTimeout — cases where the TCP connection was never established, making it safe for all HTTP methods including POST. Configurable via ClientConfig(connection_retries=N), defaulting to 3. Set to 0 to disable. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 89872a4 commit c53c3f9

5 files changed

Lines changed: 76 additions & 2 deletions

File tree

flowbio/v2/_transport.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,16 @@ class HttpTransport:
2424
2525
:param base_url: The base URL of the Flow API
2626
(e.g. ``"https://app.flow.bio/api"``).
27+
:param connection_retries: Number of retries on connection failure.
28+
Only retries ``ConnectError``/``ConnectTimeout`` (TCP never
29+
established), so it is safe for all HTTP methods. Defaults to ``3``.
2730
"""
2831

29-
def __init__(self, base_url: str) -> None:
32+
def __init__(self, base_url: str, connection_retries: int = 3) -> None:
3033
self._base_url = base_url.rstrip("/")
3134
self._client = httpx.Client(
3235
headers={"User-Agent": f"flowbio-python/{_CLIENT_VERSION}"},
36+
transport=httpx.HTTPTransport(retries=connection_retries),
3337
)
3438

3539
_STATUS_TO_EXCEPTION: dict[int, type[FlowApiError]] = {

flowbio/v2/client.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ class ClientConfig:
1313
Defaults to 1 MB.
1414
:param show_progress: Whether to display progress bars during
1515
file uploads. Defaults to ``True``.
16+
:param connection_retries: Number of times to retry on connection
17+
failure (e.g. ``ConnectError``, ``ConnectTimeout``). Only retries
18+
when the TCP connection was never established, so it is safe for
19+
all HTTP methods including POST. Set to ``0`` to disable.
20+
Defaults to ``3``.
1621
1722
Example usage::
1823
@@ -26,6 +31,7 @@ class ClientConfig:
2631

2732
chunk_size: int = 1_000_000
2833
show_progress: bool = True
34+
connection_retries: int = 3
2935

3036

3137
class Client:
@@ -58,8 +64,11 @@ def __init__(
5864
base_url: str = "https://app.flow.bio/api",
5965
config: ClientConfig | None = None,
6066
) -> None:
61-
self._transport = HttpTransport(base_url)
6267
self._config = config or ClientConfig()
68+
self._transport = HttpTransport(
69+
base_url,
70+
connection_retries=self._config.connection_retries,
71+
)
6372
self._samples = SampleResource(self._transport, self._config)
6473

6574
@property

tests/unit/v2/test_client.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
from unittest.mock import patch
23

34
import httpx
45
import pytest
@@ -111,6 +112,12 @@ def test_default_config_values(self) -> None:
111112

112113
assert config.chunk_size == 1_000_000
113114
assert config.show_progress is True
115+
assert config.connection_retries == 3
116+
117+
def test_custom_connection_retries(self) -> None:
118+
config = ClientConfig(connection_retries=0)
119+
120+
assert config.connection_retries == 0
114121

115122
def test_config_is_immutable(self) -> None:
116123
config = ClientConfig()
@@ -120,3 +127,14 @@ def test_config_is_immutable(self) -> None:
120127

121128
def test_client_accepts_custom_config(self) -> None:
122129
Client(config=ClientConfig(chunk_size=5_000_000))
130+
131+
@patch("flowbio.v2.client.HttpTransport")
132+
def test_client_passes_connection_retries_to_transport(self, mock_transport) -> None:
133+
connection_retries = 5
134+
135+
Client(config=ClientConfig(connection_retries=connection_retries))
136+
137+
mock_transport.assert_called_once_with(
138+
"https://app.flow.bio/api",
139+
connection_retries=connection_retries,
140+
)

tests/unit/v2/test_samples.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,25 @@ def test_uploads_in_chunks(self, tmp_path: Path) -> None:
425425

426426
assert route.call_count == 3
427427

428+
@respx.mock
429+
def test_connect_error_on_chunk_propagates(self, tmp_path: Path) -> None:
430+
file_path = tmp_path / "reads.fastq"
431+
file_path.write_bytes(b"A" * 100)
432+
route = respx.post(f"{DEFAULT_BASE_URL}/upload/sample")
433+
route.side_effect = [
434+
httpx.Response(200, json={"sample_id": None, "data_id": "d1"}),
435+
httpx.ConnectError("Connection refused"),
436+
]
437+
438+
client = Client(config=ClientConfig(chunk_size=40, show_progress=False))
439+
440+
with pytest.raises(httpx.ConnectError):
441+
client.samples.upload_sample(
442+
name="My Sample",
443+
sample_type="rna_seq",
444+
data={"reads1": file_path},
445+
)
446+
428447
def test_rejects_invalid_reads_key(self, tmp_path: Path) -> None:
429448
file_path = tmp_path / "reads.fastq"
430449
file_path.write_bytes(b"ATCG")

tests/unit/v2/test_transport.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
from unittest.mock import patch
23

34
import httpx
45
import respx
@@ -16,6 +17,29 @@
1617
from tests.unit.v2.conftest import DEFAULT_BASE_URL
1718

1819

20+
class TestTransportConnectionRetries:
21+
22+
@patch("flowbio.v2._transport.httpx.HTTPTransport")
23+
def test_default_retries(self, mock_http_transport) -> None:
24+
HttpTransport(DEFAULT_BASE_URL)
25+
26+
mock_http_transport.assert_called_once_with(retries=3)
27+
28+
@patch("flowbio.v2._transport.httpx.HTTPTransport")
29+
def test_custom_retries(self, mock_http_transport) -> None:
30+
connection_retries = 5
31+
32+
HttpTransport(DEFAULT_BASE_URL, connection_retries=connection_retries)
33+
34+
mock_http_transport.assert_called_once_with(retries=connection_retries)
35+
36+
@patch("flowbio.v2._transport.httpx.HTTPTransport")
37+
def test_retries_disabled(self, mock_http_transport) -> None:
38+
HttpTransport(DEFAULT_BASE_URL, connection_retries=0)
39+
40+
mock_http_transport.assert_called_once_with(retries=0)
41+
42+
1943
class TestTransportGet:
2044

2145
@respx.mock

0 commit comments

Comments
 (0)