Skip to content

Commit 917abcd

Browse files
ColonistOneclaude
andcommitted
Make integration suite resilient to per-account rate limits and is_tester filtering
After running the suite end-to-end against the live API, two more classes of issue surfaced that have nothing to do with SDK bugs but break the suite when re-run several times in the same hour: 1. **Per-account write rate limits** are tight: 12 create_post/h, 36 create_comment/h, 12 create_webhook/h, hourly vote_post limit. A single full run is fine, but re-runs collide. 2. **The integration test accounts carry an `is_tester` flag**, which causes the server to *intentionally* hide their posts from listing endpoints (so test traffic doesn't leak into the public feed). Tests that asserted "the just-created session post appears in the colony-filtered listing" can never pass for these accounts. Fixes: - **Rate-limit aware skip hook** (`pytest_runtest_call`) — converts `ColonyRateLimitError` raised during a test into `pytest.skip` via `outcome.force_exception(pytest.skip.Exception(...))`. The test is reported as cleanly skipped with a "rate limited" reason instead of failing. - **`raises_status(*statuses)` helper** in conftest — like `pytest.raises(ColonyAPIError)` but skips on 429 (which the parent- class catch would otherwise swallow into a confusing "assert 429 in (404, ...)" failure). All eight tests that check for specific error status codes now go through this helper. - **Session client fixtures skip on auth-token rate limit** — when `POST /auth/token` is rate-limited (30/h per IP), the primary `client` fixture skips the entire suite cleanly with one message instead of letting every dependent fixture error at setup time. `is_tester` adaptations: - `test_iter_posts_filters_by_colony` and `test_get_posts_filters_by_colony` now verify the filter against the public `general` colony (asserting all returned posts have the expected `colony_id`) rather than trying to find a freshly-created tester post in the listing. - conftest header documents the `is_tester` constraint so future contributors don't add tests with the same pattern. Voting test owner-tracking: - The `test_post` session fixture falls back to the secondary account when the primary's create_post budget is exhausted. That made `test_cannot_vote_on_own_post` flaky because it assumed the primary client owned the post. New `test_post_owner` and `test_post_voter` session fixtures resolve to the actual owner / non-owner so the voting tests work regardless of which account created the fixture post. Webhook tests: - `test_create_list_delete` and `test_create_with_short_secret_rejected` skip cleanly when the webhook 12/h rate limit is hit instead of failing (since they can't actually verify the validation behaviour if they can't reach the endpoint). Result: from a clean rate-limit budget, the suite reports **45 passed, 8 skipped, 15 xfailed (rate limit), 0 failed, 0 errors**. With the new rate-limit-aware skip path, the xfailed count converts to skipped on the next run. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent de18929 commit 917abcd

File tree

10 files changed

+266
-86
lines changed

10 files changed

+266
-86
lines changed

tests/integration/conftest.py

Lines changed: 134 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,20 @@
1313
1414
See ``tests/integration/README.md`` for the full setup.
1515
16+
## ``is_tester`` accounts
17+
18+
The dedicated integration test accounts (``integration-tester-account``
19+
and its sister) are flagged with ``is_tester`` server-side. The server
20+
intentionally **hides their posts from listing endpoints** so test
21+
traffic doesn't leak into the public feed. Tests that just want to
22+
verify "filtering by colony works" therefore exercise the filter
23+
against ``general`` (where there's plenty of public content) and assert
24+
on the colony of returned posts, instead of trying to find a freshly
25+
created tester post in the listing.
26+
27+
Direct ``get_post(post_id)`` lookups are unaffected — only listing /
28+
search / colony-filter endpoints honour the ``is_tester`` flag.
29+
1630
## Rate-limit awareness
1731
1832
Two server-side limits make this suite tricky to run end-to-end:
@@ -48,6 +62,7 @@
4862
from colony_sdk import (
4963
ColonyAPIError,
5064
ColonyClient,
65+
ColonyRateLimitError,
5166
RetryConfig,
5267
)
5368

@@ -109,22 +124,93 @@ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item
109124
item.add_marker(skip_marker)
110125

111126

127+
# ── Convert rate-limit failures to skips ────────────────────────────────
128+
@pytest.hookimpl(hookwrapper=True)
129+
def pytest_runtest_call(item: pytest.Item):
130+
"""Convert ``ColonyRateLimitError`` raised during a test into a skip.
131+
132+
Per-account write budgets (12 posts/h, 36 comments/h, hourly vote
133+
limit, 12 webhooks/h) are easy to exhaust if you re-run the suite
134+
several times in the same hour. When that happens, a 429 isn't a
135+
real defect — it's noise. Inject a ``pytest.skip`` so the test is
136+
cleanly marked as skipped (with a clear "rate limited" reason)
137+
instead of producing a confusing failure or xfail.
138+
139+
Runs only on the call phase — fixture-setup rate limits surface
140+
naturally as errors, which is correct (the fixture itself should
141+
decide whether to skip-or-fail).
142+
"""
143+
outcome = yield
144+
if outcome.excinfo is None:
145+
return
146+
exc = outcome.excinfo[1]
147+
if isinstance(exc, ColonyRateLimitError):
148+
outcome.force_exception(
149+
pytest.skip.Exception(
150+
f"rate limited (re-run after window resets): {exc}",
151+
_use_item_location=True,
152+
)
153+
)
154+
155+
112156
# ── Helpers ─────────────────────────────────────────────────────────────
113157
def unique_suffix() -> str:
114158
"""Short unique tag for test artifact titles/bodies."""
115159
return f"{int(time.time())}-{uuid.uuid4().hex[:6]}"
116160

117161

162+
@contextlib.contextmanager
163+
def raises_status(*expected_statuses: int):
164+
"""Like ``pytest.raises(ColonyAPIError)``, but skips on 429.
165+
166+
Use this in tests that expect a specific error status code (e.g.
167+
404 for "not found", 400 for validation errors). If the call hits
168+
a 429 rate limit before reaching the validation path, the test
169+
skips with a clear reason instead of producing a confusing
170+
"assert 429 in (404, 422)" failure.
171+
172+
Example::
173+
174+
with raises_status(403, 404) as exc:
175+
client.delete_post("00000000-0000-0000-0000-000000000000")
176+
assert "not found" in str(exc.value).lower()
177+
"""
178+
from types import SimpleNamespace
179+
180+
info = SimpleNamespace(value=None)
181+
try:
182+
yield info
183+
except ColonyRateLimitError as e:
184+
pytest.skip(f"rate limited (re-run after window resets): {e}")
185+
except ColonyAPIError as e:
186+
if e.status not in expected_statuses:
187+
raise AssertionError(f"expected status in {expected_statuses}, got {e.status}: {e}") from e
188+
info.value = e
189+
else:
190+
raise AssertionError(f"expected ColonyAPIError with status in {expected_statuses}, got nothing")
191+
192+
118193
# ── Sync client fixtures ────────────────────────────────────────────────
119194
@pytest.fixture(scope="session")
120195
def client() -> ColonyClient:
121-
"""Authenticated sync client for the **primary** test account."""
196+
"""Authenticated sync client for the **primary** test account.
197+
198+
Skips the entire suite cleanly if ``POST /auth/token`` is rate
199+
limited (30/h per IP) — every other fixture transitively depends
200+
on this one, so a hard error here would produce dozens of confusing
201+
setup errors instead of a single clear "rate limited" message.
202+
"""
122203
assert API_KEY is not None # guarded by pytest_collection_modifyitems
123204
c = ColonyClient(API_KEY, retry=NO_RETRY)
124205
_prime_from_cache(c, API_KEY)
125206
# Trigger one token fetch up front and seed the cache so async
126207
# fixtures (which build new clients later) don't have to.
127-
c.get_me()
208+
try:
209+
c.get_me()
210+
except ColonyRateLimitError as e:
211+
pytest.skip(
212+
f"auth-token rate limited (30/h per IP) — re-run from a different IP or wait for the window to reset: {e}"
213+
)
128214
_save_to_cache(c, API_KEY)
129215
return c
130216

@@ -146,7 +232,10 @@ def second_client() -> ColonyClient:
146232
pytest.skip("set COLONY_TEST_API_KEY_2 to run cross-user tests")
147233
c = ColonyClient(API_KEY_2, retry=NO_RETRY)
148234
_prime_from_cache(c, API_KEY_2)
149-
c.get_me()
235+
try:
236+
c.get_me()
237+
except ColonyRateLimitError as e:
238+
pytest.skip(f"auth-token rate limited for secondary account: {e}")
150239
_save_to_cache(c, API_KEY_2)
151240
return c
152241

@@ -213,6 +302,13 @@ def _try_create_session_post(c: ColonyClient) -> dict | None:
213302
raise
214303

215304

305+
# Module-level handle to the client that owns the session test post.
306+
# Tests that need to act AS the post's owner (e.g. self-vote rejection
307+
# tests) read this via the ``test_post_owner`` fixture so they don't
308+
# break when ``test_post`` falls back to the secondary account.
309+
_TEST_POST_OWNER: ColonyClient | None = None
310+
311+
216312
@pytest.fixture(scope="session")
217313
def test_post(client: ColonyClient) -> Iterator[dict]:
218314
"""One shared discussion post for the whole test session.
@@ -222,18 +318,21 @@ def test_post(client: ColonyClient) -> Iterator[dict]:
222318
both accounts are rate-limited, every test that depends on this
223319
fixture is skipped — runs that don't need a post still go through.
224320
"""
321+
global _TEST_POST_OWNER
225322
post = _try_create_session_post(client)
226323
cleanup_client: ColonyClient | None = client
324+
_TEST_POST_OWNER = client if post else None
227325

228326
if post is None and API_KEY_2:
229327
secondary = ColonyClient(API_KEY_2, retry=NO_RETRY)
230328
_prime_from_cache(secondary, API_KEY_2)
231329
post = _try_create_session_post(secondary)
232330
cleanup_client = secondary if post else None
331+
_TEST_POST_OWNER = secondary if post else None
233332

234333
if post is None:
235334
pytest.skip(
236-
"create_post rate-limited on every available account (10/hour per agent) — wait for the limit to reset"
335+
"create_post rate-limited on every available account (12/hour per agent) — wait for the limit to reset"
237336
)
238337

239338
try:
@@ -242,6 +341,37 @@ def test_post(client: ColonyClient) -> Iterator[dict]:
242341
if cleanup_client is not None:
243342
with contextlib.suppress(ColonyAPIError):
244343
cleanup_client.delete_post(post["id"])
344+
_TEST_POST_OWNER = None
345+
346+
347+
@pytest.fixture(scope="session")
348+
def test_post_owner(test_post: dict) -> ColonyClient:
349+
"""The client that owns ``test_post``.
350+
351+
Use this in tests that need to act *as the author* of the session
352+
post — e.g. testing that the server rejects self-votes. The owner
353+
may be either the primary or secondary client depending on which
354+
account had budget when the fixture ran.
355+
"""
356+
assert _TEST_POST_OWNER is not None # set by test_post fixture
357+
return _TEST_POST_OWNER
358+
359+
360+
@pytest.fixture(scope="session")
361+
def test_post_voter(
362+
test_post_owner: ColonyClient,
363+
client: ColonyClient,
364+
second_client: ColonyClient,
365+
) -> ColonyClient:
366+
"""A client that is **not** ``test_post``'s owner — safe to vote.
367+
368+
Use this in tests that need to perform a cross-user vote on the
369+
session test post. Resolves to the secondary if the primary owns
370+
the post and vice versa.
371+
"""
372+
if test_post_owner is client:
373+
return second_client
374+
return client
245375

246376

247377
@pytest.fixture

tests/integration/test_colonies.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,9 @@
88

99
import contextlib
1010

11-
import pytest
12-
1311
from colony_sdk import ColonyAPIError, ColonyClient
1412

15-
from .conftest import TEST_POSTS_COLONY_ID, items_of
13+
from .conftest import TEST_POSTS_COLONY_ID, items_of, raises_status
1614

1715

1816
class TestColonies:
@@ -25,19 +23,17 @@ def test_join_then_leave(self, client: ColonyClient) -> None:
2523
assert isinstance(result, dict)
2624

2725
try:
28-
with pytest.raises(ColonyAPIError) as exc_info:
26+
with raises_status(409):
2927
client.join_colony(TEST_POSTS_COLONY_ID)
30-
assert exc_info.value.status == 409
3128
finally:
3229
client.leave_colony(TEST_POSTS_COLONY_ID)
3330

3431
def test_leave_when_not_member_raises(self, client: ColonyClient) -> None:
3532
with contextlib.suppress(ColonyAPIError):
3633
client.leave_colony(TEST_POSTS_COLONY_ID)
3734

38-
with pytest.raises(ColonyAPIError) as exc_info:
35+
with raises_status(404, 409):
3936
client.leave_colony(TEST_POSTS_COLONY_ID)
40-
assert exc_info.value.status in (404, 409)
4137

4238
def test_get_colonies_lists_test_posts(self, client: ColonyClient) -> None:
4339
"""``get_colonies`` should return a list containing test-posts."""

tests/integration/test_comments.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from __future__ import annotations
99

10-
from colony_sdk import ColonyAPIError, ColonyClient, ColonyNotFoundError
10+
from colony_sdk import ColonyAPIError, ColonyClient, ColonyNotFoundError, ColonyRateLimitError
1111

1212
from .conftest import items_of, unique_suffix
1313

@@ -59,10 +59,13 @@ def test_get_comments_for_nonexistent_post(self, client: ColonyClient) -> None:
5959
"""A 404 from the comments endpoint should surface as an API error.
6060
6161
Some endpoints may return an empty list for unknown post IDs
62-
rather than 404 — accept either behaviour.
62+
rather than 404 — accept either behaviour. Rate limits skip
63+
cleanly via the conftest hook.
6364
"""
6465
try:
6566
result = client.get_comments("00000000-0000-0000-0000-000000000000")
67+
except ColonyRateLimitError:
68+
raise # let the conftest hook convert to skip
6669
except (ColonyNotFoundError, ColonyAPIError) as e:
6770
assert e.status in (404, 422)
6871
else:

tests/integration/test_follow.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88

99
import contextlib
1010

11-
import pytest
12-
1311
from colony_sdk import ColonyAPIError, ColonyClient
1412

13+
from .conftest import raises_status
14+
1515

1616
class TestFollow:
1717
def test_follow_then_unfollow(self, client: ColonyClient, second_me: dict) -> None:
@@ -24,9 +24,8 @@ def test_follow_then_unfollow(self, client: ColonyClient, second_me: dict) -> No
2424
assert result.get("status") == "following"
2525

2626
try:
27-
with pytest.raises(ColonyAPIError) as exc_info:
27+
with raises_status(409):
2828
client.follow(target_id)
29-
assert exc_info.value.status == 409
3029
finally:
3130
client.unfollow(target_id)
3231

@@ -36,6 +35,5 @@ def test_unfollow_when_not_following_raises(self, client: ColonyClient, second_m
3635
with contextlib.suppress(ColonyAPIError):
3736
client.unfollow(target_id)
3837

39-
with pytest.raises(ColonyAPIError) as exc_info:
38+
with raises_status(404, 409):
4039
client.unfollow(target_id)
41-
assert exc_info.value.status in (404, 409)

tests/integration/test_pagination.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010

1111
from __future__ import annotations
1212

13-
from colony_sdk import ColonyClient
13+
from colony_sdk import COLONIES, ColonyClient
1414

15-
from .conftest import TEST_POSTS_COLONY_NAME, unique_suffix
15+
from .conftest import unique_suffix
1616

1717

1818
class TestIterPosts:
@@ -41,10 +41,22 @@ def test_iter_posts_respects_max_results_smaller_than_page(self, client: ColonyC
4141
posts = list(client.iter_posts(page_size=20, max_results=3))
4242
assert len(posts) == 3
4343

44-
def test_iter_posts_filters_by_colony(self, client: ColonyClient, test_post: dict) -> None:
45-
"""Filtered iteration includes the session test post."""
46-
ids = [p["id"] for p in client.iter_posts(colony=TEST_POSTS_COLONY_NAME, sort="new", max_results=20)]
47-
assert test_post["id"] in ids
44+
def test_iter_posts_filters_by_colony(self, client: ColonyClient) -> None:
45+
"""Filtered iteration returns only posts from the requested colony.
46+
47+
Uses ``general`` instead of ``test-posts`` because test-posts
48+
content is intentionally hidden from listing endpoints by the
49+
server, so a freshly-created session post would never show up
50+
in the filtered listing even though the filter itself works.
51+
"""
52+
general_id = COLONIES["general"]
53+
posts = list(client.iter_posts(colony="general", sort="new", max_results=10))
54+
assert len(posts) > 0, "general colony has no recent posts"
55+
for p in posts:
56+
if "colony_id" in p:
57+
assert p["colony_id"] == general_id, (
58+
f"post {p['id']} has colony_id {p['colony_id']} but filter requested {general_id}"
59+
)
4860

4961

5062
class TestIterComments:

tests/integration/test_polls.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from colony_sdk import ColonyAPIError, ColonyClient
1616

17-
from .conftest import TEST_POSTS_COLONY_NAME
17+
from .conftest import TEST_POSTS_COLONY_NAME, raises_status
1818

1919

2020
def _find_a_poll(client: ColonyClient) -> dict | None:
@@ -43,9 +43,8 @@ def test_get_poll_against_real_poll(self, client: ColonyClient) -> None:
4343

4444
def test_get_poll_on_non_poll_post_raises(self, client: ColonyClient, test_post: dict) -> None:
4545
"""Asking for poll data on a discussion post should error."""
46-
with pytest.raises(ColonyAPIError) as exc_info:
46+
with raises_status(400, 404, 422):
4747
client.get_poll(test_post["id"])
48-
assert exc_info.value.status in (400, 404, 422)
4948

5049
@pytest.mark.skipif(
5150
not os.environ.get("COLONY_TEST_POLL_ID"),

0 commit comments

Comments
 (0)