Skip to content

Commit f729b14

Browse files
✨ Reuse existing impression share reports to avoid daily limit (v0.1.6)
- Add find_existing_report() with 1-day tolerance for date matching - Reuse existing COMPLETED reports instead of creating new ones - Fix list_reports() to not pass unsupported pagination params - Add ASA_DEBUG env var for request logging Apple limits impression share to 10 reports/day, so reuse is critical.
1 parent 7c21b7a commit f729b14

4 files changed

Lines changed: 127 additions & 19 deletions

File tree

asa_api_client/resources/base.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
"""
66

77
import asyncio
8+
import os
9+
import sys
810
import time
911
from collections.abc import AsyncIterator, Iterator
1012
from typing import TYPE_CHECKING, Any, Generic, TypeVar
@@ -41,6 +43,9 @@
4143
DEFAULT_BACKOFF_FACTOR = 2.0
4244
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
4345

46+
# Request counter for debugging
47+
_request_count = 0
48+
4449

4550
class BaseResource(Generic[T, CreateT, UpdateT]):
4651
"""Base class for API resources.
@@ -265,9 +270,17 @@ def _request(
265270
Raises:
266271
AppleSearchAdsError: If the request fails after all retries.
267272
"""
273+
global _request_count
274+
_request_count += 1
275+
268276
url = self._build_url(path)
269277
headers = self._get_headers()
270278

279+
# Show request info if ASA_DEBUG is set
280+
if os.environ.get("ASA_DEBUG"):
281+
short_url = url.replace("https://api.searchads.apple.com/api/v5/", "")
282+
print(f"[{_request_count}] {method} {short_url}", file=sys.stderr)
283+
271284
logger.debug("%s %s", method, url)
272285

273286
last_exception: AppleSearchAdsError | None = None
@@ -305,6 +318,10 @@ def _request(
305318
max_retries + 1,
306319
delay,
307320
)
321+
# Print visible retry message to stderr
322+
msg = f"⏳ Rate limited ({response.status_code}), "
323+
msg += f"attempt {attempt + 1}/{max_retries + 1}, retrying in {delay:.0f}s..."
324+
print(msg, file=sys.stderr)
308325
time.sleep(delay)
309326
continue
310327

asa_api_client/resources/custom_reports.py

Lines changed: 108 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import builtins
88
import csv
99
import io
10+
import os
1011
import sys
1112
import time
1213
from datetime import date
@@ -171,6 +172,12 @@ def _request(
171172
"""
172173
url = self._build_url(path)
173174
headers = self._get_headers()
175+
176+
# Show request info if ASA_DEBUG is set
177+
if os.environ.get("ASA_DEBUG"):
178+
short_url = url.replace("https://api.searchads.apple.com/api/v5/", "")
179+
print(f"[custom-reports] {method} {short_url}", file=sys.stderr)
180+
174181
logger.debug("%s %s", method, url)
175182

176183
last_exception: AppleSearchAdsError | None = None
@@ -284,40 +291,103 @@ async def _request_async(
284291
raise last_exception
285292
raise NetworkError("Request failed after all retries")
286293

287-
def list_reports(
288-
self, limit: int = 100, offset: int = 0
289-
) -> "builtins.list[ImpressionShareReport]":
294+
def list_reports(self) -> "builtins.list[ImpressionShareReport]":
290295
"""List all impression share reports.
291296
292-
Args:
293-
limit: Maximum number of reports to return.
294-
offset: Number of reports to skip.
295-
296297
Returns:
297298
List of impression share reports.
298299
"""
299-
params = {"limit": limit, "offset": offset}
300-
data = self._request("GET", "", params=params)
300+
# Apple's custom-reports endpoint doesn't support pagination params
301+
data = self._request("GET", "")
301302

302303
reports: builtins.list[ImpressionShareReport] = []
303304
for item in data.get("data", []):
304305
reports.append(ImpressionShareReport.model_validate(item))
305306
return reports
306307

307-
async def list_reports_async(
308-
self, limit: int = 100, offset: int = 0
309-
) -> "builtins.list[ImpressionShareReport]":
310-
"""List all impression share reports asynchronously.
308+
def find_existing_report(
309+
self,
310+
start_date: date,
311+
end_date: date,
312+
granularity: GranularityType = GranularityType.DAILY,
313+
) -> ImpressionShareReport | None:
314+
"""Find an existing completed report covering the requested date range.
315+
316+
Apple limits impression share reports to 10 per day. This method
317+
helps avoid creating duplicate reports by finding existing ones.
318+
319+
A report is reusable if:
320+
1. Exact match: covers the full requested range, OR
321+
2. Close match: starts on/before our start AND ends within 1 day of our end
322+
(useful when today just rolled over but data isn't available yet)
311323
312324
Args:
313-
limit: Maximum number of reports to return.
314-
offset: Number of reports to skip.
325+
start_date: Report start date.
326+
end_date: Report end date.
327+
granularity: DAILY or WEEKLY.
328+
329+
Returns:
330+
Best matching report if found, None otherwise.
331+
"""
332+
try:
333+
reports = self.list_reports()
334+
335+
# Find reports that cover our date range (with 1-day tolerance on end)
336+
candidates: builtins.list[tuple[ImpressionShareReport, int]] = []
337+
for report in reports:
338+
if report.state != "COMPLETED":
339+
continue
340+
if report.granularity != granularity.value:
341+
continue
342+
343+
# Parse report dates
344+
if not report.start_time or not report.end_time:
345+
continue
346+
try:
347+
report_start = date.fromisoformat(report.start_time)
348+
report_end = date.fromisoformat(report.end_time)
349+
except (ValueError, TypeError):
350+
continue
351+
352+
# Check start date coverage
353+
if report_start > start_date:
354+
continue
355+
356+
# Check end date coverage (allow 1-day tolerance for day rollover)
357+
days_short = (end_date - report_end).days
358+
if days_short > 1: # More than 1 day short, skip
359+
continue
360+
361+
# Score: prefer exact matches (0 days short) over close matches
362+
candidates.append((report, days_short))
363+
364+
if not candidates:
365+
return None
366+
367+
# Sort by: 1) days short (prefer 0), 2) range size (prefer smaller)
368+
def sort_key(
369+
item: tuple[ImpressionShareReport, int],
370+
) -> tuple[int, int]:
371+
report, days_short = item
372+
# These are guaranteed non-None since we filtered above
373+
r_start = date.fromisoformat(report.start_time) # type: ignore[arg-type]
374+
r_end = date.fromisoformat(report.end_time) # type: ignore[arg-type]
375+
range_size = (r_end - r_start).days
376+
return (days_short, range_size)
377+
378+
best = min(candidates, key=sort_key)
379+
return best[0]
380+
except Exception:
381+
return None
382+
383+
async def list_reports_async(self) -> "builtins.list[ImpressionShareReport]":
384+
"""List all impression share reports asynchronously.
315385
316386
Returns:
317387
List of impression share reports.
318388
"""
319-
params = {"limit": limit, "offset": offset}
320-
data = await self._request_async("GET", "", params=params)
389+
# Apple's custom-reports endpoint doesn't support pagination params
390+
data = await self._request_async("GET", "")
321391

322392
reports: builtins.list[ImpressionShareReport] = []
323393
for item in data.get("data", []):
@@ -635,12 +705,17 @@ def get_impression_share(
635705
country_codes: builtins.list[str] | None = None,
636706
poll_interval: float = 2.0,
637707
timeout: float = 300.0,
708+
reuse_existing: bool = True,
638709
) -> ImpressionShareReport:
639710
"""Create an impression share report and wait for results.
640711
641712
This is a convenience method that creates a report and polls
642713
until it's complete.
643714
715+
NOTE: Apple limits impression share reports to 10 per day. By default,
716+
this method will check for an existing report with matching date range
717+
and reuse it to conserve your daily quota.
718+
644719
Args:
645720
start_date: Report start date (max 12 weeks ago).
646721
end_date: Report end date.
@@ -650,6 +725,7 @@ def get_impression_share(
650725
country_codes: Optional list of country codes to filter by.
651726
poll_interval: Seconds between status checks.
652727
timeout: Maximum seconds to wait.
728+
reuse_existing: If True, reuse existing report with same date range.
653729
654730
Returns:
655731
The completed report with impression share data.
@@ -669,6 +745,21 @@ def get_impression_share(
669745
share = f"{row.low_impression_share}-{row.high_impression_share}%"
670746
print(f"{keyword}: {share}")
671747
"""
748+
# Check for existing report to avoid hitting daily limit
749+
if reuse_existing and not country_codes:
750+
existing = self.find_existing_report(start_date, end_date, granularity)
751+
if existing:
752+
logger.info(f"Reusing existing report {existing.id}")
753+
if os.environ.get("ASA_DEBUG"):
754+
print(
755+
f"[custom-reports] Reusing existing report {existing.id}",
756+
file=sys.stderr,
757+
)
758+
# Download data if needed
759+
if existing.download_uri and not existing.row:
760+
existing.row = self._download_csv(existing.download_uri)
761+
return existing
762+
672763
report = self.create_impression_share(
673764
start_date=start_date,
674765
end_date=end_date,

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 = "asa-api-client"
7-
version = "0.1.5"
7+
version = "0.1.6"
88
description = "A modern Python client for the Apple Search Ads API with full type safety and async support"
99
readme = "README.md"
1010
license = "MIT"

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)