1
1
import logging
2
2
import os
3
+ import random
3
4
import time
4
5
import uuid
5
- from typing import Optional
6
+ from functools import wraps
7
+ from typing import Callable , Optional
6
8
from urllib .parse import urlsplit , urlunsplit
7
9
8
10
import requests
9
11
from model import Detector , ImageQuery
10
- from openapi_client .api_client import ApiClient
12
+ from openapi_client .api_client import ApiClient , ApiException
11
13
12
14
from groundlight .status_codes import is_ok
13
15
@@ -67,9 +69,76 @@ def iq_is_confident(iq: ImageQuery, confidence_threshold: float) -> bool:
67
69
return iq .result .confidence >= confidence_threshold
68
70
69
71
70
- class InternalApiError (RuntimeError ):
71
- # TODO: We need a better exception hierarchy
72
- pass
72
+ class InternalApiError (ApiException , RuntimeError ):
73
+ # TODO: We should really avoid this double inheritance since
74
+ # both `ApiException` and `RuntimeError` are subclasses of
75
+ # `Exception`. Error handling might become more complex since
76
+ # the two super classes cross paths.
77
+ # pylint: disable=useless-super-delegation
78
+ def __init__ (self , status = None , reason = None , http_resp = None ):
79
+ super ().__init__ (status , reason , http_resp )
80
+
81
+
82
+ class RequestsRetryDecorator :
83
+ """
84
+ Decorate a function to retry sending HTTP requests.
85
+
86
+ Tries to re-execute the decorated function in case the execution
87
+ fails due to a server error (HTTP Error code 500 - 599).
88
+ Retry attempts are executed while exponentially backing off by a factor
89
+ of 2 with full jitter (picking a random delay time between 0 and the
90
+ maximum delay time).
91
+
92
+ """
93
+
94
+ def __init__ (
95
+ self ,
96
+ initial_delay : float = 0.2 ,
97
+ exponential_backoff : int = 2 ,
98
+ status_code_range : tuple = (500 , 600 ),
99
+ max_retries : int = 3 ,
100
+ ):
101
+ self .initial_delay = initial_delay
102
+ self .exponential_backoff = exponential_backoff
103
+ self .status_code_range = range (* status_code_range )
104
+ self .max_retries = max_retries
105
+
106
+ def __call__ (self , function : Callable ) -> Callable :
107
+ """:param callable: The function to invoke."""
108
+
109
+ @wraps (function )
110
+ def decorated (* args , ** kwargs ):
111
+ delay = self .initial_delay
112
+ retry_count = 0
113
+
114
+ while retry_count <= self .max_retries :
115
+ try :
116
+ return function (* args , ** kwargs )
117
+ except ApiException as e :
118
+ is_retryable = (e .status is not None ) and (e .status in self .status_code_range )
119
+ if not is_retryable :
120
+ raise e
121
+ if retry_count == self .max_retries :
122
+ raise InternalApiError (reason = "Maximum retries reached" ) from e
123
+
124
+ if is_retryable :
125
+ status_code = e .status
126
+ if status_code in self .status_code_range :
127
+ logger .warning (
128
+ (
129
+ f"Current HTTP response status: { status_code } . "
130
+ f"Remaining retries: { self .max_retries - retry_count } "
131
+ ),
132
+ exc_info = True ,
133
+ )
134
+ # This is implementing a full jitter strategy
135
+ random_delay = random .uniform (0 , delay )
136
+ time .sleep (random_delay )
137
+
138
+ retry_count += 1
139
+ delay *= self .exponential_backoff
140
+
141
+ return decorated
73
142
74
143
75
144
class GroundlightApiClient (ApiClient ):
@@ -80,6 +149,7 @@ class GroundlightApiClient(ApiClient):
80
149
81
150
REQUEST_ID_HEADER = "X-Request-Id"
82
151
152
+ @RequestsRetryDecorator ()
83
153
def call_api (self , * args , ** kwargs ):
84
154
"""Adds a request-id header to each API call."""
85
155
# Note we don't look for header_param in kwargs here, because this method is only called in one place
@@ -97,7 +167,6 @@ def call_api(self, *args, **kwargs):
97
167
# The methods below will eventually go away when we move to properly model
98
168
# these methods with OpenAPI
99
169
#
100
-
101
170
def _headers (self ) -> dict :
102
171
request_id = _generate_request_id ()
103
172
return {
@@ -106,6 +175,7 @@ def _headers(self) -> dict:
106
175
"X-Request-Id" : request_id ,
107
176
}
108
177
178
+ @RequestsRetryDecorator ()
109
179
def _add_label (self , image_query_id : str , label : str ) -> dict :
110
180
"""Temporary internal call to add a label to an image query. Not supported."""
111
181
# TODO: Properly model this with OpenApi spec.
@@ -126,11 +196,14 @@ def _add_label(self, image_query_id: str, label: str) -> dict:
126
196
127
197
if not is_ok (response .status_code ):
128
198
raise InternalApiError (
129
- f"Error adding label to image query { image_query_id } status={ response .status_code } { response .text } " ,
199
+ status = response .status_code ,
200
+ reason = f"Error adding label to image query { image_query_id } " ,
201
+ http_resp = response ,
130
202
)
131
203
132
204
return response .json ()
133
205
206
+ @RequestsRetryDecorator ()
134
207
def _get_detector_by_name (self , name : str ) -> Detector :
135
208
"""Get a detector by name. For now, we use the list detectors API directly.
136
209
@@ -141,9 +214,7 @@ def _get_detector_by_name(self, name: str) -> Detector:
141
214
response = requests .request ("GET" , url , headers = headers )
142
215
143
216
if not is_ok (response .status_code ):
144
- raise InternalApiError (
145
- f"Error getting detector by name '{ name } ' (status={ response .status_code } ): { response .text } " ,
146
- )
217
+ raise InternalApiError (status = response .status_code , http_resp = response )
147
218
148
219
parsed = response .json ()
149
220
0 commit comments