11"""HTTP client for RHI API."""
22
3+ import logging
34from pathlib import Path
45from typing import Dict , List , Optional , Tuple , Union
56
67import httpx
8+ from tenacity import (
9+ retry ,
10+ stop_after_attempt ,
11+ wait_exponential ,
12+ retry_if_exception_type ,
13+ before_sleep_log ,
14+ )
715
816from .models import Post , Tag , PostDetails , UserProfile
917from .parser import PostParser , SidebarParser , PostDetailsParser , UserProfileParser
1018
19+ logger = logging .getLogger (__name__ )
20+
21+
22+ class RateLimitError (Exception ):
23+ """Raised when the server returns a 429 Too Many Requests response."""
24+ pass
25+
26+
27+ def _check_rate_limit (response : httpx .Response ) -> httpx .Response :
28+ """Check response for rate limiting and raise if detected."""
29+ if response .status_code == 429 :
30+ raise RateLimitError (f"Rate limited: { response .status_code } " )
31+ return response
32+
1133DEFAULT_BASE_URL = "https://rule34.xxx/index.php"
1234DEFAULT_POSTS_PER_PAGE = 42
1335
@@ -27,6 +49,7 @@ def __init__(
2749 timeout : float = 30.0 ,
2850 posts_per_page : int = DEFAULT_POSTS_PER_PAGE ,
2951 headers : Optional [Dict [str , str ]] = None ,
52+ max_retries : int = 5 ,
3053 ):
3154 """Initialize the client.
3255
@@ -35,12 +58,14 @@ def __init__(
3558 timeout: Request timeout in seconds.
3659 posts_per_page: Number of posts per page (for pagination offset).
3760 headers: Custom headers to use (merges with defaults).
61+ max_retries: Maximum number of retry attempts for rate-limited requests.
3862 """
3963 self .base_url = base_url .rstrip ("/" )
4064 self .posts_per_page = posts_per_page
4165 self ._timeout = timeout
4266 self ._headers = {** DEFAULT_HEADERS , ** (headers or {})}
4367 self ._client : Optional [httpx .Client ] = None
68+ self ._max_retries = max_retries
4469
4570 @property
4671 def client (self ) -> httpx .Client :
@@ -49,6 +74,21 @@ def client(self) -> httpx.Client:
4974 self ._client = httpx .Client (timeout = self ._timeout , headers = self ._headers )
5075 return self ._client
5176
77+ def _get (self , url : str , params : Dict = None ) -> httpx .Response :
78+ """Execute GET request with retry logic for rate limits."""
79+ @retry (
80+ stop = stop_after_attempt (self ._max_retries ),
81+ wait = wait_exponential (multiplier = 1 , min = 1.0 , max = 60.0 ),
82+ retry = retry_if_exception_type ((RateLimitError , httpx .TransportError )),
83+ before_sleep = before_sleep_log (logger , logging .WARNING ),
84+ reraise = True ,
85+ )
86+ def _request () -> httpx .Response :
87+ response = self .client .get (url , params = params )
88+ return _check_rate_limit (response )
89+
90+ return _request ()
91+
5292 def close (self ) -> None :
5393 """Close the HTTP client."""
5494 if self ._client :
@@ -74,7 +114,7 @@ def get_posts(self, tags: str = "", page: int = 1) -> Tuple[List[Post], List[Tag
74114 offset = (page - 1 ) * self .posts_per_page
75115 params = {"page" : "post" , "s" : "list" , "tags" : tags , "pid" : offset }
76116
77- response = self .client . get (self .base_url , params = params )
117+ response = self ._get (self .base_url , params = params )
78118 response .raise_for_status ()
79119
80120 html = response .text
@@ -96,14 +136,14 @@ def get_sidebar_tags(self, tags: str = "") -> List[Tag]:
96136 def get_post_details (self , post_id : int ) -> Optional [PostDetails ]:
97137 """Fetch detailed info for a specific post."""
98138 params = {"page" : "post" , "s" : "view" , "id" : post_id }
99- response = self .client . get (self .base_url , params = params )
139+ response = self ._get (self .base_url , params = params )
100140 response .raise_for_status ()
101141 return PostDetailsParser .parse_html (response .text )
102142
103143 def get_user_profile (self , username : str ) -> Optional [UserProfile ]:
104144 """Fetch user profile by username."""
105145 params = {"page" : "account" , "s" : "profile" , "uname" : username }
106- response = self .client . get (self .base_url , params = params )
146+ response = self ._get (self .base_url , params = params )
107147 response .raise_for_status ()
108148 return UserProfileParser .parse_html (response .text , self .base_url )
109149
@@ -170,6 +210,7 @@ def __init__(
170210 timeout : float = 30.0 ,
171211 posts_per_page : int = DEFAULT_POSTS_PER_PAGE ,
172212 headers : Optional [Dict [str , str ]] = None ,
213+ max_retries : int = 5 ,
173214 ):
174215 """Initialize the async client.
175216
@@ -178,12 +219,14 @@ def __init__(
178219 timeout: Request timeout in seconds.
179220 posts_per_page: Number of posts per page.
180221 headers: Custom headers to use.
222+ max_retries: Maximum number of retry attempts for rate-limited requests.
181223 """
182224 self .base_url = base_url .rstrip ("/" )
183225 self .posts_per_page = posts_per_page
184226 self ._timeout = timeout
185227 self ._headers = {** DEFAULT_HEADERS , ** (headers or {})}
186228 self ._client : Optional [httpx .AsyncClient ] = None
229+ self ._max_retries = max_retries
187230
188231 @property
189232 def client (self ) -> httpx .AsyncClient :
@@ -192,6 +235,21 @@ def client(self) -> httpx.AsyncClient:
192235 self ._client = httpx .AsyncClient (timeout = self ._timeout , headers = self ._headers )
193236 return self ._client
194237
238+ async def _get (self , url : str , params : Dict = None ) -> httpx .Response :
239+ """Execute GET request with retry logic for rate limits."""
240+ @retry (
241+ stop = stop_after_attempt (self ._max_retries ),
242+ wait = wait_exponential (multiplier = 1 , min = 1.0 , max = 60.0 ),
243+ retry = retry_if_exception_type ((RateLimitError , httpx .TransportError )),
244+ before_sleep = before_sleep_log (logger , logging .WARNING ),
245+ reraise = True ,
246+ )
247+ async def _request () -> httpx .Response :
248+ response = await self .client .get (url , params = params )
249+ return _check_rate_limit (response )
250+
251+ return await _request ()
252+
195253 async def close (self ) -> None :
196254 """Close the HTTP client."""
197255 if self ._client :
@@ -209,7 +267,7 @@ async def get_posts(self, tags: str = "", page: int = 1) -> Tuple[List[Post], Li
209267 offset = (page - 1 ) * self .posts_per_page
210268 params = {"page" : "post" , "s" : "list" , "tags" : tags , "pid" : offset }
211269
212- response = await self .client . get (self .base_url , params = params )
270+ response = await self ._get (self .base_url , params = params )
213271 response .raise_for_status ()
214272
215273 html = response .text
@@ -226,14 +284,14 @@ async def search(self, tags: str, page: int = 1) -> List[Post]:
226284 async def get_post_details (self , post_id : int ) -> Optional [PostDetails ]:
227285 """Fetch detailed info for a specific post."""
228286 params = {"page" : "post" , "s" : "view" , "id" : post_id }
229- response = await self .client . get (self .base_url , params = params )
287+ response = await self ._get (self .base_url , params = params )
230288 response .raise_for_status ()
231289 return PostDetailsParser .parse_html (response .text )
232290
233291 async def get_user_profile (self , username : str ) -> Optional [UserProfile ]:
234292 """Fetch user profile by username."""
235293 params = {"page" : "account" , "s" : "profile" , "uname" : username }
236- response = await self .client . get (self .base_url , params = params )
294+ response = await self ._get (self .base_url , params = params )
237295 response .raise_for_status ()
238296 return UserProfileParser .parse_html (response .text , self .base_url )
239297
0 commit comments