Skip to content

Commit 8c9a0c9

Browse files
karlwaldmanclaude
andauthored
ci: skip live tests on 429 (shared-key rate-limit resilience) (#38)
The integration/live tests hit the real API with a single 1-request/second key that is shared across SDK repos in CI. Cross-repo contention produces HTTP 429 (RateLimitError) responses that fail otherwise-green code. Add a `live_call` fixture in tests/integration/conftest.py that: - spaces live calls >= 1.1s apart (1 req/sec shared key), and - converts any 429 (RateLimitError) into a pytest.skip while re-raising all other errors unchanged so real failures still surface. Route every live API call in test_live_api.py and test_historical_endpoints.py through this helper. The existing skip-if-no-OILPRICEAPI_KEY behavior is preserved. No version bump. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f0ceb5b commit 8c9a0c9

3 files changed

Lines changed: 105 additions & 40 deletions

File tree

‎tests/integration/conftest.py‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
"""
44

55
import os
6+
import time
67
import pytest
78
from pathlib import Path
89
from oilpriceapi import OilPriceAPI
10+
from oilpriceapi.exceptions import RateLimitError
911

1012
try:
1113
from dotenv import dotenv_values
@@ -27,6 +29,24 @@ def dotenv_values(_path): # type: ignore[misc]
2729
API_KEY = env_vars.get('OILPRICEAPI_KEY')
2830
BASE_URL = env_vars.get('OILPRICEAPI_BASE_URL', 'https://api.oilpriceapi.com')
2931

32+
# CI shares a single 1-request/second API key across repositories, so live
33+
# tests can collide and get HTTP 429 (RateLimitError) through no fault of the
34+
# code under test. To keep green code green we:
35+
# 1. Space live calls at least MIN_CALL_SPACING_SECONDS apart, and
36+
# 2. Treat any 429 as a pytest.skip rather than a failure.
37+
MIN_CALL_SPACING_SECONDS = 1.1
38+
_last_live_call_at = 0.0
39+
40+
41+
def _throttle_live_calls():
42+
"""Enforce >=1.1s spacing between live API calls (1 req/sec shared key)."""
43+
global _last_live_call_at
44+
now = time.monotonic()
45+
elapsed = now - _last_live_call_at
46+
if elapsed < MIN_CALL_SPACING_SECONDS:
47+
time.sleep(MIN_CALL_SPACING_SECONDS - elapsed)
48+
_last_live_call_at = time.monotonic()
49+
3050

3151
@pytest.fixture(scope="session")
3252
def api_key():
@@ -47,4 +67,27 @@ def live_client(api_key, base_url):
4767
"""Create a client for live API testing."""
4868
client = OilPriceAPI(api_key=api_key, base_url=base_url)
4969
yield client
50-
client.close()
70+
client.close()
71+
72+
73+
@pytest.fixture
74+
def live_call():
75+
"""Run a live API call with rate-limit resilience.
76+
77+
Spaces calls out (>=1.1s) and converts HTTP 429 (RateLimitError) into a
78+
pytest.skip so the shared 1-req/sec CI key can't red-flag green code.
79+
Any non-429 error is re-raised unchanged so real failures still surface.
80+
81+
Usage:
82+
price = live_call(client.prices.get, "BRENT_CRUDE_USD")
83+
"""
84+
def _call(func, *args, **kwargs):
85+
_throttle_live_calls()
86+
try:
87+
return func(*args, **kwargs)
88+
except RateLimitError:
89+
pytest.skip(
90+
"rate-limited (shared CI key) - skipping live assertion"
91+
)
92+
93+
return _call

‎tests/integration/test_historical_endpoints.py‎

Lines changed: 44 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,21 @@
1313
import time
1414
from datetime import datetime, timedelta
1515
from oilpriceapi import OilPriceAPI
16-
from oilpriceapi.exceptions import TimeoutError
16+
from oilpriceapi.exceptions import RateLimitError, TimeoutError
1717

1818

1919
@pytest.mark.integration
2020
class TestHistoricalEndpointSelection:
2121
"""Test that SDK selects correct endpoints for different date ranges."""
2222

23-
def test_1_day_query_uses_past_day_endpoint(self, live_client):
23+
def test_1_day_query_uses_past_day_endpoint(self, live_client, live_call):
2424
"""Verify 1-day queries use /v1/prices/past_day endpoint."""
2525
end_date = datetime.now()
2626
start_date = end_date - timedelta(days=1)
2727

2828
start_time = time.time()
29-
history = live_client.historical.get(
29+
history = live_call(
30+
live_client.historical.get,
3031
commodity="WTI_USD",
3132
start_date=start_date.strftime("%Y-%m-%d"),
3233
end_date=end_date.strftime("%Y-%m-%d"),
@@ -41,13 +42,14 @@ def test_1_day_query_uses_past_day_endpoint(self, live_client):
4142
# Should be fast (using optimized endpoint)
4243
assert duration < 10, f"1-day query took {duration}s, expected <10s"
4344

44-
def test_7_day_query_uses_past_week_endpoint(self, live_client):
45+
def test_7_day_query_uses_past_week_endpoint(self, live_client, live_call):
4546
"""Verify 7-day queries use /v1/prices/past_week endpoint."""
4647
end_date = datetime.now()
4748
start_date = end_date - timedelta(days=7)
4849

4950
start_time = time.time()
50-
history = live_client.historical.get(
51+
history = live_call(
52+
live_client.historical.get,
5153
commodity="WTI_USD",
5254
start_date=start_date.strftime("%Y-%m-%d"),
5355
end_date=end_date.strftime("%Y-%m-%d"),
@@ -65,13 +67,14 @@ def test_7_day_query_uses_past_week_endpoint(self, live_client):
6567
assert duration < 30, f"7-day query took {duration}s, expected <30s"
6668
print(f"✓ 7-day query completed in {duration:.2f}s (optimized endpoint)")
6769

68-
def test_30_day_query_uses_past_month_endpoint(self, live_client):
70+
def test_30_day_query_uses_past_month_endpoint(self, live_client, live_call):
6971
"""Verify 30-day queries use /v1/prices/past_month endpoint."""
7072
end_date = datetime.now()
7173
start_date = end_date - timedelta(days=30)
7274

7375
start_time = time.time()
74-
history = live_client.historical.get(
76+
history = live_call(
77+
live_client.historical.get,
7578
commodity="WTI_USD",
7679
start_date=start_date.strftime("%Y-%m-%d"),
7780
end_date=end_date.strftime("%Y-%m-%d"),
@@ -89,14 +92,15 @@ def test_30_day_query_uses_past_month_endpoint(self, live_client):
8992
assert duration < 60, f"30-day query took {duration}s, expected <60s"
9093
print(f"✓ 30-day query completed in {duration:.2f}s (optimized endpoint)")
9194

92-
def test_365_day_query_uses_past_year_endpoint(self, live_client):
95+
def test_365_day_query_uses_past_year_endpoint(self, live_client, live_call):
9396
"""
9497
Verify 365-day queries use /v1/prices/past_year endpoint.
9598
9699
This is the EXACT query that failed for Idan in v1.4.1.
97100
"""
98101
start_time = time.time()
99-
history = live_client.historical.get(
102+
history = live_call(
103+
live_client.historical.get,
100104
commodity="WTI_USD",
101105
start_date="2024-01-01",
102106
end_date="2024-12-31",
@@ -129,13 +133,14 @@ def test_365_day_query_uses_past_year_endpoint(self, live_client):
129133
class TestHistoricalTimeoutBehavior:
130134
"""Test timeout handling for historical queries."""
131135

132-
def test_custom_timeout_is_respected(self, live_client):
136+
def test_custom_timeout_is_respected(self, live_client, live_call):
133137
"""Test that custom timeout parameter works."""
134138
# Try a multi-year query with custom timeout
135139
start_time = time.time()
136140

137141
try:
138-
history = live_client.historical.get(
142+
history = live_call(
143+
live_client.historical.get,
139144
commodity="WTI_USD",
140145
start_date="2020-01-01",
141146
end_date="2024-12-31",
@@ -154,11 +159,12 @@ def test_custom_timeout_is_respected(self, live_client):
154159
# Still assert we tried with the right timeout
155160
assert duration >= 120, "Should have used longer timeout"
156161

157-
def test_timeout_scales_with_date_range(self, live_client):
162+
def test_timeout_scales_with_date_range(self, live_client, live_call):
158163
"""Verify timeout automatically scales for larger date ranges."""
159164
# Small query should have short timeout
160165
start_time = time.time()
161-
history_week = live_client.historical.get(
166+
history_week = live_call(
167+
live_client.historical.get,
162168
commodity="BRENT_CRUDE_USD",
163169
start_date=(datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d"),
164170
end_date=datetime.now().strftime("%Y-%m-%d"),
@@ -171,7 +177,8 @@ def test_timeout_scales_with_date_range(self, live_client):
171177

172178
# Large query should have longer timeout
173179
start_time = time.time()
174-
history_year = live_client.historical.get(
180+
history_year = live_call(
181+
live_client.historical.get,
175182
commodity="BRENT_CRUDE_USD",
176183
start_date="2024-01-01",
177184
end_date="2024-12-31",
@@ -193,13 +200,14 @@ class TestHistoricalPerformanceBaselines:
193200
These tests document expected response times and alert on regressions.
194201
"""
195202

196-
def test_1_week_query_performance_baseline(self, live_client):
203+
def test_1_week_query_performance_baseline(self, live_client, live_call):
197204
"""1-week queries should complete in <30s."""
198205
end_date = datetime.now()
199206
start_date = end_date - timedelta(days=7)
200207

201208
start_time = time.time()
202-
history = live_client.historical.get(
209+
history = live_call(
210+
live_client.historical.get,
203211
commodity="WTI_USD",
204212
start_date=start_date.strftime("%Y-%m-%d"),
205213
end_date=end_date.strftime("%Y-%m-%d"),
@@ -213,13 +221,14 @@ def test_1_week_query_performance_baseline(self, live_client):
213221
# Record baseline for monitoring
214222
print(f"📊 Performance baseline: 1-week query = {duration:.2f}s")
215223

216-
def test_1_month_query_performance_baseline(self, live_client):
224+
def test_1_month_query_performance_baseline(self, live_client, live_call):
217225
"""1-month queries should complete in <60s."""
218226
end_date = datetime.now()
219227
start_date = end_date - timedelta(days=30)
220228

221229
start_time = time.time()
222-
history = live_client.historical.get(
230+
history = live_call(
231+
live_client.historical.get,
223232
commodity="WTI_USD",
224233
start_date=start_date.strftime("%Y-%m-%d"),
225234
end_date=end_date.strftime("%Y-%m-%d"),
@@ -232,14 +241,15 @@ def test_1_month_query_performance_baseline(self, live_client):
232241

233242
print(f"📊 Performance baseline: 1-month query = {duration:.2f}s")
234243

235-
def test_1_year_query_performance_baseline(self, live_client):
244+
def test_1_year_query_performance_baseline(self, live_client, live_call):
236245
"""
237246
1-year queries should complete in <120s.
238247
239248
This test documents the exact scenario that failed for Idan.
240249
"""
241250
start_time = time.time()
242-
history = live_client.historical.get(
251+
history = live_call(
252+
live_client.historical.get,
243253
commodity="WTI_USD",
244254
start_date="2024-01-01",
245255
end_date="2024-12-31",
@@ -262,9 +272,10 @@ def test_1_year_query_performance_baseline(self, live_client):
262272
class TestHistoricalDataQuality:
263273
"""Test data quality for historical queries."""
264274

265-
def test_year_query_returns_complete_data(self, live_client):
275+
def test_year_query_returns_complete_data(self, live_client, live_call):
266276
"""Verify 1-year query returns complete dataset."""
267-
history = live_client.historical.get(
277+
history = live_call(
278+
live_client.historical.get,
268279
commodity="WTI_USD",
269280
start_date="2024-01-01",
270281
end_date="2024-12-31",
@@ -281,12 +292,13 @@ def test_year_query_returns_complete_data(self, live_client):
281292
dates = [p.date for p in history.data]
282293
assert dates == sorted(dates, reverse=True), "Data should be sorted by date (descending)"
283294

284-
def test_historical_data_matches_commodity(self, live_client):
295+
def test_historical_data_matches_commodity(self, live_client, live_call):
285296
"""Verify all returned data matches requested commodity."""
286297
commodities = ["WTI_USD", "BRENT_CRUDE_USD", "NATURAL_GAS_USD"]
287298

288299
for commodity_code in commodities:
289-
history = live_client.historical.get(
300+
history = live_call(
301+
live_client.historical.get,
290302
commodity=commodity_code,
291303
start_date=(datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d"),
292304
end_date=datetime.now().strftime("%Y-%m-%d"),
@@ -322,7 +334,14 @@ def query_historical(commodity):
322334

323335
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
324336
futures = [executor.submit(query_historical, c) for c in commodities]
325-
results = [f.result() for f in concurrent.futures.as_completed(futures)]
337+
try:
338+
results = [
339+
f.result() for f in concurrent.futures.as_completed(futures)
340+
]
341+
except RateLimitError:
342+
pytest.skip(
343+
"rate-limited (shared CI key) - skipping live assertion"
344+
)
326345

327346
assert len(results) == 3
328347
for result in results:

‎tests/integration/test_live_api.py‎

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,32 +17,33 @@
1717
class TestLiveAPIIntegration:
1818
"""Integration tests with live API."""
1919

20-
def test_get_current_price(self, live_client):
20+
def test_get_current_price(self, live_client, live_call):
2121
"""Test getting current price from live API."""
22-
price = live_client.prices.get("BRENT_CRUDE_USD")
22+
price = live_call(live_client.prices.get, "BRENT_CRUDE_USD")
2323

2424
assert price is not None
2525
assert price.commodity == "BRENT_CRUDE_USD"
2626
assert price.value > 0
2727
assert price.currency == "USD"
2828
assert isinstance(price.timestamp, datetime)
2929

30-
def test_get_multiple_prices(self, live_client):
30+
def test_get_multiple_prices(self, live_client, live_call):
3131
"""Test getting multiple prices."""
3232
commodities = ["BRENT_CRUDE_USD", "WTI_USD", "NATURAL_GAS_USD"]
33-
prices = live_client.prices.get_multiple(commodities)
33+
prices = live_call(live_client.prices.get_multiple, commodities)
3434

3535
assert len(prices) >= 1 # At least some should succeed
3636
for price in prices:
3737
assert price.value > 0
3838
assert price.commodity in commodities
3939

40-
def test_get_historical_data(self, live_client):
40+
def test_get_historical_data(self, live_client, live_call):
4141
"""Test getting historical data."""
4242
end_date = datetime.now()
4343
start_date = end_date - timedelta(days=7)
4444

45-
history = live_client.historical.get(
45+
history = live_call(
46+
live_client.historical.get,
4647
commodity="BRENT_CRUDE_USD",
4748
start_date=start_date.strftime("%Y-%m-%d"),
4849
end_date=end_date.strftime("%Y-%m-%d"),
@@ -55,10 +56,10 @@ def test_get_historical_data(self, live_client):
5556
assert price.value > 0
5657
assert price.commodity == "BRENT_CRUDE_USD"
5758

58-
def test_invalid_commodity(self, live_client):
59+
def test_invalid_commodity(self, live_client, live_call):
5960
"""Test handling of invalid commodity."""
6061
with pytest.raises(DataNotFoundError):
61-
live_client.prices.get("TOTALLY_INVALID_COMMODITY_XYZ")
62+
live_call(live_client.prices.get, "TOTALLY_INVALID_COMMODITY_XYZ")
6263

6364
@pytest.mark.skip(reason="API currently doesn't validate API keys for read operations")
6465
def test_invalid_api_key(self):
@@ -70,21 +71,22 @@ def test_invalid_api_key(self):
7071
with pytest.raises(AuthenticationError):
7172
bad_client.prices.get("BRENT_CRUDE_USD")
7273

73-
def test_context_manager(self, api_key):
74+
def test_context_manager(self, api_key, live_call):
7475
"""Test client works as context manager."""
7576
with OilPriceAPI(api_key=api_key) as client:
76-
price = client.prices.get("BRENT_CRUDE_USD")
77+
price = live_call(client.prices.get, "BRENT_CRUDE_USD")
7778
assert price is not None
7879

7980

8081
@pytest.mark.slow
8182
class TestLiveAPIPerformance:
8283
"""Performance tests (marked slow)."""
8384

84-
def test_pagination_performance(self, live_client):
85+
def test_pagination_performance(self, live_client, live_call):
8586
"""Test pagination doesn't cause issues."""
8687
# Get a reasonable amount of data
87-
history = live_client.historical.get(
88+
history = live_call(
89+
live_client.historical.get,
8890
commodity="BRENT_CRUDE_USD",
8991
start_date="2024-01-01",
9092
end_date="2024-01-31",
@@ -97,10 +99,11 @@ def test_pagination_performance(self, live_client):
9799
not os.getenv("RUN_EXPENSIVE_TESTS"),
98100
reason="Expensive test - set RUN_EXPENSIVE_TESTS=1 to run"
99101
)
100-
def test_get_all_historical_large_dataset(self, live_client):
102+
def test_get_all_historical_large_dataset(self, live_client, live_call):
101103
"""Test get_all with large dataset (expensive)."""
102104
# This could use many API calls
103-
all_data = live_client.historical.get_all(
105+
all_data = live_call(
106+
live_client.historical.get_all,
104107
commodity="BRENT_CRUDE_USD",
105108
start_date="2024-01-01",
106109
end_date="2024-02-01",

0 commit comments

Comments
 (0)