tinyfish 0.2.2__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.
Files changed (34) hide show
  1. tinyfish-0.2.2/.gitignore +1 -0
  2. tinyfish-0.2.2/PKG-INFO +8 -0
  3. tinyfish-0.2.2/README.md +334 -0
  4. tinyfish-0.2.2/RELEASE.md +71 -0
  5. tinyfish-0.2.2/docs/exceptions-and-errors-guide.md +110 -0
  6. tinyfish-0.2.2/docs/internal/exceptions-and-errors-guide.md +110 -0
  7. tinyfish-0.2.2/docs/internal/publishing-private-guide.md +59 -0
  8. tinyfish-0.2.2/docs/pagination-guide.md +143 -0
  9. tinyfish-0.2.2/docs/proxy-and-browser-profiles.md +123 -0
  10. tinyfish-0.2.2/docs/streaming-guide.md +181 -0
  11. tinyfish-0.2.2/pyproject.toml +41 -0
  12. tinyfish-0.2.2/src/tinyfish/__init__.py +104 -0
  13. tinyfish-0.2.2/src/tinyfish/_utils/__init__.py +26 -0
  14. tinyfish-0.2.2/src/tinyfish/_utils/client/__init__.py +4 -0
  15. tinyfish-0.2.2/src/tinyfish/_utils/client/_base.py +137 -0
  16. tinyfish-0.2.2/src/tinyfish/_utils/client/async_.py +192 -0
  17. tinyfish-0.2.2/src/tinyfish/_utils/client/sync.py +191 -0
  18. tinyfish-0.2.2/src/tinyfish/_utils/exceptions.py +159 -0
  19. tinyfish-0.2.2/src/tinyfish/_utils/resource.py +23 -0
  20. tinyfish-0.2.2/src/tinyfish/_utils/sse_parser.py +62 -0
  21. tinyfish-0.2.2/src/tinyfish/agent/__init__.py +368 -0
  22. tinyfish-0.2.2/src/tinyfish/agent/types.py +182 -0
  23. tinyfish-0.2.2/src/tinyfish/client.py +76 -0
  24. tinyfish-0.2.2/src/tinyfish/py.typed +11 -0
  25. tinyfish-0.2.2/src/tinyfish/runs/__init__.py +147 -0
  26. tinyfish-0.2.2/src/tinyfish/runs/types.py +107 -0
  27. tinyfish-0.2.2/tests/__init__.py +0 -0
  28. tinyfish-0.2.2/tests/conftest.py +26 -0
  29. tinyfish-0.2.2/tests/test_agent.py +1221 -0
  30. tinyfish-0.2.2/tests/test_client.py +186 -0
  31. tinyfish-0.2.2/tests/test_errors.py +298 -0
  32. tinyfish-0.2.2/tests/test_runs.py +607 -0
  33. tinyfish-0.2.2/tests/testing_guide.md +201 -0
  34. tinyfish-0.2.2/uv.lock +377 -0
@@ -0,0 +1 @@
1
+ playground/
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: tinyfish
3
+ Version: 0.2.2
4
+ Summary: Official Python SDK for the TinyFish API
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: httpx>=0.27.0
7
+ Requires-Dist: pydantic>=2.0.0
8
+ Requires-Dist: tenacity>=8.0.0
@@ -0,0 +1,334 @@
1
+ # TinyFish Python SDK
2
+
3
+ The official Python SDK for [TinyFish](https://agent.tinyfish.ai)
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install tinyfish
9
+ ```
10
+
11
+ Requires Python 3.11+.
12
+
13
+ ## Get your API key
14
+
15
+ Sign up and grab your key at [agent.tinyfish.ai/api-keys](https://agent.tinyfish.ai/api-keys).
16
+
17
+ ## Quickstart
18
+
19
+ ```python
20
+ from tinyfish import TinyFish
21
+
22
+ client = TinyFish(api_key="your-api-key")
23
+
24
+ response = client.agent.run(
25
+ goal="What is the current Bitcoin price?",
26
+ url="https://www.coinbase.com/price/bitcoin",
27
+ )
28
+ print(response.result)
29
+ ```
30
+
31
+ Or set the `TINYFISH_API_KEY` environment variable and omit `api_key`:
32
+
33
+ ```python
34
+ client = TinyFish()
35
+ ```
36
+
37
+ ## Methods
38
+
39
+ Every method below is available on both `TinyFish` (sync) and `AsyncTinyFish` (async). Async versions have the same signatures — just `await` them.
40
+
41
+ | Method | Description | Returns | Blocks? |
42
+ |--------|-------------|---------|---------|
43
+ | [`agent.run()`](#agentrun--block-until-done) | Run an automation, wait for the result | `AgentRunResponse` | Yes |
44
+ | [`agent.queue()`](#agentqueue--fire-and-forget) | Start an automation, return immediately | `AgentRunAsyncResponse` | No |
45
+ | [`agent.stream()`](#agentstream--real-time-events) | Stream live SSE events as the agent works | `AgentStream` | No |
46
+ | [`runs.get()`](#runsget--retrieve-a-single-run) | Retrieve a single run by ID | `Run` | — |
47
+ | [`runs.list()`](#runslist--list-and-filter-runs) | List runs with filtering, sorting, pagination | `RunListResponse` | — |
48
+
49
+ ---
50
+
51
+ ### `agent.run()` — block until done
52
+
53
+ Sends the automation and waits for it to finish. Returns the full result in one shot.
54
+
55
+ ```python
56
+ from tinyfish import TinyFish, RunStatus, BrowserProfile, ProxyConfig, ProxyCountryCode
57
+
58
+ client = TinyFish()
59
+
60
+ response = client.agent.run(
61
+ goal="Extract the top 5 headlines", # required — what to do on the page
62
+ url="https://news.ycombinator.com", # required — URL to open
63
+ browser_profile=BrowserProfile.STEALTH, # optional — "lite" (default) or "stealth"
64
+ proxy_config=ProxyConfig( # optional — proxy settings
65
+ enabled=True,
66
+ country_code=ProxyCountryCode.US, # optional — US, GB, CA, DE, FR, JP, AU
67
+ ),
68
+ )
69
+
70
+ if response.status == RunStatus.COMPLETED:
71
+ print(response.result)
72
+ else:
73
+ print(f"Failed: {response.error.message}")
74
+ ```
75
+
76
+ **Returns `AgentRunResponse`:**
77
+
78
+ | Field | Type | Description |
79
+ |-------|------|-------------|
80
+ | `status` | `RunStatus` | `COMPLETED`, `FAILED`, etc. |
81
+ | `run_id` | `str \| None` | Unique run identifier |
82
+ | `result` | `dict \| None` | Extracted data (`None` if failed) |
83
+ | `error` | `RunError \| None` | Error details (`None` if succeeded) |
84
+ | `num_of_steps` | `int` | Number of steps the agent took |
85
+ | `started_at` | `datetime \| None` | When the run started |
86
+ | `finished_at` | `datetime \| None` | When the run finished |
87
+
88
+ ---
89
+
90
+ ### `agent.queue()` — fire and forget
91
+
92
+ Starts the automation in the background and returns a `run_id` immediately. Poll with `runs.get()` when you're ready for the result.
93
+
94
+ ```python
95
+ import time
96
+ from tinyfish import TinyFish, RunStatus
97
+
98
+ client = TinyFish()
99
+
100
+ queued = client.agent.queue(
101
+ goal="Extract the top 5 headlines", # required — what to do on the page
102
+ url="https://news.ycombinator.com", # required — URL to open
103
+ browser_profile=None, # optional — "lite" (default) or "stealth"
104
+ proxy_config=None, # optional — proxy settings
105
+ )
106
+ print(f"Run started: {queued.run_id}")
107
+
108
+ # Poll for completion
109
+ while True:
110
+ run = client.runs.get(queued.run_id)
111
+ if run.status in (RunStatus.COMPLETED, RunStatus.FAILED):
112
+ break
113
+ time.sleep(5)
114
+
115
+ print(run.result)
116
+ ```
117
+
118
+ **Returns `AgentRunAsyncResponse`:**
119
+
120
+ | Field | Type | Description |
121
+ |-------|------|-------------|
122
+ | `run_id` | `str \| None` | Run ID to poll with `runs.get()` |
123
+ | `error` | `RunError \| None` | Error if queuing itself failed |
124
+
125
+ ---
126
+
127
+ ### `agent.stream()` — real-time events
128
+
129
+ Opens a Server-Sent Events stream. You get live progress updates as the agent works, plus a WebSocket URL for a live browser preview.
130
+
131
+ ```python
132
+ from tinyfish import TinyFish, CompleteEvent, ProgressEvent
133
+
134
+ client = TinyFish()
135
+
136
+ with client.agent.stream(
137
+ goal="Extract the top 5 headlines", # required — what to do on the page
138
+ url="https://news.ycombinator.com", # required — URL to open
139
+ browser_profile=None, # optional — "lite" (default) or "stealth"
140
+ proxy_config=None, # optional — proxy settings
141
+ on_started=lambda e: print(f"Started: {e.run_id}"), # optional — called when run starts
142
+ on_streaming_url=lambda e: print(f"Watch: {e.streaming_url}"), # optional — called with live browser URL
143
+ on_progress=lambda e: print(f" > {e.purpose}"), # optional — called on each step
144
+ on_heartbeat=lambda e: None, # optional — called on keepalive pings
145
+ on_complete=lambda e: print(f"Done: {e.status}"), # optional — called when run finishes
146
+ ) as stream:
147
+ for event in stream:
148
+ # Callbacks fire automatically during iteration.
149
+ # You can also inspect events directly:
150
+ if isinstance(event, CompleteEvent):
151
+ print(event.result_json)
152
+ ```
153
+
154
+ **Returns `AgentStream`** — a context manager you iterate over. Events arrive in order: `STARTED` → `STREAMING_URL` → `PROGRESS` (repeated) → `COMPLETE`.
155
+
156
+ See the [Streaming Guide](docs/streaming-guide.md) for the full event lifecycle, event types, and advanced patterns.
157
+
158
+ ---
159
+
160
+ ### `runs.get()` — retrieve a single run
161
+
162
+ Fetch the full details of a run by its ID.
163
+
164
+ ```python
165
+ run = client.runs.get(
166
+ "run_abc123", # required — the run ID
167
+ )
168
+
169
+ print(run.status) # PENDING, RUNNING, COMPLETED, FAILED, CANCELLED
170
+ print(run.result)
171
+ print(run.goal)
172
+ print(run.streaming_url) # live browser URL (while RUNNING)
173
+ print(run.browser_config) # proxy/browser settings that were used
174
+ ```
175
+
176
+ **Returns `Run`:**
177
+
178
+ | Field | Type | Description |
179
+ |-------|------|-------------|
180
+ | `run_id` | `str` | Unique identifier |
181
+ | `status` | `RunStatus` | `PENDING`, `RUNNING`, `COMPLETED`, `FAILED`, `CANCELLED` |
182
+ | `goal` | `str` | The goal that was given |
183
+ | `result` | `dict \| None` | Extracted data (`None` if not completed) |
184
+ | `error` | `RunError \| None` | Error details (`None` if succeeded) |
185
+ | `streaming_url` | `str \| None` | Live browser URL (available while running) |
186
+ | `browser_config` | `BrowserConfig \| None` | Proxy/browser settings used |
187
+ | `created_at` | `datetime` | When the run was created |
188
+ | `started_at` | `datetime \| None` | When execution started |
189
+ | `finished_at` | `datetime \| None` | When execution finished |
190
+
191
+ **Raises:** `ValueError` if `run_id` is empty. `NotFoundError` if no run exists with that ID.
192
+
193
+ ---
194
+
195
+ ### `runs.list()` — list and filter runs
196
+
197
+ List runs with optional filtering, sorting, and cursor-based pagination. All parameters are optional.
198
+
199
+ ```python
200
+ from tinyfish import RunStatus, SortDirection
201
+
202
+ response = client.runs.list(
203
+ status=RunStatus.COMPLETED, # optional — filter by status
204
+ goal="headlines", # optional — filter by goal text
205
+ created_after="2025-01-01T00:00:00Z", # optional — ISO 8601 lower bound
206
+ created_before="2025-12-31T23:59:59Z", # optional — ISO 8601 upper bound
207
+ sort_direction=SortDirection.DESC, # optional — "asc" or "desc"
208
+ limit=10, # optional — max runs per page
209
+ cursor=None, # optional — pagination cursor from previous response
210
+ )
211
+
212
+ for run in response.data:
213
+ print(f"{run.run_id} | {run.goal}")
214
+
215
+ # Pagination
216
+ if response.pagination.has_more:
217
+ next_page = client.runs.list(cursor=response.pagination.next_cursor)
218
+ ```
219
+
220
+ **Returns `RunListResponse`:**
221
+
222
+ | Field | Type | Description |
223
+ |-------|------|-------------|
224
+ | `data` | `list[Run]` | List of runs |
225
+ | `pagination.total` | `int` | Total runs matching filters |
226
+ | `pagination.has_more` | `bool` | Whether more pages exist |
227
+ | `pagination.next_cursor` | `str \| None` | Pass to `cursor=` for the next page |
228
+
229
+ See the [Pagination Guide](docs/pagination-guide.md) for full pagination loop examples.
230
+
231
+ ---
232
+
233
+ ## Sync vs Async
234
+
235
+ Use `AsyncTinyFish` when you're in an async context (FastAPI, aiohttp, etc.):
236
+
237
+ **Sync:**
238
+
239
+ ```python
240
+ from tinyfish import TinyFish
241
+
242
+ client = TinyFish()
243
+ response = client.agent.run(goal="...", url="...")
244
+ ```
245
+
246
+ **Async:**
247
+
248
+ ```python
249
+ from tinyfish import AsyncTinyFish
250
+
251
+ client = AsyncTinyFish()
252
+ response = await client.agent.run(goal="...", url="...")
253
+ ```
254
+
255
+ All five methods (`agent.run()`, `agent.queue()`, `agent.stream()`, `runs.get()`, `runs.list()`) work the same way — same parameters, just `await`-ed.
256
+
257
+ ## Configuration
258
+
259
+ ### Client options
260
+
261
+ ```python
262
+ client = TinyFish(
263
+ api_key="your-api-key", # optional — or set TINYFISH_API_KEY env var
264
+ base_url="https://agent.tinyfish.ai", # optional — default shown
265
+ timeout=600.0, # optional — seconds (default: 600)
266
+ max_retries=2, # optional — retry attempts (default: 2)
267
+ )
268
+ ```
269
+
270
+ The SDK retries `408`, `429`, and `5xx` errors automatically with exponential backoff (0.5s multiplier, max 8s wait).
271
+
272
+ ### Browser profiles
273
+
274
+ Control the browser environment with `browser_profile`:
275
+
276
+ - **`lite`** (default) — fast, lightweight. Good for most sites.
277
+ - **`stealth`** — anti-detection mode. Use for sites with bot protection.
278
+
279
+ ```python
280
+ from tinyfish import BrowserProfile
281
+
282
+ response = client.agent.run(
283
+ goal="...",
284
+ url="...",
285
+ browser_profile=BrowserProfile.STEALTH,
286
+ )
287
+ ```
288
+
289
+ ### Proxy configuration
290
+
291
+ Route requests through a proxy, optionally pinned to a country:
292
+
293
+ ```python
294
+ from tinyfish import ProxyConfig, ProxyCountryCode
295
+
296
+ response = client.agent.run(
297
+ goal="...",
298
+ url="...",
299
+ proxy_config=ProxyConfig(enabled=True, country_code=ProxyCountryCode.US),
300
+ )
301
+ ```
302
+
303
+ Available countries: `US`, `GB`, `CA`, `DE`, `FR`, `JP`, `AU`.
304
+
305
+ See the [Proxy & Browser Profiles Guide](docs/proxy-and-browser-profiles.md) for more details.
306
+
307
+ ## Error handling
308
+
309
+ ```python
310
+ from tinyfish import TinyFish, AuthenticationError, RateLimitError, SDKError
311
+
312
+ client = TinyFish()
313
+
314
+ try:
315
+ response = client.agent.run(goal="...", url="...")
316
+ except AuthenticationError:
317
+ print("Invalid API key")
318
+ except RateLimitError:
319
+ print("Rate limited (retries exhausted)")
320
+ except SDKError:
321
+ print("Something else went wrong")
322
+ ```
323
+
324
+ The SDK automatically retries transient errors (`408`, `429`, `5xx`) up to `max_retries` times with exponential backoff. Non-retryable errors (`401`, `400`, `404`) raise immediately.
325
+
326
+ For the full exception hierarchy and internal architecture, see [docs/internal/exceptions-and-errors-guide.md](docs/internal/exceptions-and-errors-guide.md).
327
+
328
+ ## Guides
329
+
330
+ - [Streaming Guide](docs/streaming-guide.md) — event lifecycle, callbacks vs iteration, event type reference
331
+ - [Proxy & Browser Profiles](docs/proxy-and-browser-profiles.md) — stealth mode, proxy countries
332
+ - [Pagination Guide](docs/pagination-guide.md) — filtering, sorting, cursor-based pagination
333
+ - [Exceptions & Error Handling (internal)](docs/internal/exceptions-and-errors-guide.md) — layer-by-layer architecture
334
+ - [Testing Guide](tests/testing_guide.md) — running and writing tests
@@ -0,0 +1,71 @@
1
+ # Releasing the Python SDK to PyPI
2
+
3
+ This document covers how to publish a new version of the TinyFish Python SDK to the public PyPI registry so users can `pip install tinyfish`.
4
+
5
+ ## Prerequisites (one-time infra setup)
6
+
7
+ The `PYPI_API_TOKEN` secret exists in AWS Secrets Manager (`pypi-token`) and is already used by other repos. It needs to be added to the `ux-labs` repo via a PR to `github-control/repos.tf`:
8
+
9
+ ```hcl
10
+ "ux-labs" : {
11
+ secrets = {
12
+ # ... existing secrets ...
13
+ PYPI_API_TOKEN = data.aws_secretsmanager_secret_version.pypi-token.secret_string
14
+ }
15
+ }
16
+ ```
17
+
18
+ This is a one-time change. Once merged and applied, the workflow will have access to the token.
19
+
20
+ ## Release process
21
+
22
+ ### Step 1: Bump the version
23
+
24
+ Edit `sdk/sdk-python/pyproject.toml` and update the `version` field:
25
+
26
+ ```toml
27
+ [project]
28
+ version = "0.2.0" # bump this
29
+ ```
30
+
31
+ Commit and merge to main.
32
+
33
+ ### Step 2: Create a GitHub Release
34
+
35
+ 1. Go to the `ux-labs` repo on GitHub → **Releases** → **Draft a new release**
36
+ 2. Set the tag to match the version exactly, prefixed with `v`:
37
+ - Version `0.2.0` → tag `v0.2.0`
38
+ 3. Set the target to `main`
39
+ 4. Write release notes summarizing what changed
40
+ 5. Click **Publish release**
41
+
42
+ The workflow validates that the tag matches the version in `pyproject.toml` — if they don't match, the build fails before anything is published.
43
+
44
+ ### Step 3: Monitor the workflow
45
+
46
+ The `SDK Python CD - Publish to PyPI` workflow triggers automatically on release publish. It runs two jobs in sequence:
47
+
48
+ 1. **Build** — validates the tag matches `pyproject.toml` version, builds the package with `uv build`
49
+ 2. **Publish to PyPI** — publishes to `pypi.org` using the `PYPI_API_TOKEN` secret
50
+
51
+ Monitor progress at: `github.com/tinyfish-io/ux-labs/actions`
52
+
53
+ ### Step 4: Verify
54
+
55
+ After the workflow completes, verify the release:
56
+
57
+ ```bash
58
+ pip install tinyfish==0.2.0
59
+ python -c "from tinyfish import Tinyfish; print('ok')"
60
+ ```
61
+
62
+ ## Troubleshooting
63
+
64
+ **Tag does not match pyproject.toml version**
65
+ The build job validates that the git tag (e.g., `v0.2.0`) matches the version in `pyproject.toml` (e.g., `0.2.0`). Fix by updating `pyproject.toml` to match the tag before publishing, then delete and recreate the release.
66
+
67
+ **403 / authentication error**
68
+ Verify that `PYPI_API_TOKEN` has been added to the `ux-labs` repo secrets in `github-control`. The token must exist as a GitHub Actions secret before the workflow can publish.
69
+
70
+ **Package already exists on PyPI**
71
+ PyPI does not allow re-uploading the same version. Bump the version in `pyproject.toml` and create a new release.
@@ -0,0 +1,110 @@
1
+ # Exceptions & Error Handling
2
+
3
+ ---
4
+
5
+ ## Layer 1 — httpx (the bottom, third-party)
6
+
7
+ `httpx` is the HTTP library doing the actual network calls. It can throw two kinds of errors (regardless of async or sync):
8
+
9
+ - `httpx.TimeoutException` — the request took too long
10
+ - `httpx.RequestError` — any other network failure (DNS, connection refused, etc.)
11
+
12
+ These are not SDK errors yet. The job of the layers above is to catch and translate them.
13
+
14
+ ---
15
+
16
+ ## Layer 2 — `_make_status_error` in `_base.py` (the translator)
17
+
18
+ When the server responds but with a bad status code, httpx itself doesn't raise — it just returns a response with `response.is_error == True`. So `_base.py:60` has `_make_status_error()` which:
19
+
20
+ 1. Looks up the status code in a map → picks the right exception class
21
+ 2. Tries to parse the JSON body for a human-readable message
22
+ 3. Falls back to raw response text if the body isn't JSON
23
+
24
+ ```
25
+ 404 → NotFoundError
26
+ 401 → AuthenticationError
27
+ 429 → RateLimitError
28
+ 5xx → InternalServerError
29
+ ...etc
30
+ ```
31
+
32
+ This is where the raw HTTP world gets converted into SDK-typed exceptions.
33
+
34
+ ---
35
+
36
+ ## Layer 3 — `_request()` in `sync.py` / `async_.py` (the retry + catch layer)
37
+
38
+ This is the core of the logic. It has two jobs:
39
+
40
+ **Job 1 — retry certain errors via tenacity:**
41
+
42
+ ```
43
+ RequestTimeoutError ← server sent 408
44
+ RateLimitError ← server sent 429
45
+ InternalServerError ← server sent 5xx
46
+ httpx.RequestError ← network failure
47
+ ```
48
+
49
+ These are retried up to `max_retries` times (default 2) with exponential backoff (0.5s multiplier, max 8s wait).
50
+
51
+ **Job 2 — translate the remaining httpx errors that survived all retries:**
52
+
53
+ ```python
54
+ except httpx.TimeoutException → raise APITimeoutError
55
+ except httpx.RequestError → raise APIConnectionError
56
+ ```
57
+
58
+ Notice what's *not* retried: `BadRequestError` (400), `AuthenticationError` (401), `NotFoundError` (404), etc. Those propagate immediately on the first attempt — there's no point retrying them.
59
+
60
+ ---
61
+
62
+ ## Layer 4 — `_get` / `_post` / `_post_stream` (the interface layer)
63
+
64
+ These call `_request()` and either parse the response via Pydantic (`_parse_response`) or yield raw lines (for SSE streams). They don't add any error handling — they just let exceptions propagate upward.
65
+
66
+ ---
67
+
68
+ ## Layer 5 — The exception hierarchy (`exceptions.py`)
69
+
70
+ Everything the user can catch inherits from `SDKError`:
71
+
72
+ ```
73
+ SDKError
74
+ └─ APIError ← carries .request and .response
75
+ ├─ APIConnectionError ← network failed
76
+ │ └─ APITimeoutError ← specifically a timeout
77
+ └─ APIStatusError ← carries .status_code too
78
+ ├─ BadRequestError (400)
79
+ ├─ AuthenticationError (401)
80
+ ├─ PermissionDeniedError (403)
81
+ ├─ NotFoundError (404)
82
+ ├─ RequestTimeoutError (408) ← server-side timeout
83
+ ├─ ConflictError (409)
84
+ ├─ UnprocessableEntityError (422)
85
+ ├─ RateLimitError (429)
86
+ └─ InternalServerError (500+)
87
+ ```
88
+
89
+ Note the distinction between `APITimeoutError` (client-side — the request never completed) and `RequestTimeoutError` (server-side — server responded with 408).
90
+
91
+ ---
92
+
93
+ ## What this means for SDK users
94
+
95
+ They can be as broad or specific as they want:
96
+
97
+ ```python
98
+ from tinyfish import TinyFish, RateLimitError, AuthenticationError, SDKError
99
+
100
+ client = TinyFish(api_key="...")
101
+
102
+ try:
103
+ result = client.agent.run(...)
104
+ except AuthenticationError:
105
+ # bad API key — don't retry
106
+ except RateLimitError:
107
+ # already retried internally, give up
108
+ except SDKError:
109
+ # catch anything else
110
+ ```
@@ -0,0 +1,110 @@
1
+ # Exceptions & Error Handling
2
+
3
+ ---
4
+
5
+ ## Layer 1 — httpx (the bottom, third-party)
6
+
7
+ `httpx` is the HTTP library doing the actual network calls. It can throw two kinds of errors (regardless of async or sync):
8
+
9
+ - `httpx.TimeoutException` — the request took too long
10
+ - `httpx.RequestError` — any other network failure (DNS, connection refused, etc.)
11
+
12
+ These are not SDK errors yet. The job of the layers above is to catch and translate them.
13
+
14
+ ---
15
+
16
+ ## Layer 2 — `_make_status_error` in `_base.py` (the translator)
17
+
18
+ When the server responds but with a bad status code, httpx itself doesn't raise — it just returns a response with `response.is_error == True`. So `_base.py:60` has `_make_status_error()` which:
19
+
20
+ 1. Looks up the status code in a map → picks the right exception class
21
+ 2. Tries to parse the JSON body for a human-readable message
22
+ 3. Falls back to raw response text if the body isn't JSON
23
+
24
+ ```
25
+ 404 → NotFoundError
26
+ 401 → AuthenticationError
27
+ 429 → RateLimitError
28
+ 5xx → InternalServerError
29
+ ...etc
30
+ ```
31
+
32
+ This is where the raw HTTP world gets converted into SDK-typed exceptions.
33
+
34
+ ---
35
+
36
+ ## Layer 3 — `_request()` in `sync.py` / `async_.py` (the retry + catch layer)
37
+
38
+ This is the core of the logic. It has two jobs:
39
+
40
+ **Job 1 — retry certain errors via tenacity:**
41
+
42
+ ```
43
+ RequestTimeoutError ← server sent 408
44
+ RateLimitError ← server sent 429
45
+ InternalServerError ← server sent 5xx
46
+ httpx.RequestError ← network failure
47
+ ```
48
+
49
+ These are retried up to `max_retries` times (default 2) with exponential backoff (0.5s multiplier, max 8s wait).
50
+
51
+ **Job 2 — translate the remaining httpx errors that survived all retries:**
52
+
53
+ ```python
54
+ except httpx.TimeoutException → raise APITimeoutError
55
+ except httpx.RequestError → raise APIConnectionError
56
+ ```
57
+
58
+ Notice what's *not* retried: `BadRequestError` (400), `AuthenticationError` (401), `NotFoundError` (404), etc. Those propagate immediately on the first attempt — there's no point retrying them.
59
+
60
+ ---
61
+
62
+ ## Layer 4 — `_get` / `_post` / `_post_stream` (the interface layer)
63
+
64
+ These call `_request()` and either parse the response via Pydantic (`_parse_response`) or yield raw lines (for SSE streams). They don't add any error handling — they just let exceptions propagate upward.
65
+
66
+ ---
67
+
68
+ ## Layer 5 — The exception hierarchy (`exceptions.py`)
69
+
70
+ Everything the user can catch inherits from `SDKError`:
71
+
72
+ ```
73
+ SDKError
74
+ └─ APIError ← carries .request and .response
75
+ ├─ APIConnectionError ← network failed
76
+ │ └─ APITimeoutError ← specifically a timeout
77
+ └─ APIStatusError ← carries .status_code too
78
+ ├─ BadRequestError (400)
79
+ ├─ AuthenticationError (401)
80
+ ├─ PermissionDeniedError (403)
81
+ ├─ NotFoundError (404)
82
+ ├─ RequestTimeoutError (408) ← server-side timeout
83
+ ├─ ConflictError (409)
84
+ ├─ UnprocessableEntityError (422)
85
+ ├─ RateLimitError (429)
86
+ └─ InternalServerError (500+)
87
+ ```
88
+
89
+ Note the distinction between `APITimeoutError` (client-side — the request never completed) and `RequestTimeoutError` (server-side — server responded with 408).
90
+
91
+ ---
92
+
93
+ ## What this means for SDK users
94
+
95
+ They can be as broad or specific as they want:
96
+
97
+ ```python
98
+ from tinyfish import TinyFish, RateLimitError, AuthenticationError, SDKError
99
+
100
+ client = TinyFish(api_key="...")
101
+
102
+ try:
103
+ result = client.agent.run(...)
104
+ except AuthenticationError:
105
+ # bad API key — don't retry
106
+ except RateLimitError:
107
+ # already retried internally, give up
108
+ except SDKError:
109
+ # catch anything else
110
+ ```
@@ -0,0 +1,59 @@
1
+ # Publishing to the TinyFish Private Registry
2
+
3
+ ## Prerequisites
4
+
5
+ - [uv](https://docs.astral.sh/uv/) installed
6
+ - PyPI credentials from 1Password
7
+ - Need to be connected to VPN (or company wifi)
8
+
9
+ ## 1. Clean and build
10
+
11
+ ```bash
12
+ cd sdk/sdk-python
13
+ rm -rf dist/
14
+ uv build
15
+ ```
16
+
17
+ This outputs `dist/tinyfish-<version>-py3-none-any.whl` and `dist/tinyfish-<version>.tar.gz`.
18
+
19
+ ## 2. Publish
20
+
21
+ Set credentials via environment variables (values are in 1Password):
22
+
23
+ ```bash
24
+ export UV_PUBLISH_USERNAME="<from 1password>"
25
+ export UV_PUBLISH_PASSWORD="<from 1password>"
26
+ ```
27
+
28
+ Then publish:
29
+
30
+ ```bash
31
+ uv publish dist/* --publish-url https://pypi.production.tinyfish.io/
32
+ ```
33
+
34
+ ## 3. Verify the install
35
+
36
+ Configure pip to authenticate with the private registry. Add to `~/.pip/pip.conf` (or `pip.ini` on Windows):
37
+
38
+ ```ini
39
+ [global]
40
+ extra-index-url = https://pypi.production.tinyfish.io/
41
+ ```
42
+
43
+ Then set credentials via environment variables:
44
+
45
+ ```bash
46
+ export PIP_EXTRA_INDEX_URL="https://${TINYFISH_PYPI_USER}:${TINYFISH_PYPI_PASS}@pypi.production.tinyfish.io/"
47
+ ```
48
+
49
+ ```bash
50
+ pip install tinyfish
51
+ python -c "from tinyfish import TinyFish; print('ok')"
52
+ ```
53
+
54
+ Use `--extra-index-url` (not `--index-url`) so pip still falls back to public PyPI for dependencies like `httpx`, `pydantic`, and `tenacity`.
55
+
56
+ ## Notes
57
+
58
+ - **Bump the version** before each publish — the registry rejects duplicate versions. Update the version in `pyproject.toml`.
59
+ - **Never put credentials inline** in commands — they leak into shell history and logs. Always use environment variables or config files.