1
+ """HTTP client for FrontApp tap with rate limit handling."""
2
+
1
3
import json
2
4
import time
3
5
import requests
7
9
8
10
LOGGER = singer .get_logger ()
9
11
12
+
10
13
class RateLimitException (Exception ):
11
- pass
14
+ """Exception raised when rate limit is exceeded."""
15
+
12
16
13
17
class MetricsRateLimitException (Exception ):
14
- pass
18
+ """Exception raised for 423 locked error specific to metrics."""
19
+
15
20
16
- class Client (object ):
17
- BASE_URL = 'https://api2.frontapp.com'
21
+ class Client :
22
+ """Client to make authenticated requests to the FrontApp API."""
23
+ BASE_URL = "https://api2.frontapp.com"
18
24
19
25
def __init__ (self , config ):
20
- self .token = ' Bearer ' + config .get (' token' )
26
+ self .token = " Bearer " + config .get (" token" )
21
27
self .session = requests .Session ()
22
28
self .calls_remaining = None
23
29
self .limit_reset = None
24
30
25
31
def url (self , path ):
32
+ """Return full API URL."""
26
33
return self .BASE_URL + path
27
34
28
- # Log backoff retries
29
- def log_backoff (details ):
30
- LOGGER .warning (f"[Backoff] Retrying { details ['target' ].__name__ } in { details ['wait' ]} s due to { details ['exception' ]} " )
31
-
32
- # Retry strategy: Max 5 retries or 60 seconds total
33
- @backoff .on_exception (backoff .expo ,
34
- RateLimitException ,
35
- max_tries = 5 ,
36
- max_time = 60 ,
37
- on_backoff = log_backoff )
35
+ def log_backoff (self , details ):
36
+ """
37
+ Logs backoff retry information.
38
+
39
+ Args:
40
+ details (dict): Backoff details including 'wait' and 'value'.
41
+ """
42
+ wait_time = details .get ("wait" , "unknown" )
43
+ reason = details .get ("value" , "unknown" )
44
+ LOGGER .warning ("Retrying request in %ss due to %s" , wait_time , reason )
45
+
46
+ @backoff .on_exception (
47
+ backoff .expo ,
48
+ RateLimitException ,
49
+ max_tries = 5 ,
50
+ max_time = 60 ,
51
+ on_backoff = lambda details : Client .log_backoff (Client , details ),
52
+ )
38
53
def request (self , method , url , ** kwargs ):
39
- if self .calls_remaining is not None and self .calls_remaining == 0 :
54
+ """Make an HTTP request with retry and rate limit handling."""
55
+ if self .calls_remaining == 0 and self .limit_reset :
40
56
wait = self .limit_reset - int (time .monotonic ())
41
57
if 0 < wait <= 300 :
42
58
time .sleep (wait )
43
59
44
- if 'headers' not in kwargs :
45
- kwargs ['headers' ] = {}
46
- if self .token :
47
- kwargs ['headers' ]['Authorization' ] = self .token
48
-
49
- kwargs ['headers' ]['Content-Type' ] = 'application/json'
60
+ kwargs .setdefault ("headers" , {})
61
+ kwargs ["headers" ]["Authorization" ] = self .token
62
+ kwargs ["headers" ]["Content-Type" ] = "application/json"
50
63
51
- if 'endpoint' in kwargs :
52
- endpoint = kwargs ['endpoint' ]
53
- del kwargs ['endpoint' ]
64
+ if "endpoint" in kwargs :
65
+ endpoint = kwargs .pop ("endpoint" )
54
66
with metrics .http_request_timer (endpoint ) as timer :
55
- response = requests .request (method , url , ** kwargs )
67
+ response = requests .request (method , url , timeout = 30 , ** kwargs )
56
68
timer .tags [metrics .Tag .http_status_code ] = response .status_code
57
69
else :
58
- response = requests .request (method , url , ** kwargs )
70
+ response = requests .request (method , url , timeout = 30 , ** kwargs )
59
71
60
- #Handled missing headers to avoid KeyError
61
- self .calls_remaining = int (response . headers . get ( 'X-Ratelimit-Remaining' , 1 ))
62
- self . limit_reset = int ( float ( response .headers .get (' X-Ratelimit-Reset' , time .monotonic () + 1 )))
72
+ self . calls_remaining = int ( response . headers . get ( "X-Ratelimit-Remaining" , 0 ))
73
+ self .limit_reset = int (float (
74
+ response .headers .get (" X-Ratelimit-Reset" , time .monotonic () + 60 )))
63
75
64
76
if response .status_code in [429 , 503 ]:
65
77
raise RateLimitException (response .text )
66
78
if response .status_code == 423 :
67
79
raise MetricsRateLimitException ()
80
+
68
81
try :
69
82
response .raise_for_status ()
70
- except :
71
- LOGGER .error ('{} - {}' . format ( response .status_code , response .text ) )
83
+ except requests . HTTPError :
84
+ LOGGER .error ("HTTP %s - %s" , response .status_code , response .text )
72
85
raise
73
86
74
87
return response
75
88
76
89
def get_report_metrics (self , url , ** kwargs ):
77
- response = self .request ('get' , url , ** kwargs )
78
- return response .json ().get ('metrics' , [])
90
+ """Fetch analytics metrics from a report endpoint."""
91
+ response = self .request ("get" , url , ** kwargs )
92
+ return response .json ().get ("metrics" , [])
79
93
80
94
def create_report (self , path , data , ** kwargs ):
95
+ """Create a report on FrontApp and return its URL."""
81
96
url = self .url (path )
82
- kwargs ['data' ] = json .dumps (data )
83
- response = self .request ('post' , url , ** kwargs )
84
- if response .json ().get ('_links' , {}).get ('self' ):
85
- return response .json ()['_links' ]['self' ]
86
- return {}
97
+ kwargs ["data" ] = json .dumps (data )
98
+ response = self .request ("post" , url , ** kwargs )
99
+ return response .json ().get ("_links" , {}).get ("self" , {})
87
100
88
101
def list_metrics (self , path , ** kwargs ):
102
+ """List available metrics from an endpoint."""
89
103
url = self .url (path )
90
- response = self .request (' get' , url , ** kwargs )
91
- return response .json ().get (' _results' , [])
104
+ response = self .request (" get" , url , ** kwargs )
105
+ return response .json ().get (" _results" , [])
0 commit comments