2424from abc import ABC
2525from datetime import datetime , timedelta
2626from enum import StrEnum
27- from typing import Any , Self , Callable , Coroutine
27+ from typing import Any , Self , Callable , Coroutine , TypeAlias
2828from dataclasses import dataclass , field
2929
3030import httpx
3131from loguru import logger
3232
33- # Type Aliases
34- type AccessToken = str
35- type ProfileID = str
36- type JSONData = dict [str , Any ]
37- type JSONList = list [JSONData ]
33+ AccessToken : TypeAlias = str
34+ ProfileID : TypeAlias = str
35+ JSONData : TypeAlias = dict [str , Any ]
36+ JSONList : TypeAlias = list [JSONData ]
3837
3938
4039class AdsRegion (StrEnum ):
@@ -60,16 +59,16 @@ def __str__(self) -> str:
6059class AsyncTokenManager :
6160 """
6261 异步 Token 管理器(线程安全单例)
63-
62+
6463 所有 API 模块共享同一个 token,避免重复刷新
6564 使用 asyncio.Lock 保证并发安全
6665 """
67-
66+
6867 TOKEN_URL = "https://api.amazon.com/auth/o2/token"
69-
68+
7069 _instances : dict [str , "AsyncTokenManager" ] = {}
7170 _lock = asyncio .Lock ()
72-
71+
7372 def __new__ (cls , client_id : str , client_secret : str , refresh_token : str , timeout : int = 30 ):
7473 """根据 refresh_token 创建或获取单例"""
7574 key = f"{ client_id } :{ refresh_token [:20 ]} "
@@ -78,11 +77,11 @@ def __new__(cls, client_id: str, client_secret: str, refresh_token: str, timeout
7877 instance ._initialized = False
7978 cls ._instances [key ] = instance
8079 return cls ._instances [key ]
81-
80+
8281 def __init__ (self , client_id : str , client_secret : str , refresh_token : str , timeout : int = 30 ):
8382 if getattr (self , "_initialized" , False ):
8483 return
85-
84+
8685 self .client_id = client_id
8786 self .client_secret = client_secret
8887 self .refresh_token = refresh_token
@@ -91,7 +90,7 @@ def __init__(self, client_id: str, client_secret: str, refresh_token: str, timeo
9190 self ._token_expires_at : datetime | None = None
9291 self ._token_lock = asyncio .Lock ()
9392 self ._initialized = True
94-
93+
9594 async def get_access_token (self ) -> AccessToken :
9695 """获取有效的 Access Token(异步,自动刷新)"""
9796 # 快速路径:token 有效时直接返回
@@ -101,7 +100,7 @@ async def get_access_token(self) -> AccessToken:
101100 and datetime .now () < self ._token_expires_at
102101 ):
103102 return self ._access_token
104-
103+
105104 # 慢路径:需要刷新 token(加锁)
106105 async with self ._token_lock :
107106 # 双重检查
@@ -112,7 +111,7 @@ async def get_access_token(self) -> AccessToken:
112111 ):
113112 return self ._access_token
114113 return await self ._refresh_access_token ()
115-
114+
116115 async def _refresh_access_token (self ) -> AccessToken :
117116 """刷新 Access Token(调用前需持有锁)"""
118117 async with httpx .AsyncClient (timeout = self .timeout ) as client :
@@ -145,14 +144,14 @@ async def _refresh_access_token(self) -> AccessToken:
145144class BaseAdsClient (ABC ):
146145 """
147146 Amazon Ads API 异步基础客户端
148-
147+
149148 特性:
150149 - 全异步设计(async/await)
151150 - HTTP/2 支持(连接复用,更低延迟)
152151 - 自动重试(指数退避)
153152 - Rate Limit 处理(自动等待)
154153 - 共享连接池
155-
154+
156155 使用方法:
157156 client = MyAPIClient(...)
158157 result = await client.get("/endpoint")
@@ -184,7 +183,7 @@ def __init__(
184183
185184 # 使用共享的异步 TokenManager
186185 self ._token_manager = AsyncTokenManager (client_id , client_secret , refresh_token , timeout )
187-
186+
188187 # 延迟初始化的 httpx 客户端
189188 self ._client : httpx .AsyncClient | None = None
190189 self ._client_lock = asyncio .Lock ()
@@ -232,7 +231,7 @@ async def _get_access_token(self) -> AccessToken:
232231
233232 async def _get_headers (self , content_type : str | None = None , method : str = "POST" ) -> dict [str , str ]:
234233 """构建请求头
235-
234+
236235 Args:
237236 content_type: 自定义 Content-Type(用于 API v3)
238237 method: HTTP方法(GET/DELETE请求不添加Content-Type)
@@ -242,12 +241,12 @@ async def _get_headers(self, content_type: str | None = None, method: str = "POS
242241 "Authorization" : f"Bearer { token } " ,
243242 "Amazon-Advertising-API-ClientId" : self .client_id ,
244243 }
245-
244+
246245 # GET和DELETE请求不需要Content-Type(因为没有body)
247246 # 添加Content-Type可能导致Amazon API返回403错误
248247 if method not in ("GET" , "DELETE" , "HEAD" ):
249248 headers ["Content-Type" ] = content_type or "application/json"
250-
249+
251250 if content_type :
252251 headers ["Accept" ] = content_type
253252 if self .profile_id :
@@ -266,7 +265,7 @@ async def _request(
266265 accept : str | None = None ,
267266 ) -> JSONData | JSONList :
268267 """执行异步 HTTP 请求(带自动重试)
269-
268+
270269 Args:
271270 method: HTTP方法
272271 endpoint: API端点
@@ -280,9 +279,9 @@ async def _request(
280279 if accept :
281280 headers ["Accept" ] = accept
282281 client = await self ._get_client ()
283-
282+
284283 last_error : Exception | None = None
285-
284+
286285 for attempt in range (self .max_retries + 1 ):
287286 try :
288287 response = await client .request (
@@ -292,21 +291,21 @@ async def _request(
292291 params = params ,
293292 json = json_data ,
294293 )
295-
294+
296295 # Rate Limit 处理
297296 if response .status_code == 429 :
298297 retry_after = int (response .headers .get ("Retry-After" , 5 ))
299298 logger .warning (f"Rate limited, waiting { retry_after } s (attempt { attempt + 1 } )" )
300299 await asyncio .sleep (retry_after )
301300 continue
302-
301+
303302 # 服务器错误,重试
304303 if response .status_code >= 500 :
305304 wait_time = min (2 ** attempt , 30 ) # 指数退避,最大30秒
306305 logger .warning (f"Server error { response .status_code } , retrying in { wait_time } s" )
307306 await asyncio .sleep (wait_time )
308307 continue
309-
308+
310309 # 客户端错误,不重试
311310 if not response .is_success :
312311 error_detail = response .json () if response .text else {}
@@ -315,25 +314,25 @@ async def _request(
315314 message = f"API request failed: { endpoint } " ,
316315 details = error_detail ,
317316 )
318-
317+
319318 # 成功
320319 if response .status_code == 204 :
321320 return {}
322-
321+
323322 return response .json ()
324-
323+
325324 except httpx .TimeoutException as e :
326325 last_error = e
327326 wait_time = min (2 ** attempt , 30 )
328327 logger .warning (f"Request timeout, retrying in { wait_time } s (attempt { attempt + 1 } )" )
329328 await asyncio .sleep (wait_time )
330-
329+
331330 except httpx .RequestError as e :
332331 last_error = e
333332 wait_time = min (2 ** attempt , 30 )
334333 logger .warning (f"Request error: { e } , retrying in { wait_time } s" )
335334 await asyncio .sleep (wait_time )
336-
335+
337336 # 所有重试都失败
338337 raise AmazonAdsError (
339338 status_code = 0 ,
@@ -380,16 +379,16 @@ async def delete(self, endpoint: str) -> JSONData:
380379 async def download_report (self , url : str ) -> JSONList :
381380 """异步下载并解压报告"""
382381 client = await self ._get_client ()
383-
382+
384383 try :
385384 response = await client .get (url , timeout = 60.0 )
386385 if not response .is_success :
387386 return []
388-
387+
389388 # 解压 gzip 数据
390389 decompressed = gzip .decompress (response .content )
391390 return json .loads (decompressed )
392-
391+
393392 except Exception as e :
394393 logger .error (f"Failed to download/decompress report: { e } " )
395394 return []
@@ -403,24 +402,24 @@ async def parallel_execute(
403402 ) -> list [Any ]:
404403 """
405404 并行执行多个异步任务(带并发限制)
406-
405+
407406 Args:
408407 tasks: 协程列表
409408 max_concurrent: 最大并发数
410-
409+
411410 Returns:
412411 结果列表(按任务顺序)
413-
412+
414413 Example:
415414 tasks = [client.get(f"/campaigns/{id}") for id in campaign_ids]
416415 results = await client.parallel_execute(tasks, max_concurrent=5)
417416 """
418417 semaphore = asyncio .Semaphore (max_concurrent )
419-
418+
420419 async def limited_task (coro : Coroutine ) -> Any :
421420 async with semaphore :
422421 return await coro
423-
422+
424423 return await asyncio .gather (
425424 * [limited_task (task ) for task in tasks ],
426425 return_exceptions = True ,
@@ -433,25 +432,25 @@ async def parallel_paginate(
433432 ) -> list :
434433 """
435434 并行分页获取数据
436-
435+
437436 第一阶段:串行获取所有页面的 next_token
438437 第二阶段:并行获取所有页面数据(如果需要)
439-
438+
440439 Args:
441440 fetch_page: 获取单页数据的异步函数,参数为 next_token,返回 (items, next_token)
442441 max_workers: 最大并行数
443-
442+
444443 Returns:
445444 所有数据列表
446445 """
447446 all_items = []
448447 next_token = None
449-
448+
450449 while True :
451450 items , next_token = await fetch_page (next_token )
452451 all_items .extend (items )
453-
452+
454453 if not next_token :
455454 break
456-
455+
457456 return all_items
0 commit comments