webuplink 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="360" height="160" viewBox="0 0 360 160">
2
+ <!-- WebUplink wordmark lockup — mark + text side by side. Dark mode. -->
3
+ <!-- PageMap 3×3 mark, vertically centered -->
4
+ <g transform="translate(20, 36)">
5
+ <rect x="0" y="0" width="24" height="24" rx="3.6" fill="#EDE7D9"/>
6
+ <rect x="32" y="0" width="24" height="24" rx="3.6" fill="#EDE7D9"/>
7
+ <rect x="64" y="0" width="24" height="24" rx="3.6" fill="#46999A"/>
8
+ <rect x="0" y="32" width="24" height="24" rx="3.6" fill="#EDE7D9"/>
9
+ <rect x="32" y="32" width="24" height="24" rx="3.6" fill="#EDE7D9"/>
10
+ <rect x="64" y="32" width="24" height="24" rx="3.6" fill="#EDE7D9"/>
11
+ <rect x="0" y="64" width="24" height="24" rx="3.6" fill="#EDE7D9"/>
12
+ <rect x="32" y="64" width="24" height="24" rx="3.6" fill="#EDE7D9"/>
13
+ <rect x="64" y="64" width="24" height="24" rx="3.6" fill="#EDE7D9"/>
14
+ </g>
15
+ <!-- Wordmark text, vertically centered alongside mark -->
16
+ <text x="124" y="89" font-family="Inter, system-ui, -apple-system, sans-serif" font-size="36" font-weight="600" letter-spacing="-0.02em" fill="#EDE7D9">WebUplink</text>
17
+ </svg>
@@ -0,0 +1,17 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="360" height="160" viewBox="0 0 360 160">
2
+ <!-- WebUplink wordmark lockup — mark + text side by side. Light mode. -->
3
+ <!-- PageMap 3×3 mark, vertically centered -->
4
+ <g transform="translate(20, 36)">
5
+ <rect x="0" y="0" width="24" height="24" rx="3.6" fill="#1C1B18"/>
6
+ <rect x="32" y="0" width="24" height="24" rx="3.6" fill="#1C1B18"/>
7
+ <rect x="64" y="0" width="24" height="24" rx="3.6" fill="#0E6E78"/>
8
+ <rect x="0" y="32" width="24" height="24" rx="3.6" fill="#1C1B18"/>
9
+ <rect x="32" y="32" width="24" height="24" rx="3.6" fill="#1C1B18"/>
10
+ <rect x="64" y="32" width="24" height="24" rx="3.6" fill="#1C1B18"/>
11
+ <rect x="0" y="64" width="24" height="24" rx="3.6" fill="#1C1B18"/>
12
+ <rect x="32" y="64" width="24" height="24" rx="3.6" fill="#1C1B18"/>
13
+ <rect x="64" y="64" width="24" height="24" rx="3.6" fill="#1C1B18"/>
14
+ </g>
15
+ <!-- Wordmark text, vertically centered alongside mark -->
16
+ <text x="124" y="89" font-family="Inter, system-ui, -apple-system, sans-serif" font-size="36" font-weight="600" letter-spacing="-0.02em" fill="#1C1B18">WebUplink</text>
17
+ </svg>
@@ -0,0 +1,21 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ name: Build & Test
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ['3.10', '3.12', '3.13']
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: ${{ matrix.python-version }}
20
+ - run: pip install -e '.[dev]'
21
+ - run: pytest
@@ -0,0 +1,32 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ name: Publish
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: read
13
+ id-token: write
14
+ environment: pypi
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: '3.12'
20
+ - run: pip install build
21
+ - name: Verify version matches release tag
22
+ run: |
23
+ PKG_VERSION=$(python -c "exec(open('src/webuplink/_version.py').read()); print(__version__)")
24
+ TAG_VERSION="${GITHUB_REF_NAME#v}"
25
+ if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then
26
+ echo "::error::_version.py ($PKG_VERSION) does not match release tag ($TAG_VERSION)"
27
+ exit 1
28
+ fi
29
+ - run: pip install -e '.[dev]'
30
+ - run: pytest
31
+ - run: python -m build
32
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ .ruff_cache/
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .DS_Store
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-06-30)
4
+
5
+ Initial public release.
6
+
7
+ ### Features
8
+
9
+ - `browse()` — browse pages and execute tools with string shorthand, `BrowseParams`, or keyword arguments
10
+ - `close_session()` — explicit session cleanup
11
+ - `health()` — API health check with optional deep component checks
12
+ - `get_usage()` — usage and billing information for the authenticated tenant
13
+ - Sync (`WebUplink`) and async (`AsyncWebUplink`) clients with context manager support
14
+ - Idempotency-aware retry with configurable `max_retries`
15
+ - Typed error hierarchy: `WebUplinkError`, `AuthenticationError`, `RateLimitError`, `APIConnectionError`
16
+ - `Usage` metadata from `X-Usage-*` response headers
17
+ - Pydantic v2 models for all request/response types
18
+ - `py.typed` marker for PEP 561 type checker support
19
+ - Custom `httpx.Client` / `httpx.AsyncClient` injection for proxies, mTLS, and logging
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WebUplink
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: webuplink
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for WebUplink — the whole web, as function calls.
5
+ Project-URL: Homepage, https://webuplink.ai
6
+ Project-URL: Documentation, https://webuplink.ai/docs
7
+ Project-URL: Repository, https://github.com/webuplink-dev/webuplink-python
8
+ Author-email: WebUplink <dev@webuplink.ai>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai-agent,browser,sdk,web-automation,webuplink
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: httpx<1.0,>=0.27
21
+ Requires-Dist: pydantic<3.0,>=2.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pyright>=1.1; extra == 'dev'
24
+ Requires-Dist: pytest-asyncio>=1.0; extra == 'dev'
25
+ Requires-Dist: pytest>=8.0; extra == 'dev'
26
+ Requires-Dist: respx>=0.22; extra == 'dev'
27
+ Requires-Dist: ruff>=0.11; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ <picture>
31
+ <source media="(prefers-color-scheme: dark)" srcset=".github/logo-dark.svg">
32
+ <source media="(prefers-color-scheme: light)" srcset=".github/logo-light.svg">
33
+ <img alt="WebUplink" src=".github/logo-light.svg" width="200">
34
+ </picture>
35
+
36
+ ### The whole web, as function calls.
37
+
38
+ [![PyPI version](https://img.shields.io/pypi/v/webuplink.svg)](https://pypi.org/project/webuplink/)
39
+ [![CI](https://github.com/webuplink-dev/webuplink-python/actions/workflows/ci.yml/badge.svg)](https://github.com/webuplink-dev/webuplink-python/actions)
40
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
41
+
42
+ Official Python SDK for [WebUplink](https://webuplink.ai).
43
+
44
+ ## Installation
45
+
46
+ ```bash
47
+ pip install webuplink
48
+ ```
49
+
50
+ ## Quickstart
51
+
52
+ ```python
53
+ from webuplink import WebUplink
54
+
55
+ client = WebUplink() # reads WEBUPLINK_API_KEY from env
56
+
57
+ # Browse a page — get back structured, callable tools
58
+ page = client.browse("https://example.com")
59
+ print(page.summary)
60
+ print(page.tools) # [Tool(name='...', description='...', params=[...]), ...]
61
+
62
+ # Execute a tool discovered on the page
63
+ tool = page.tools[0]
64
+ result = client.browse(
65
+ session_id=page.session_id,
66
+ tool=tool.name,
67
+ params={tool.params[0].name: "some value"},
68
+ )
69
+
70
+ # Clean up
71
+ client.close_session(page.session_id)
72
+ ```
73
+
74
+ ## Async
75
+
76
+ ```python
77
+ from webuplink import AsyncWebUplink
78
+
79
+ async with AsyncWebUplink() as client:
80
+ page = await client.browse("https://example.com")
81
+ print(page.tools)
82
+ ```
83
+
84
+ ## What you can build
85
+
86
+ - **AI agents that act on the web** — browse any site, get back typed tool definitions, execute actions
87
+ - **No selectors, no scraping** — WebUplink understands pages and generates callable tools automatically
88
+ - **Multi-step workflows** — sessions persist across navigations, so your agent can search → filter → select → checkout
89
+ - **Any website, zero configuration** — works on sites you've never seen before
90
+
91
+ ## Documentation
92
+
93
+ Full reference at **[webuplink.ai/docs](https://webuplink.ai/docs)**.
94
+
95
+ ## Contributing
96
+
97
+ ```bash
98
+ git clone https://github.com/webuplink-dev/webuplink-python.git
99
+ cd webuplink-python
100
+ pip install -e ".[dev]"
101
+ pytest
102
+ ```
103
+
104
+ ## License
105
+
106
+ MIT
@@ -0,0 +1,77 @@
1
+ <picture>
2
+ <source media="(prefers-color-scheme: dark)" srcset=".github/logo-dark.svg">
3
+ <source media="(prefers-color-scheme: light)" srcset=".github/logo-light.svg">
4
+ <img alt="WebUplink" src=".github/logo-light.svg" width="200">
5
+ </picture>
6
+
7
+ ### The whole web, as function calls.
8
+
9
+ [![PyPI version](https://img.shields.io/pypi/v/webuplink.svg)](https://pypi.org/project/webuplink/)
10
+ [![CI](https://github.com/webuplink-dev/webuplink-python/actions/workflows/ci.yml/badge.svg)](https://github.com/webuplink-dev/webuplink-python/actions)
11
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
12
+
13
+ Official Python SDK for [WebUplink](https://webuplink.ai).
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ pip install webuplink
19
+ ```
20
+
21
+ ## Quickstart
22
+
23
+ ```python
24
+ from webuplink import WebUplink
25
+
26
+ client = WebUplink() # reads WEBUPLINK_API_KEY from env
27
+
28
+ # Browse a page — get back structured, callable tools
29
+ page = client.browse("https://example.com")
30
+ print(page.summary)
31
+ print(page.tools) # [Tool(name='...', description='...', params=[...]), ...]
32
+
33
+ # Execute a tool discovered on the page
34
+ tool = page.tools[0]
35
+ result = client.browse(
36
+ session_id=page.session_id,
37
+ tool=tool.name,
38
+ params={tool.params[0].name: "some value"},
39
+ )
40
+
41
+ # Clean up
42
+ client.close_session(page.session_id)
43
+ ```
44
+
45
+ ## Async
46
+
47
+ ```python
48
+ from webuplink import AsyncWebUplink
49
+
50
+ async with AsyncWebUplink() as client:
51
+ page = await client.browse("https://example.com")
52
+ print(page.tools)
53
+ ```
54
+
55
+ ## What you can build
56
+
57
+ - **AI agents that act on the web** — browse any site, get back typed tool definitions, execute actions
58
+ - **No selectors, no scraping** — WebUplink understands pages and generates callable tools automatically
59
+ - **Multi-step workflows** — sessions persist across navigations, so your agent can search → filter → select → checkout
60
+ - **Any website, zero configuration** — works on sites you've never seen before
61
+
62
+ ## Documentation
63
+
64
+ Full reference at **[webuplink.ai/docs](https://webuplink.ai/docs)**.
65
+
66
+ ## Contributing
67
+
68
+ ```bash
69
+ git clone https://github.com/webuplink-dev/webuplink-python.git
70
+ cd webuplink-python
71
+ pip install -e ".[dev]"
72
+ pytest
73
+ ```
74
+
75
+ ## License
76
+
77
+ MIT
@@ -0,0 +1,62 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "webuplink"
7
+ dynamic = ["version"]
8
+ description = "Official Python SDK for WebUplink — the whole web, as function calls."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "httpx>=0.27,<1.0",
14
+ "pydantic>=2.0,<3.0",
15
+ ]
16
+ keywords = ["webuplink", "ai-agent", "browser", "web-automation", "sdk"]
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Intended Audience :: Developers",
20
+ "Typing :: Typed",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ ]
26
+ authors = [
27
+ { name = "WebUplink", email = "dev@webuplink.ai" },
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ dev = [
32
+ "pytest>=8.0",
33
+ "pytest-asyncio>=1.0",
34
+ "respx>=0.22",
35
+ "ruff>=0.11",
36
+ "pyright>=1.1",
37
+ ]
38
+
39
+ [project.urls]
40
+ Homepage = "https://webuplink.ai"
41
+ Documentation = "https://webuplink.ai/docs"
42
+ Repository = "https://github.com/webuplink-dev/webuplink-python"
43
+
44
+ [tool.hatch.version]
45
+ path = "src/webuplink/_version.py"
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/webuplink"]
49
+
50
+ [tool.pyright]
51
+ typeCheckingMode = "strict"
52
+ pythonVersion = "3.10"
53
+
54
+ [tool.ruff]
55
+ target-version = "py310"
56
+ line-length = 120
57
+
58
+ [tool.ruff.lint]
59
+ select = ["E", "F", "I", "UP", "B", "SIM"]
60
+
61
+ [tool.pytest.ini_options]
62
+ asyncio_mode = "auto"
@@ -0,0 +1,64 @@
1
+ """webuplink — Official Python SDK for WebUplink.
2
+
3
+ Browse and interact with any website via structured, typed tool definitions.
4
+
5
+ Usage::
6
+
7
+ from webuplink import WebUplink
8
+
9
+ client = WebUplink(api_key="wup_your_api_key")
10
+ page = client.browse("https://example.com")
11
+ print(page.tools)
12
+
13
+ For async usage::
14
+
15
+ from webuplink import AsyncWebUplink
16
+
17
+ async with AsyncWebUplink() as client:
18
+ page = await client.browse("https://example.com")
19
+ """
20
+
21
+ from webuplink._client import AsyncWebUplink, WebUplink
22
+ from webuplink._errors import (
23
+ APIConnectionError,
24
+ AuthenticationError,
25
+ RateLimitError,
26
+ WebUplinkError,
27
+ )
28
+ from webuplink._types import (
29
+ BrowseParams,
30
+ BrowseResult,
31
+ ErrorCode,
32
+ HealthResult,
33
+ Tool,
34
+ ToolExecution,
35
+ ToolParam,
36
+ ToolResult,
37
+ Usage,
38
+ UsageResponse,
39
+ )
40
+ from webuplink._version import __version__
41
+
42
+ __all__ = [
43
+ # Clients
44
+ "WebUplink",
45
+ "AsyncWebUplink",
46
+ # Errors
47
+ "WebUplinkError",
48
+ "AuthenticationError",
49
+ "RateLimitError",
50
+ "APIConnectionError",
51
+ # Types
52
+ "BrowseParams",
53
+ "BrowseResult",
54
+ "Tool",
55
+ "ToolParam",
56
+ "ToolResult",
57
+ "ToolExecution",
58
+ "Usage",
59
+ "UsageResponse",
60
+ "HealthResult",
61
+ "ErrorCode",
62
+ # Version
63
+ "__version__",
64
+ ]
@@ -0,0 +1,178 @@
1
+ """
2
+ Shared HTTP logic for both sync and async clients.
3
+
4
+ This module contains all request construction, response parsing, error
5
+ mapping, usage header extraction, and retry logic. The ``WebUplink``
6
+ and ``AsyncWebUplink`` classes delegate to these functions so the
7
+ logic exists in exactly one place.
8
+
9
+ Retry semantics match the TypeScript SDK exactly:
10
+ - API errors with ``retry_after``: sleep for ``retry_after`` seconds
11
+ - Connection errors: linear backoff (1s × attempt)
12
+ - Never retry 429 (amplifies load)
13
+ - Never retry tool execution (non-idempotent)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import time
19
+ from typing import Any
20
+
21
+ import httpx
22
+
23
+ from webuplink._constants import USER_AGENT
24
+ from webuplink._errors import (
25
+ AuthenticationError,
26
+ RateLimitError,
27
+ WebUplinkError,
28
+ )
29
+ from webuplink._types import BrowseResult, HealthResult, Usage, UsageResponse
30
+
31
+ DEFAULT_RETRY_DELAY_S = 5.0
32
+ """Seconds to wait before retrying when the server omits ``retry_after``."""
33
+
34
+ # ── Header Construction ──────────────────────────────────────────
35
+
36
+
37
+ def make_headers(api_key: str) -> dict[str, str]:
38
+ """Build the default request headers."""
39
+ return {
40
+ "Authorization": f"Bearer {api_key}",
41
+ "User-Agent": USER_AGENT,
42
+ }
43
+
44
+
45
+ # ── Response Parsing ─────────────────────────────────────────────
46
+
47
+
48
+ def parse_browse_response(response: httpx.Response) -> BrowseResult:
49
+ """Deserialize a successful browse response + usage headers."""
50
+ data = response.json()
51
+ usage = _parse_usage_headers(response)
52
+ return BrowseResult(**data, usage=usage)
53
+
54
+
55
+ def parse_health_response(response: httpx.Response) -> HealthResult:
56
+ """Deserialize a successful health response."""
57
+ return HealthResult(**response.json())
58
+
59
+
60
+ def parse_usage_response(response: httpx.Response) -> UsageResponse:
61
+ """Deserialize a successful usage response."""
62
+ return UsageResponse(**response.json())
63
+
64
+
65
+ def _parse_usage_headers(response: httpx.Response) -> Usage | None:
66
+ """Extract ``X-Usage-*`` headers into a ``Usage`` model, or ``None``."""
67
+ count = response.headers.get("x-usage-action-count")
68
+ limit = response.headers.get("x-usage-action-limit")
69
+ period = response.headers.get("x-usage-period-start")
70
+
71
+ if not count or not limit or not period:
72
+ return None
73
+
74
+ return Usage(
75
+ action_count=int(count),
76
+ action_limit=int(limit),
77
+ period_start=period,
78
+ )
79
+
80
+
81
+ # ── Error Parsing ────────────────────────────────────────────────
82
+
83
+
84
+ def parse_error_response(response: httpx.Response) -> WebUplinkError:
85
+ """Map a non-2xx ``httpx.Response`` to a ``WebUplinkError`` (or subclass)."""
86
+ request_id = response.headers.get("x-request-id", "unknown")
87
+
88
+ try:
89
+ data: dict[str, Any] = response.json()
90
+ except Exception:
91
+ # Non-JSON body (e.g. 502 from load balancer)
92
+ return _create_error_from_status(
93
+ response.status_code,
94
+ response.reason_phrase or f"HTTP {response.status_code}",
95
+ code="INTERNAL_ERROR",
96
+ status_code=response.status_code,
97
+ request_id=request_id,
98
+ )
99
+
100
+ code = data.get("error", "INTERNAL_ERROR")
101
+ message = data.get("message", response.reason_phrase or f"HTTP {response.status_code}")
102
+ retry_after_raw = data.get("retry_after")
103
+ details = data.get("details")
104
+
105
+ # Retryability: the server sets retry_after for transient issues.
106
+ # 429 (RATE_LIMITED / QUOTA_EXCEEDED) carries retry_after but
107
+ # auto-retrying a throttle just amplifies load — never retry them.
108
+ retryable = (
109
+ response.status_code != 429
110
+ and retry_after_raw is not None
111
+ and float(retry_after_raw) > 0
112
+ )
113
+
114
+ return _create_error_from_status(
115
+ response.status_code,
116
+ message,
117
+ code=code,
118
+ status_code=response.status_code,
119
+ request_id=request_id,
120
+ retryable=retryable,
121
+ retry_after=float(retry_after_raw) if retry_after_raw is not None else None,
122
+ details=details,
123
+ )
124
+
125
+
126
+ def _create_error_from_status(
127
+ status: int,
128
+ message: str,
129
+ **kwargs: Any,
130
+ ) -> WebUplinkError:
131
+ """Map HTTP status to the most specific error subclass."""
132
+ if status == 401:
133
+ return AuthenticationError(message, **kwargs)
134
+ if status == 429:
135
+ return RateLimitError(message, **kwargs)
136
+ return WebUplinkError(message, **kwargs)
137
+
138
+
139
+ # ── Retry Logic ──────────────────────────────────────────────────
140
+
141
+
142
+ def should_retry(
143
+ error: WebUplinkError,
144
+ *,
145
+ attempt: int,
146
+ max_retries: int,
147
+ has_tools: bool,
148
+ ) -> bool:
149
+ """Decide whether a failed request should be retried.
150
+
151
+ Rules (matching the TypeScript SDK exactly):
152
+ 1. Must have retries remaining (``attempt < 1 + max_retries``)
153
+ 2. The error must be marked retryable by ``parse_error_response``
154
+ 3. Tool execution is never retried (non-idempotent)
155
+ """
156
+ if attempt >= 1 + max_retries:
157
+ return False
158
+ if has_tools:
159
+ return False
160
+ return error.retryable
161
+
162
+
163
+ def get_retry_delay(error: WebUplinkError) -> float:
164
+ """Return the delay (in seconds) before retrying an API error."""
165
+ return error.retry_after if error.retry_after is not None else DEFAULT_RETRY_DELAY_S
166
+
167
+
168
+ def get_connection_retry_delay(attempt: int) -> float:
169
+ """Return the delay (in seconds) before retrying a connection error.
170
+
171
+ Uses linear backoff: 1s × attempt (matching the TS SDK).
172
+ """
173
+ return 1.0 * attempt
174
+
175
+
176
+ def sleep_sync(seconds: float) -> None:
177
+ """Blocking sleep for sync retry loops."""
178
+ time.sleep(seconds)