pararamio-aio 2.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. pararamio_aio/__init__.py +78 -0
  2. pararamio_aio/_core/__init__.py +125 -0
  3. pararamio_aio/_core/_types.py +120 -0
  4. pararamio_aio/_core/base.py +143 -0
  5. pararamio_aio/_core/client_protocol.py +90 -0
  6. pararamio_aio/_core/constants/__init__.py +7 -0
  7. pararamio_aio/_core/constants/base.py +9 -0
  8. pararamio_aio/_core/constants/endpoints.py +84 -0
  9. pararamio_aio/_core/cookie_decorator.py +208 -0
  10. pararamio_aio/_core/cookie_manager.py +1222 -0
  11. pararamio_aio/_core/endpoints.py +67 -0
  12. pararamio_aio/_core/exceptions/__init__.py +6 -0
  13. pararamio_aio/_core/exceptions/auth.py +91 -0
  14. pararamio_aio/_core/exceptions/base.py +124 -0
  15. pararamio_aio/_core/models/__init__.py +17 -0
  16. pararamio_aio/_core/models/base.py +66 -0
  17. pararamio_aio/_core/models/chat.py +92 -0
  18. pararamio_aio/_core/models/post.py +65 -0
  19. pararamio_aio/_core/models/user.py +54 -0
  20. pararamio_aio/_core/py.typed +2 -0
  21. pararamio_aio/_core/utils/__init__.py +73 -0
  22. pararamio_aio/_core/utils/async_requests.py +417 -0
  23. pararamio_aio/_core/utils/auth_flow.py +202 -0
  24. pararamio_aio/_core/utils/authentication.py +235 -0
  25. pararamio_aio/_core/utils/captcha.py +92 -0
  26. pararamio_aio/_core/utils/helpers.py +336 -0
  27. pararamio_aio/_core/utils/http_client.py +199 -0
  28. pararamio_aio/_core/utils/requests.py +424 -0
  29. pararamio_aio/_core/validators.py +78 -0
  30. pararamio_aio/_types.py +29 -0
  31. pararamio_aio/client.py +989 -0
  32. pararamio_aio/constants/__init__.py +16 -0
  33. pararamio_aio/cookie_manager.py +15 -0
  34. pararamio_aio/exceptions/__init__.py +31 -0
  35. pararamio_aio/exceptions/base.py +1 -0
  36. pararamio_aio/file_operations.py +232 -0
  37. pararamio_aio/models/__init__.py +32 -0
  38. pararamio_aio/models/activity.py +127 -0
  39. pararamio_aio/models/attachment.py +141 -0
  40. pararamio_aio/models/base.py +83 -0
  41. pararamio_aio/models/bot.py +274 -0
  42. pararamio_aio/models/chat.py +722 -0
  43. pararamio_aio/models/deferred_post.py +174 -0
  44. pararamio_aio/models/file.py +103 -0
  45. pararamio_aio/models/group.py +361 -0
  46. pararamio_aio/models/poll.py +275 -0
  47. pararamio_aio/models/post.py +643 -0
  48. pararamio_aio/models/team.py +403 -0
  49. pararamio_aio/models/user.py +239 -0
  50. pararamio_aio/py.typed +2 -0
  51. pararamio_aio/utils/__init__.py +18 -0
  52. pararamio_aio/utils/authentication.py +383 -0
  53. pararamio_aio/utils/requests.py +75 -0
  54. pararamio_aio-2.1.1.dist-info/METADATA +269 -0
  55. pararamio_aio-2.1.1.dist-info/RECORD +57 -0
  56. pararamio_aio-2.1.1.dist-info/WHEEL +5 -0
  57. pararamio_aio-2.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,417 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import mimetypes
7
+ from io import BytesIO
8
+ from json import JSONDecodeError
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Any, BinaryIO
11
+ from urllib.parse import quote
12
+
13
+ import aiohttp
14
+ from aiohttp import FormData
15
+
16
+ from ..constants import (
17
+ BASE_API_URL,
18
+ FILE_UPLOAD_URL,
19
+ UPLOAD_TIMEOUT,
20
+ VERSION,
21
+ )
22
+ from ..constants import (
23
+ REQUEST_TIMEOUT as TIMEOUT,
24
+ )
25
+ from ..exceptions import PararamioHTTPRequestException
26
+
27
+ if TYPE_CHECKING:
28
+ from .._types import HeaderLikeT
29
+
30
+ __all__ = (
31
+ 'api_request',
32
+ 'bot_request',
33
+ 'delete_file',
34
+ 'download_file',
35
+ 'raw_api_request',
36
+ 'upload_file',
37
+ 'xupload_file',
38
+ )
39
+ log = logging.getLogger('pararamio')
40
+ UA_HEADER = f'pararamio lib version {VERSION}'
41
+ DEFAULT_HEADERS = {
42
+ 'Content-type': 'application/json',
43
+ 'Accept': 'application/json',
44
+ 'User-agent': UA_HEADER,
45
+ }
46
+
47
+
48
+ async def bot_request(
49
+ session: aiohttp.ClientSession,
50
+ url: str,
51
+ key: str,
52
+ method: str = 'GET',
53
+ data: dict | None = None,
54
+ headers: dict | None = None,
55
+ timeout: int = TIMEOUT,
56
+ ):
57
+ """
58
+ Sends an async request to a bot API endpoint with the specified parameters.
59
+
60
+ Parameters:
61
+ session (aiohttp.ClientSession): The aiohttp session to use for the request.
62
+ url (str): The endpoint URL of the bot API.
63
+ key (str): The API token for authentication.
64
+ method (str): The HTTP method to use for the request. Defaults to 'GET'.
65
+ data (Optional[dict]): The data payload for the request. Defaults to None.
66
+ headers (Optional[dict]): Additional headers to include in the request. Defaults to None.
67
+ timeout (int): The timeout setting for the request. Defaults to TIMEOUT.
68
+
69
+ Returns:
70
+ Response object from the API request.
71
+ """
72
+ _headers = {'X-APIToken': key, **DEFAULT_HEADERS}
73
+ if headers:
74
+ _headers = {**_headers, **headers}
75
+ return await api_request(
76
+ session=session, url=url, method=method, data=data, headers=_headers, timeout=timeout
77
+ )
78
+
79
+
80
+ async def _base_request(
81
+ session: aiohttp.ClientSession,
82
+ url: str,
83
+ method: str = 'GET',
84
+ data: bytes | None = None,
85
+ headers: dict | None = None,
86
+ timeout: int = TIMEOUT,
87
+ ) -> aiohttp.ClientResponse:
88
+ """
89
+ Sends an async HTTP request and returns a ClientResponse object.
90
+
91
+ Parameters:
92
+ session (aiohttp.ClientSession): The aiohttp session to use for the request.
93
+ url (str): The URL endpoint to which the request is sent.
94
+ method (str): The HTTP method to use for the request (default is 'GET').
95
+ data (Optional[bytes]): The payload to include in the request body (default is None).
96
+ headers (Optional[dict]): A dictionary of headers to include in the request
97
+ (default is None).
98
+ timeout (int): The timeout for the request in seconds (TIMEOUT defines default).
99
+
100
+ Returns:
101
+ aiohttp.ClientResponse: The response object containing the server's response to the HTTP
102
+ request.
103
+
104
+ Raises:
105
+ PararamioHTTPRequestException: An exception is raised if there is
106
+ an issue with the HTTP request.
107
+ """
108
+ _url = f'{BASE_API_URL}{url}'
109
+ _headers = DEFAULT_HEADERS.copy()
110
+ if headers:
111
+ _headers.update(headers)
112
+
113
+ return await _make_request(session, _url, method, data, _headers, timeout)
114
+
115
+
116
+ async def _base_file_request(
117
+ session: aiohttp.ClientSession,
118
+ url: str,
119
+ method='GET',
120
+ data: bytes | FormData | None = None,
121
+ headers: HeaderLikeT | None = None,
122
+ timeout: int = TIMEOUT,
123
+ ) -> aiohttp.ClientResponse:
124
+ """
125
+ Performs an async file request to the specified URL with the given parameters.
126
+
127
+ Arguments:
128
+ session (aiohttp.ClientSession): The aiohttp session to use for the request.
129
+ url (str): The URL endpoint to send the request to.
130
+ method (str, optional): The HTTP method to use for the request (default is 'GET').
131
+ data (Optional[Union[bytes, FormData]], optional): The data to send in the request body
132
+ (default is None).
133
+ headers (Optional[HeaderLikeT], optional): The headers to include in the request
134
+ (default is None).
135
+ timeout (int, optional): The timeout duration for the request (default value is TIMEOUT).
136
+
137
+ Returns:
138
+ aiohttp.ClientResponse: The response object from the file request.
139
+
140
+ Raises:
141
+ PararamioHTTPRequestException: If the request fails with an HTTP error code.
142
+ """
143
+ _url = f'{FILE_UPLOAD_URL}{url}'
144
+ _headers = headers or {}
145
+
146
+ return await _make_request(session, _url, method, data, _headers, timeout)
147
+
148
+
149
+ async def _read_json_response(response: aiohttp.ClientResponse) -> dict:
150
+ """Read response content and parse as JSON."""
151
+ content = await response.read()
152
+ return json.loads(content)
153
+
154
+
155
+ async def _upload_with_form_data(
156
+ session: aiohttp.ClientSession,
157
+ url: str,
158
+ data: FormData,
159
+ headers: HeaderLikeT | None = None,
160
+ timeout: int = TIMEOUT,
161
+ ) -> dict:
162
+ """Upload form data and return JSON response."""
163
+ _headers = {
164
+ 'User-agent': UA_HEADER,
165
+ 'Accept': 'application/json',
166
+ **(headers or {}),
167
+ }
168
+
169
+ response = await _base_file_request(
170
+ session,
171
+ url,
172
+ method='POST',
173
+ data=data,
174
+ headers=_headers,
175
+ timeout=timeout,
176
+ )
177
+
178
+ return await _read_json_response(response)
179
+
180
+
181
+ async def upload_file(
182
+ session: aiohttp.ClientSession,
183
+ fp: BinaryIO,
184
+ perm: str,
185
+ filename: str | None = None,
186
+ file_type=None,
187
+ headers: HeaderLikeT | None = None,
188
+ timeout: int = UPLOAD_TIMEOUT,
189
+ ):
190
+ """
191
+ Upload a file to a pararam server asynchronously with specified permissions and optional
192
+ parameters.
193
+
194
+ Args:
195
+ session (aiohttp.ClientSession): The aiohttp session to use for the request.
196
+ fp (BinaryIO): A file-like object to be uploaded.
197
+ perm (str): The permission level for the uploaded file.
198
+ filename (Optional[str], optional): Optional filename used during upload. Defaults to None.
199
+ file_type (optional): Optional MIME type of the file. Defaults to None.
200
+ headers (Optional[HeaderLikeT], optional): Optional headers to include in the request.
201
+ Defaults to None.
202
+ timeout (int, optional): Timeout duration for the upload request.
203
+ Defaults to UPLOAD_TIMEOUT.
204
+
205
+ Returns:
206
+ dict: A dictionary containing the server's response to the file upload.
207
+ """
208
+ url = f'/upload/{perm}'
209
+
210
+ if not filename:
211
+ filename = Path(getattr(fp, 'name', 'file')).name
212
+ if not file_type:
213
+ file_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
214
+
215
+ data = FormData()
216
+ fp.seek(0)
217
+ data.add_field('file', fp, filename=filename, content_type=file_type)
218
+
219
+ return await _upload_with_form_data(session, url, data, headers, timeout)
220
+
221
+
222
+ async def xupload_file(
223
+ session: aiohttp.ClientSession,
224
+ fp: BinaryIO,
225
+ fields: list[tuple[str, str | None | int]],
226
+ filename: str | None = None,
227
+ content_type: str | None = None,
228
+ headers: HeaderLikeT | None = None,
229
+ timeout: int = UPLOAD_TIMEOUT,
230
+ ) -> dict:
231
+ """
232
+ Uploads a file asynchronously to a predefined URL using a multipart/form-data request.
233
+
234
+ Arguments:
235
+ - session: The aiohttp session to use for the request.
236
+ - fp: A binary file-like object to upload.
237
+ - fields: A list of tuples where each tuple contains a field name and a value which can be
238
+ a string, None, or an integer.
239
+ - filename: Optional; The name of the file being uploaded.
240
+ If not provided, it defaults to None.
241
+ - content_type: Optional; The MIME type of the file being uploaded.
242
+ If not provided, it defaults to None.
243
+ - headers: Optional; Additional headers to include in the upload request.
244
+ If not provided, defaults to None.
245
+ - timeout: Optional; The timeout in seconds for the request.
246
+ Defaults to UPLOAD_TIMEOUT.
247
+
248
+ Returns:
249
+ - A dictionary parsed from the JSON response of the upload request.
250
+ """
251
+ url = '/upload'
252
+
253
+ if not filename:
254
+ filename = Path(getattr(fp, 'name', 'file')).name
255
+ if not content_type:
256
+ content_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
257
+
258
+ data = FormData()
259
+
260
+ # Add form fields
261
+ for key, value in fields:
262
+ if value is not None:
263
+ data.add_field(key, str(value))
264
+
265
+ # Add file
266
+ fp.seek(0)
267
+ data.add_field('data', fp, filename=filename, content_type=content_type)
268
+
269
+ return await _upload_with_form_data(session, url, data, headers, timeout)
270
+
271
+
272
+ async def delete_file(
273
+ session: aiohttp.ClientSession,
274
+ guid: str,
275
+ headers: HeaderLikeT | None = None,
276
+ timeout: int = TIMEOUT,
277
+ ) -> dict:
278
+ url = f'/delete/{guid}'
279
+ response = await _base_file_request(
280
+ session, url, method='DELETE', headers=headers, timeout=timeout
281
+ )
282
+ return await _read_json_response(response)
283
+
284
+
285
+ async def download_file(
286
+ session: aiohttp.ClientSession,
287
+ guid: str,
288
+ filename: str,
289
+ headers: HeaderLikeT | None = None,
290
+ timeout: int = TIMEOUT,
291
+ ) -> BytesIO:
292
+ url = f'/download/{guid}/{quote(filename)}'
293
+ response = await file_request(session, url, method='GET', headers=headers, timeout=timeout)
294
+ content = await response.read()
295
+ return BytesIO(content)
296
+
297
+
298
+ async def file_request(
299
+ session: aiohttp.ClientSession,
300
+ url: str,
301
+ method='GET',
302
+ data: bytes | None = None,
303
+ headers: HeaderLikeT | None = None,
304
+ timeout: int = TIMEOUT,
305
+ ) -> aiohttp.ClientResponse:
306
+ _headers = DEFAULT_HEADERS.copy()
307
+ if headers:
308
+ _headers.update(headers)
309
+ return await _base_file_request(
310
+ session,
311
+ url,
312
+ method=method,
313
+ data=data,
314
+ headers=_headers,
315
+ timeout=timeout,
316
+ )
317
+
318
+
319
+ async def _make_request(
320
+ session: aiohttp.ClientSession,
321
+ url: str,
322
+ method: str = 'GET',
323
+ data: Any = None,
324
+ headers: HeaderLikeT | None = None,
325
+ timeout: int = TIMEOUT,
326
+ ) -> aiohttp.ClientResponse:
327
+ """
328
+ Common request handler for both API and file requests.
329
+
330
+ Args:
331
+ session: The aiohttp session to use for the request
332
+ url: The full URL to send the request to
333
+ method: The HTTP method to use
334
+ data: The data to send in the request body
335
+ headers: Headers to include in the request
336
+ timeout: Request timeout in seconds
337
+
338
+ Returns:
339
+ The response object from the request
340
+
341
+ Raises:
342
+ PararamioHTTPRequestException: If the request fails
343
+ """
344
+ log.debug('%s - %s - %s - %s', url, method, data, headers)
345
+
346
+ try:
347
+ timeout_obj = aiohttp.ClientTimeout(total=timeout)
348
+ async with session.request(
349
+ method, url, data=data, headers=headers or {}, timeout=timeout_obj
350
+ ) as response:
351
+ if response.status >= 400:
352
+ content = await response.read()
353
+ headers_list = list(response.headers.items())
354
+ raise PararamioHTTPRequestException(
355
+ url,
356
+ response.status,
357
+ response.reason or 'Unknown error',
358
+ headers_list,
359
+ BytesIO(content),
360
+ )
361
+ return response
362
+ except asyncio.TimeoutError as e:
363
+ log.exception('%s - %s - timeout', url, method)
364
+ raise PararamioHTTPRequestException(url, 408, 'Request Timeout', [], BytesIO()) from e
365
+ except aiohttp.ClientError as e:
366
+ log.exception('%s - %s', url, method)
367
+ raise PararamioHTTPRequestException(url, 0, str(e), [], BytesIO()) from e
368
+
369
+
370
+ async def raw_api_request(
371
+ session: aiohttp.ClientSession,
372
+ url: str,
373
+ method: str = 'GET',
374
+ data: bytes | None = None,
375
+ headers: HeaderLikeT | None = None,
376
+ timeout: int = TIMEOUT,
377
+ ) -> tuple[dict, list]:
378
+ response = await _base_request(session, url, method, data, headers, timeout)
379
+ if 200 <= response.status < 300:
380
+ content = await response.read()
381
+ headers_list = list(response.headers.items())
382
+ return json.loads(content), headers_list
383
+ return {}, []
384
+
385
+
386
+ async def api_request(
387
+ session: aiohttp.ClientSession,
388
+ url: str,
389
+ method: str = 'GET',
390
+ data: dict | None = None,
391
+ headers: HeaderLikeT | None = None,
392
+ timeout: int = TIMEOUT,
393
+ ) -> dict:
394
+ _data = None
395
+ if data is not None:
396
+ _data = json.dumps(data).encode('utf-8')
397
+
398
+ response = await _base_request(session, url, method, _data, headers, timeout)
399
+
400
+ if response.status == 204:
401
+ return {}
402
+
403
+ if 200 <= response.status < 500:
404
+ content = await response.read()
405
+ try:
406
+ return json.loads(content)
407
+ except JSONDecodeError as e:
408
+ log.exception('%s - %s', url, method)
409
+ headers_list = list(response.headers.items())
410
+ raise PararamioHTTPRequestException(
411
+ url,
412
+ response.status,
413
+ 'JSONDecodeError',
414
+ headers_list,
415
+ BytesIO(content),
416
+ ) from e
417
+ return {}
@@ -0,0 +1,202 @@
1
+ """Common authentication flow logic for sync and async clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import contextlib
8
+ import hashlib
9
+ import hmac
10
+ import time
11
+ from datetime import datetime
12
+
13
+ __all__ = (
14
+ 'AuthenticationFlow',
15
+ 'AuthenticationResult',
16
+ 'generate_otp',
17
+ )
18
+
19
+
20
+ def generate_otp(key: str) -> str:
21
+ """Generate one-time password from TOTP key.
22
+
23
+ Args:
24
+ key: TOTP secret key
25
+
26
+ Returns:
27
+ 6-digit OTP code
28
+
29
+ Raises:
30
+ binascii.Error: If key is invalid
31
+ """
32
+ digits = 6
33
+ digest = hashlib.sha1
34
+ interval = 30
35
+
36
+ def byte_secret(s):
37
+ missing_padding = len(s) % 8
38
+ if missing_padding != 0:
39
+ s += '=' * (8 - missing_padding)
40
+ return base64.b32decode(s, casefold=True)
41
+
42
+ def int_to_byte_string(i, padding=8):
43
+ result = bytearray()
44
+ while i != 0:
45
+ result.append(i & 0xFF)
46
+ i >>= 8
47
+ return bytes(bytearray(reversed(result)).rjust(padding, b'\0'))
48
+
49
+ def time_code(for_time):
50
+ i = time.mktime(for_time.timetuple())
51
+ return int(i / interval)
52
+
53
+ hmac_hash = hmac.new(
54
+ byte_secret(key),
55
+ int_to_byte_string(time_code(datetime.now())), # noqa: DTZ005
56
+ digest,
57
+ ).digest()
58
+
59
+ hmac_hash = bytearray(hmac_hash)
60
+ offset = hmac_hash[-1] & 0xF
61
+ code = (
62
+ (hmac_hash[offset] & 0x7F) << 24
63
+ | (hmac_hash[offset + 1] & 0xFF) << 16
64
+ | (hmac_hash[offset + 2] & 0xFF) << 8
65
+ | (hmac_hash[offset + 3] & 0xFF)
66
+ )
67
+ str_code = str(code % 10**digits)
68
+ while len(str_code) < digits:
69
+ str_code = '0' + str_code
70
+
71
+ return str_code
72
+
73
+
74
+ class AuthenticationResult:
75
+ """Result of authentication attempt."""
76
+
77
+ def __init__( # pylint: disable=too-many-positional-arguments
78
+ self,
79
+ success: bool,
80
+ xsrf_token: str | None = None,
81
+ user_id: int | None = None,
82
+ error: str | None = None,
83
+ error_type: str | None = None,
84
+ requires_captcha: bool = False,
85
+ requires_totp: bool = False,
86
+ ):
87
+ self.success = success
88
+ self.xsrf_token = xsrf_token
89
+ self.user_id = user_id
90
+ self.error = error
91
+ self.error_type = error_type
92
+ self.requires_captcha = requires_captcha
93
+ self.requires_totp = requires_totp
94
+
95
+
96
+ class AuthenticationFlow:
97
+ """Common authentication flow logic.
98
+
99
+ This class provides the core authentication flow that can be used
100
+ by both sync and async implementations.
101
+
102
+ Rate limits for login attempts:
103
+ - 3 attempts per minute
104
+ - 10 attempts per 30 minutes
105
+ """
106
+
107
+ @staticmethod
108
+ def prepare_login_data(email: str, password: str) -> dict[str, str]:
109
+ """Prepare login data payload.
110
+
111
+ Args:
112
+ email: User email
113
+ password: User password
114
+
115
+ Returns:
116
+ Dict with login data
117
+ """
118
+ return {'email': email, 'password': password}
119
+
120
+ @staticmethod
121
+ def prepare_totp_data(code: str) -> dict[str, str]:
122
+ """Prepare TOTP data payload.
123
+
124
+ Args:
125
+ code: 6-digit TOTP code or key
126
+
127
+ Returns:
128
+ Dict with TOTP data
129
+ """
130
+ # If it's longer than 6 chars, it's likely a key
131
+ if len(code) > 6:
132
+ with contextlib.suppress(ValueError, binascii.Error):
133
+ code = generate_otp(code)
134
+
135
+ return {'code': code}
136
+
137
+ @staticmethod
138
+ def parse_error_response(response: dict[str, any]) -> tuple[str, str]:
139
+ """Parse error from API response.
140
+
141
+ Args:
142
+ response: API response dict
143
+
144
+ Returns:
145
+ Tuple of (error_type, error_message)
146
+ """
147
+ # Check for various error formats
148
+ if 'error' in response:
149
+ return 'error', response['error']
150
+
151
+ if 'codes' in response:
152
+ codes = response['codes']
153
+ if isinstance(codes, dict):
154
+ # Check for specific error codes
155
+ if codes.get('non_field') == 'captcha_required':
156
+ return 'captcha_required', 'Captcha required'
157
+ # Return first error found
158
+ for field, error in codes.items():
159
+ if error:
160
+ return field, error
161
+
162
+ if 'message' in response:
163
+ return 'message', response['message']
164
+
165
+ return 'unknown', 'Unknown error'
166
+
167
+ @staticmethod
168
+ def should_retry_with_new_xsrf(error_type: str, error_message: str) -> bool: # noqa: ARG004 # pylint: disable=unused-argument
169
+ """Check if we should retry with a new XSRF token.
170
+
171
+ Args:
172
+ error_type: Type of error
173
+ error_message: Error message
174
+
175
+ Returns:
176
+ True if should retry
177
+ """
178
+ # Common XSRF-related errors
179
+ xsrf_errors = ['xsrf', 'csrf', 'token']
180
+ error_lower = error_message.lower()
181
+
182
+ return any(err in error_lower for err in xsrf_errors)
183
+
184
+ @staticmethod
185
+ def parse_rate_limit_info(headers: dict[str, str]) -> int | None:
186
+ """Parse rate limit information from response headers.
187
+
188
+ Args:
189
+ headers: Response headers
190
+
191
+ Returns:
192
+ Retry-after seconds if rate limited, None otherwise
193
+ """
194
+ retry_after = headers.get('Retry-After', headers.get('retry-after'))
195
+ if retry_after:
196
+ try:
197
+ return int(retry_after)
198
+ except ValueError:
199
+ # Default to 60 seconds if can't parse
200
+ return 60
201
+ # Default retry after for rate limits
202
+ return 60