Skip to content

Commit 729bcce

Browse files
committed
v1.1.0: auto-retry with backoff, pagination helpers, split timeouts, typed returns
- Automatic retry with exponential backoff + jitter on 429, 5xx, and network errors - Configurable retry policy (max_retries, backoff_multiplier, initial_retry_delay, max_retry_delay) - paginate() and paginate_pages() generators for auto-pagination - Split timeouts (connect_timeout, read_timeout) with tuple support - Typed return values on all 70+ resource methods (specific TypedDicts) - rate_limit_info property exposes last 429 state - Updated README with new feature documentation
1 parent f16ac2b commit 729bcce

21 files changed

Lines changed: 753 additions & 108 deletions

README.md

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,13 @@ next_page = client.user.get_followers(
3737
## Features
3838

3939
- **70+ endpoints** covering users, tweets, posts, interactions, DMs, communities, spaces, and search
40-
- **Full type hints** with TypedDict definitions for IDE autocomplete
40+
- **Full type hints** with TypedDict response types for IDE autocomplete
41+
- **Automatic retry with backoff** on rate limits (429) and server errors (5xx)
42+
- **Auto-pagination helpers** — iterate all pages with a simple `for` loop
4143
- **Solid error handling** with typed exceptions (`RateLimitError`, `NotFoundError`, etc.)
44+
- **Rate limit awareness**`retry_after` respected automatically, state exposed via `client.rate_limit_info`
45+
- **Split timeouts** — separate connect and read timeouts
4246
- **Single dependency**`requests` only
43-
- **Pagination support** with cursor-based navigation
4447
- **Python 3.9+** compatible
4548

4649
## API Reference
@@ -170,12 +173,71 @@ next_page = client.user.get_followers(
170173
| `client.dm.get_dm_user_updates(auth_token=..., cursor=...)` | DM user updates |
171174
| `client.dm.accept_conversation(auth_token=..., conversation_id=...)` | Accept request |
172175

176+
## Auto-Pagination
177+
178+
Use the `paginate()` and `paginate_pages()` helpers to iterate through all pages automatically:
179+
180+
```python
181+
from tweetapi import TweetAPI, paginate, paginate_pages
182+
183+
client = TweetAPI(api_key="YOUR_API_KEY")
184+
185+
# Iterate individual items across all pages
186+
for user in paginate(
187+
lambda cursor: client.user.get_followers(user_id="123456", cursor=cursor),
188+
):
189+
print(user["username"])
190+
191+
# Iterate full pages (access page-level data)
192+
for page in paginate_pages(
193+
lambda cursor: client.explore.search(query="bitcoin", type="Latest", cursor=cursor),
194+
max_pages=5, # optional: limit number of pages
195+
):
196+
print(f"Got {len(page['data'])} results")
197+
print(f"Next cursor: {page['pagination']['nextCursor']}")
198+
```
199+
200+
Works with any paginated endpoint — followers, tweets, search results, list members, community posts, etc.
201+
202+
## Automatic Retry with Backoff
203+
204+
The SDK automatically retries on transient errors with exponential backoff:
205+
206+
- **429 (Rate Limit)** — waits the `retry_after` duration from the API, then retries
207+
- **5xx (Server Error)** — retries with exponential backoff + jitter
208+
- **Network errors** — retries on timeouts and connection failures
209+
- **4xx (Client Error)** — never retried (400, 401, 403, 404 fail immediately)
210+
211+
Default: 3 retries, 2x backoff, 1s initial delay, 30s max delay.
212+
213+
```python
214+
# Customize retry behavior
215+
client = TweetAPI(
216+
api_key="YOUR_API_KEY",
217+
max_retries=5, # default: 3
218+
initial_retry_delay=2.0, # default: 1.0 (seconds)
219+
backoff_multiplier=3.0, # default: 2.0
220+
max_retry_delay=60.0, # default: 30.0 (seconds)
221+
)
222+
223+
# Disable retries entirely
224+
client = TweetAPI(api_key="YOUR_API_KEY", max_retries=0)
225+
```
226+
227+
### Rate Limit Awareness
228+
229+
After a 429 response, the SDK exposes the last known rate limit state:
230+
231+
```python
232+
print(client.rate_limit_info)
233+
# {"retry_after": 30, "timestamp": 1712345678.0} — or None if no 429 encountered
234+
```
235+
173236
## Error Handling
174237

175-
The SDK throws typed exceptions you can catch and handle:
238+
The SDK raises typed exceptions you can catch and handle. With automatic retries enabled (default), you'll only see these after all retry attempts are exhausted:
176239

177240
```python
178-
import time
179241
from tweetapi import (
180242
TweetAPI,
181243
TweetAPIError,
@@ -192,9 +254,7 @@ client = TweetAPI(api_key="YOUR_API_KEY")
192254
try:
193255
user = client.user.get_by_username(username="elonmusk")
194256
except RateLimitError as e:
195-
# Wait and retry
196257
print(f"Rate limited. Retry in {e.retry_after}s")
197-
time.sleep(e.retry_after)
198258
except NotFoundError:
199259
print("User not found")
200260
except AuthenticationError:
@@ -206,7 +266,6 @@ except ServerError:
206266
except NetworkError:
207267
print("Network error — check your connection")
208268
except TweetAPIError as e:
209-
# Catch-all for any other API error
210269
print(f"Error [{e.code}]: {e.message}")
211270
```
212271

@@ -222,8 +281,17 @@ Every error includes:
222281
client = TweetAPI(
223282
api_key="YOUR_API_KEY", # Required
224283
base_url="https://...", # Optional (default: https://api.tweetapi.com)
225-
timeout=30, # Optional (default: 30 seconds)
284+
timeout=30, # Optional — single value for both connect + read
285+
connect_timeout=10.0, # Optional (default: 10s)
286+
read_timeout=30.0, # Optional (default: 30s)
287+
max_retries=3, # Optional (default: 3, set 0 to disable)
288+
backoff_multiplier=2.0, # Optional (default: 2.0)
289+
initial_retry_delay=1.0, # Optional (default: 1.0s)
290+
max_retry_delay=30.0, # Optional (default: 30.0s)
226291
)
292+
293+
# You can also pass a tuple for timeout
294+
client = TweetAPI(api_key="YOUR_API_KEY", timeout=(5, 30)) # (connect, read)
227295
```
228296

229297
## Requirements

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "tweetapi"
7-
version = "1.0.0"
7+
version = "1.1.0"
88
description = "Official Python SDK for TweetAPI — Twitter/X Data API for developers and researchers"
99
readme = "README.md"
1010
license = "MIT"

tests/test_client.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717
BASE_URL = "https://api.tweetapi.com"
1818

1919

20-
def make_client() -> TweetAPI:
21-
return TweetAPI(api_key="test-api-key")
20+
def make_client(**kwargs) -> TweetAPI:
21+
defaults = {"api_key": "test-api-key", "max_retries": 0}
22+
defaults.update(kwargs)
23+
return TweetAPI(**defaults)
2224

2325

2426
class TestClientInit:
@@ -240,6 +242,6 @@ def test_preserves_api_error_code(self):
240242
assert exc_info.value.code == "ACCOUNT_SUSPENDED"
241243

242244
def test_network_error_on_connection_failure(self):
243-
client = TweetAPI(api_key="key", base_url="http://localhost:1")
245+
client = TweetAPI(api_key="key", base_url="http://localhost:1", max_retries=0)
244246
with pytest.raises(NetworkError):
245247
client.user.get_by_username(username="test")

tests/test_pagination.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Tests for auto-pagination helpers."""
2+
3+
import pytest
4+
5+
from tweetapi import paginate, paginate_pages
6+
7+
8+
def make_fetcher(pages):
9+
"""Create a fetcher that returns pre-built pages in sequence."""
10+
call_count = [0]
11+
12+
def fetcher(cursor=None):
13+
idx = call_count[0]
14+
if idx >= len(pages):
15+
raise RuntimeError(f"Unexpected call #{idx + 1}")
16+
call_count[0] += 1
17+
return pages[idx]
18+
19+
fetcher.call_count = call_count
20+
return fetcher
21+
22+
23+
class TestPaginatePages:
24+
def test_iterates_all_pages(self):
25+
pages_data = [
26+
{"data": [{"id": "1"}, {"id": "2"}], "pagination": {"nextCursor": "c2", "prevCursor": None}},
27+
{"data": [{"id": "3"}], "pagination": {"nextCursor": "c3", "prevCursor": "c2"}},
28+
{"data": [{"id": "4"}], "pagination": {"nextCursor": None, "prevCursor": "c3"}},
29+
]
30+
fetcher = make_fetcher(pages_data)
31+
32+
result = list(paginate_pages(fetcher))
33+
assert len(result) == 3
34+
assert fetcher.call_count[0] == 3
35+
36+
def test_respects_max_pages(self):
37+
pages_data = [
38+
{"data": [{"id": "1"}], "pagination": {"nextCursor": "c2", "prevCursor": None}},
39+
{"data": [{"id": "2"}], "pagination": {"nextCursor": "c3", "prevCursor": "c2"}},
40+
{"data": [{"id": "3"}], "pagination": {"nextCursor": None, "prevCursor": "c3"}},
41+
]
42+
fetcher = make_fetcher(pages_data)
43+
44+
result = list(paginate_pages(fetcher, max_pages=2))
45+
assert len(result) == 2
46+
assert fetcher.call_count[0] == 2
47+
48+
def test_single_page_no_next_cursor(self):
49+
pages_data = [
50+
{"data": [{"id": "1"}], "pagination": {"nextCursor": None, "prevCursor": None}},
51+
]
52+
fetcher = make_fetcher(pages_data)
53+
54+
result = list(paginate_pages(fetcher))
55+
assert len(result) == 1
56+
57+
def test_empty_data(self):
58+
pages_data = [
59+
{"data": [], "pagination": {"nextCursor": None, "prevCursor": None}},
60+
]
61+
fetcher = make_fetcher(pages_data)
62+
63+
result = list(paginate_pages(fetcher))
64+
assert len(result) == 1
65+
assert result[0]["data"] == []
66+
67+
def test_error_propagation(self):
68+
call_count = [0]
69+
70+
def fetcher(cursor=None):
71+
idx = call_count[0]
72+
call_count[0] += 1
73+
if idx == 0:
74+
return {"data": [{"id": "1"}], "pagination": {"nextCursor": "c2", "prevCursor": None}}
75+
raise RuntimeError("API error")
76+
77+
with pytest.raises(RuntimeError, match="API error"):
78+
list(paginate_pages(fetcher))
79+
80+
81+
class TestPaginate:
82+
def test_yields_individual_items(self):
83+
pages_data = [
84+
{"data": [{"id": "1"}, {"id": "2"}], "pagination": {"nextCursor": "c2", "prevCursor": None}},
85+
{"data": [{"id": "3"}], "pagination": {"nextCursor": None, "prevCursor": "c2"}},
86+
]
87+
fetcher = make_fetcher(pages_data)
88+
89+
items = list(paginate(fetcher))
90+
assert items == [{"id": "1"}, {"id": "2"}, {"id": "3"}]
91+
92+
def test_respects_max_pages(self):
93+
pages_data = [
94+
{"data": [{"id": "1"}], "pagination": {"nextCursor": "c2", "prevCursor": None}},
95+
{"data": [{"id": "2"}], "pagination": {"nextCursor": None, "prevCursor": "c2"}},
96+
]
97+
fetcher = make_fetcher(pages_data)
98+
99+
items = list(paginate(fetcher, max_pages=1))
100+
assert items == [{"id": "1"}]
101+
assert fetcher.call_count[0] == 1

0 commit comments

Comments
 (0)