usaspending-orm 0.6.0__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.
- usaspending/__init__.py +144 -0
- usaspending/cli/__init__.py +2 -0
- usaspending/cli/download_award.py +113 -0
- usaspending/client.py +550 -0
- usaspending/config.py +119 -0
- usaspending/download/__init__.py +8 -0
- usaspending/download/job.py +219 -0
- usaspending/download/manager.py +140 -0
- usaspending/exceptions.py +71 -0
- usaspending/logging_config.py +310 -0
- usaspending/models/__init__.py +86 -0
- usaspending/models/agency.py +631 -0
- usaspending/models/award.py +933 -0
- usaspending/models/award_factory.py +72 -0
- usaspending/models/award_types.py +114 -0
- usaspending/models/base_model.py +214 -0
- usaspending/models/contract.py +147 -0
- usaspending/models/district_spending.py +70 -0
- usaspending/models/download.py +74 -0
- usaspending/models/funding.py +182 -0
- usaspending/models/grant.py +147 -0
- usaspending/models/idv.py +165 -0
- usaspending/models/lazy_record.py +82 -0
- usaspending/models/loan.py +85 -0
- usaspending/models/location.py +125 -0
- usaspending/models/period_of_performance.py +43 -0
- usaspending/models/recipient.py +204 -0
- usaspending/models/recipient_business_categories.py +364 -0
- usaspending/models/recipient_spending.py +58 -0
- usaspending/models/spending.py +70 -0
- usaspending/models/state_spending.py +43 -0
- usaspending/models/subaward.py +270 -0
- usaspending/models/subtier_agency.py +98 -0
- usaspending/models/transaction.py +89 -0
- usaspending/queries/__init__.py +87 -0
- usaspending/queries/agencies_search.py +196 -0
- usaspending/queries/agency_award_summary.py +134 -0
- usaspending/queries/agency_query.py +102 -0
- usaspending/queries/award_query.py +40 -0
- usaspending/queries/awarding_agencies_search.py +16 -0
- usaspending/queries/awards_search.py +602 -0
- usaspending/queries/filters.py +437 -0
- usaspending/queries/funding_agencies_search.py +20 -0
- usaspending/queries/funding_search.py +180 -0
- usaspending/queries/query_builder.py +1430 -0
- usaspending/queries/recipient_query.py +66 -0
- usaspending/queries/recipients_search.py +167 -0
- usaspending/queries/single_resource_base.py +83 -0
- usaspending/queries/spending_search.py +261 -0
- usaspending/queries/sub_agency_query.py +177 -0
- usaspending/queries/subawards_search.py +196 -0
- usaspending/queries/transactions_search.py +213 -0
- usaspending/resources/__init__.py +17 -0
- usaspending/resources/agency_resource.py +105 -0
- usaspending/resources/award_resource.py +119 -0
- usaspending/resources/base_resource.py +31 -0
- usaspending/resources/download_resource.py +102 -0
- usaspending/resources/funding_resource.py +41 -0
- usaspending/resources/recipients_resource.py +104 -0
- usaspending/resources/spending_resource.py +45 -0
- usaspending/resources/subawards_resource.py +68 -0
- usaspending/resources/transactions_resource.py +39 -0
- usaspending/utils/__init__.py +6 -0
- usaspending/utils/formatter.py +550 -0
- usaspending/utils/rate_limit.py +135 -0
- usaspending/utils/retry.py +257 -0
- usaspending/utils/special_cases.yaml +155 -0
- usaspending_orm-0.6.0.dist-info/LICENSE +18 -0
- usaspending_orm-0.6.0.dist-info/METADATA +324 -0
- usaspending_orm-0.6.0.dist-info/RECORD +71 -0
- usaspending_orm-0.6.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Retry logic implementation for USASpending API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Callable, Optional
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from ..config import config
|
|
12
|
+
from ..exceptions import HTTPError, RateLimitError
|
|
13
|
+
from ..logging_config import USASpendingLogger
|
|
14
|
+
|
|
15
|
+
logger = USASpendingLogger.get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RetryHandler:
|
|
19
|
+
"""
|
|
20
|
+
Handles retry logic with exponential backoff for API requests.
|
|
21
|
+
|
|
22
|
+
This implementation retries requests that fail due to transient errors
|
|
23
|
+
like network issues, server errors (5xx), and rate limiting (429).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
# HTTP status codes that should be retried
|
|
27
|
+
RETRYABLE_STATUS_CODES = {
|
|
28
|
+
429, # Too Many Requests (rate limit)
|
|
29
|
+
500, # Internal Server Error
|
|
30
|
+
502, # Bad Gateway
|
|
31
|
+
503, # Service Unavailable
|
|
32
|
+
504, # Gateway Timeout
|
|
33
|
+
520, # Unknown Error (Cloudflare)
|
|
34
|
+
521, # Web Server Is Down
|
|
35
|
+
522, # Connection Timed Out
|
|
36
|
+
523, # Origin Is Unreachable
|
|
37
|
+
524, # A Timeout Occurred
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Exception types that should be retried
|
|
41
|
+
RETRYABLE_EXCEPTIONS = (
|
|
42
|
+
requests.exceptions.ConnectionError,
|
|
43
|
+
requests.exceptions.Timeout,
|
|
44
|
+
requests.exceptions.ConnectTimeout,
|
|
45
|
+
requests.exceptions.ReadTimeout,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def __init__(self, session_reset_callback: Optional[Callable] = None):
|
|
49
|
+
"""
|
|
50
|
+
Initialize the retry handler.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
session_reset_callback: Optional callback to reset session on persistent errors
|
|
54
|
+
"""
|
|
55
|
+
self.max_retries = config.max_retries
|
|
56
|
+
self.base_delay = config.retry_delay
|
|
57
|
+
self.backoff_factor = config.retry_backoff
|
|
58
|
+
self.session_reset_callback = session_reset_callback
|
|
59
|
+
|
|
60
|
+
# Track consecutive 5XX errors for session reset logic
|
|
61
|
+
self._consecutive_5xx_errors = 0
|
|
62
|
+
|
|
63
|
+
logger.debug(
|
|
64
|
+
f"Initialized RetryHandler: max_retries={self.max_retries}, "
|
|
65
|
+
f"base_delay={self.base_delay}s, backoff_factor={self.backoff_factor}, "
|
|
66
|
+
f"session_reset={'enabled' if session_reset_callback else 'disabled'}"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def execute(self, func: Callable, *args, **kwargs) -> Any:
|
|
70
|
+
"""
|
|
71
|
+
Execute a function with retry logic.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
func: Function to execute (typically session.request)
|
|
75
|
+
*args: Positional arguments to pass to the function
|
|
76
|
+
**kwargs: Keyword arguments to pass to the function
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
The result of the successful function call
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
The last exception encountered if all retries are exhausted
|
|
83
|
+
"""
|
|
84
|
+
last_exception = None
|
|
85
|
+
|
|
86
|
+
for attempt in range(self.max_retries + 1): # +1 for the initial attempt
|
|
87
|
+
try:
|
|
88
|
+
result = func(*args, **kwargs)
|
|
89
|
+
|
|
90
|
+
# Check if the result is a response object with a status code
|
|
91
|
+
if hasattr(result, "status_code"):
|
|
92
|
+
self._check_response_for_retry(result, attempt)
|
|
93
|
+
|
|
94
|
+
# Reset consecutive 5XX error counter on successful response
|
|
95
|
+
self._consecutive_5xx_errors = 0
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
except Exception as e:
|
|
99
|
+
last_exception = e
|
|
100
|
+
|
|
101
|
+
# Track consecutive 5XX errors for session reset logic
|
|
102
|
+
if isinstance(e, HTTPError) and e.status_code >= 500:
|
|
103
|
+
self._consecutive_5xx_errors += 1
|
|
104
|
+
logger.debug(
|
|
105
|
+
f"Consecutive 5XX errors: {self._consecutive_5xx_errors}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Check if we should reset session due to persistent 5XX errors
|
|
109
|
+
if (
|
|
110
|
+
self.session_reset_callback
|
|
111
|
+
and self._consecutive_5xx_errors
|
|
112
|
+
>= config.session_reset_on_5xx_threshold
|
|
113
|
+
and attempt < self.max_retries
|
|
114
|
+
): # Don't reset on final attempt
|
|
115
|
+
logger.warning(
|
|
116
|
+
f"Resetting session due to {self._consecutive_5xx_errors} consecutive 5XX errors "
|
|
117
|
+
f"(suggests server-side session exhaustion)"
|
|
118
|
+
)
|
|
119
|
+
self.session_reset_callback()
|
|
120
|
+
self._consecutive_5xx_errors = 0
|
|
121
|
+
# Reduce delay after session reset since we may have fixed the issue
|
|
122
|
+
delay = self.base_delay
|
|
123
|
+
logger.info(f"Retrying with fresh session after {delay:.2f}s")
|
|
124
|
+
time.sleep(delay)
|
|
125
|
+
continue
|
|
126
|
+
else:
|
|
127
|
+
# Reset counter for non-5XX errors
|
|
128
|
+
if self._consecutive_5xx_errors > 0:
|
|
129
|
+
logger.debug(
|
|
130
|
+
f"5XX error counter reset by {type(e).__name__} (was {self._consecutive_5xx_errors})"
|
|
131
|
+
)
|
|
132
|
+
self._consecutive_5xx_errors = 0
|
|
133
|
+
|
|
134
|
+
# Don't retry on the last attempt
|
|
135
|
+
if attempt == self.max_retries:
|
|
136
|
+
logger.warning(
|
|
137
|
+
f"Max retries ({self.max_retries}) exhausted. Final error: {e}"
|
|
138
|
+
)
|
|
139
|
+
break
|
|
140
|
+
|
|
141
|
+
# Check if this exception should be retried
|
|
142
|
+
if not self._should_retry_exception(e):
|
|
143
|
+
logger.debug(f"Exception {type(e).__name__} is not retryable")
|
|
144
|
+
break
|
|
145
|
+
|
|
146
|
+
# Calculate delay and wait before retrying
|
|
147
|
+
delay = self._calculate_delay(attempt, e)
|
|
148
|
+
if delay > 0:
|
|
149
|
+
logger.info(
|
|
150
|
+
f"Retry attempt {attempt + 1}/{self.max_retries} after {delay:.2f}s "
|
|
151
|
+
f"due to {type(e).__name__}: {e}"
|
|
152
|
+
)
|
|
153
|
+
time.sleep(delay)
|
|
154
|
+
|
|
155
|
+
# If we get here, all retries were exhausted
|
|
156
|
+
raise last_exception
|
|
157
|
+
|
|
158
|
+
def _check_response_for_retry(
|
|
159
|
+
self, response: requests.Response, attempt: int
|
|
160
|
+
) -> None:
|
|
161
|
+
"""
|
|
162
|
+
Check if a response should trigger a retry.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
response: The HTTP response object
|
|
166
|
+
attempt: Current attempt number (0-based)
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
Various exceptions if retry should occur
|
|
170
|
+
"""
|
|
171
|
+
if response.status_code in self.RETRYABLE_STATUS_CODES:
|
|
172
|
+
if response.status_code == 429:
|
|
173
|
+
# Rate limit exceeded
|
|
174
|
+
retry_after = self._get_retry_after_header(response)
|
|
175
|
+
logger.warning(
|
|
176
|
+
f"Rate limit hit (HTTP 429). Retry-After: {retry_after}s"
|
|
177
|
+
)
|
|
178
|
+
raise RateLimitError("Rate limit exceeded", retry_after=retry_after)
|
|
179
|
+
elif response.status_code >= 500:
|
|
180
|
+
# Server error
|
|
181
|
+
logger.warning(f"Server error (HTTP {response.status_code})")
|
|
182
|
+
raise HTTPError(
|
|
183
|
+
f"Server error: HTTP {response.status_code}",
|
|
184
|
+
status_code=response.status_code,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def _should_retry_exception(self, exception: Exception) -> bool:
|
|
188
|
+
"""
|
|
189
|
+
Determine if an exception should trigger a retry.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
exception: The exception that occurred
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
True if the exception should be retried, False otherwise
|
|
196
|
+
"""
|
|
197
|
+
# Always retry these network-related exceptions
|
|
198
|
+
if isinstance(exception, self.RETRYABLE_EXCEPTIONS):
|
|
199
|
+
return True
|
|
200
|
+
|
|
201
|
+
# Retry rate limit errors
|
|
202
|
+
if isinstance(exception, RateLimitError):
|
|
203
|
+
return True
|
|
204
|
+
|
|
205
|
+
# Retry HTTP errors with retryable status codes
|
|
206
|
+
if isinstance(exception, HTTPError):
|
|
207
|
+
return exception.status_code in self.RETRYABLE_STATUS_CODES
|
|
208
|
+
|
|
209
|
+
# Don't retry other exceptions (like validation errors, auth errors, etc.)
|
|
210
|
+
return False
|
|
211
|
+
|
|
212
|
+
def _calculate_delay(self, attempt: int, exception: Exception) -> float:
|
|
213
|
+
"""
|
|
214
|
+
Calculate the delay before the next retry attempt.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
attempt: Current attempt number (0-based)
|
|
218
|
+
exception: The exception that triggered this retry
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
Delay in seconds before the next attempt
|
|
222
|
+
"""
|
|
223
|
+
# Handle rate limit errors specially
|
|
224
|
+
if isinstance(exception, RateLimitError) and exception.retry_after:
|
|
225
|
+
# Use the server-provided retry-after value
|
|
226
|
+
return float(exception.retry_after)
|
|
227
|
+
|
|
228
|
+
# Calculate exponential backoff with jitter
|
|
229
|
+
delay = self.base_delay * (self.backoff_factor**attempt)
|
|
230
|
+
|
|
231
|
+
# Add jitter (randomness) to avoid thundering herd problem
|
|
232
|
+
# Use up to 25% jitter
|
|
233
|
+
jitter = delay * 0.25 * random.random()
|
|
234
|
+
delay += jitter
|
|
235
|
+
|
|
236
|
+
logger.debug(f"Calculated retry delay: {delay:.3f}s (attempt {attempt})")
|
|
237
|
+
return delay
|
|
238
|
+
|
|
239
|
+
def _get_retry_after_header(self, response: requests.Response) -> Optional[int]:
|
|
240
|
+
"""
|
|
241
|
+
Extract the Retry-After header value from a rate limit response.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
response: The HTTP response object
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
Number of seconds to wait, or None if header not present
|
|
248
|
+
"""
|
|
249
|
+
retry_after = response.headers.get("Retry-After")
|
|
250
|
+
if retry_after:
|
|
251
|
+
try:
|
|
252
|
+
return int(retry_after)
|
|
253
|
+
except ValueError:
|
|
254
|
+
# Header might be in HTTP-date format, but we'll just ignore it
|
|
255
|
+
# and use exponential backoff instead
|
|
256
|
+
pass
|
|
257
|
+
return None
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Updatable list of special cases for title casing when converting USASpending field data
|
|
2
|
+
# from uppercase to title or sentence case.
|
|
3
|
+
# These words will display exactly as they are listed here, regardless of their position in a sentence.
|
|
4
|
+
|
|
5
|
+
# NASA and technical acronyms
|
|
6
|
+
- NASA
|
|
7
|
+
- ESA
|
|
8
|
+
- USA
|
|
9
|
+
- UK
|
|
10
|
+
- SBIR
|
|
11
|
+
- STTR
|
|
12
|
+
- TDM
|
|
13
|
+
- TDRS
|
|
14
|
+
- FY
|
|
15
|
+
- EPSCoR
|
|
16
|
+
- STEM
|
|
17
|
+
- JAXA
|
|
18
|
+
- JPL
|
|
19
|
+
- JSC
|
|
20
|
+
- KSC
|
|
21
|
+
- GSFC
|
|
22
|
+
- MSFC
|
|
23
|
+
- ARC
|
|
24
|
+
- GRC
|
|
25
|
+
- LARC
|
|
26
|
+
- AFRC
|
|
27
|
+
- SSC
|
|
28
|
+
- JWST
|
|
29
|
+
- CSOS
|
|
30
|
+
- CL
|
|
31
|
+
- FL
|
|
32
|
+
- FPRW
|
|
33
|
+
- PADF
|
|
34
|
+
- ICAT
|
|
35
|
+
- ICATEQ
|
|
36
|
+
- AC
|
|
37
|
+
- RFQ
|
|
38
|
+
- RFP
|
|
39
|
+
- SOW
|
|
40
|
+
- CDR
|
|
41
|
+
- PDR
|
|
42
|
+
- QA
|
|
43
|
+
- PI
|
|
44
|
+
- COTS
|
|
45
|
+
- SETI
|
|
46
|
+
- SLS
|
|
47
|
+
- ISS
|
|
48
|
+
|
|
49
|
+
# Business suffixes
|
|
50
|
+
- LLC
|
|
51
|
+
- LLP
|
|
52
|
+
- L.L.C.
|
|
53
|
+
- I.N.C.
|
|
54
|
+
- Inc
|
|
55
|
+
- L.L.P.
|
|
56
|
+
- L.T.D.
|
|
57
|
+
- LTD
|
|
58
|
+
- P.C.
|
|
59
|
+
- P.A.
|
|
60
|
+
- PLLC
|
|
61
|
+
- P.L.L.C.
|
|
62
|
+
- PC
|
|
63
|
+
- PA
|
|
64
|
+
- S.A.
|
|
65
|
+
- S.C.
|
|
66
|
+
- GmbH
|
|
67
|
+
- B.V.
|
|
68
|
+
- N.V.
|
|
69
|
+
|
|
70
|
+
# NASA programs and projects
|
|
71
|
+
- PDS
|
|
72
|
+
- PDART
|
|
73
|
+
- MDAP
|
|
74
|
+
- MEP
|
|
75
|
+
- MEPAG
|
|
76
|
+
|
|
77
|
+
# Mission names and acronyms
|
|
78
|
+
- Artemis
|
|
79
|
+
- Orion
|
|
80
|
+
- ISS
|
|
81
|
+
- SpaceX
|
|
82
|
+
- Falcon 9
|
|
83
|
+
- OSIRIS-REx
|
|
84
|
+
- OSIRIS-APEX
|
|
85
|
+
- Perseverance
|
|
86
|
+
- Mars 2020
|
|
87
|
+
- Curiosity
|
|
88
|
+
- MSL
|
|
89
|
+
- MER
|
|
90
|
+
- MAVEN
|
|
91
|
+
- Mars Reconnaissance Orbiter
|
|
92
|
+
- MRO
|
|
93
|
+
- Mars Odyssey
|
|
94
|
+
- Mars Express
|
|
95
|
+
- ODY
|
|
96
|
+
- VERITAS
|
|
97
|
+
- DAVINCI
|
|
98
|
+
- Psyche
|
|
99
|
+
- Europa Clipper
|
|
100
|
+
- Dragonfly
|
|
101
|
+
- DART
|
|
102
|
+
- Hubble
|
|
103
|
+
- Chandra
|
|
104
|
+
- Spitzer
|
|
105
|
+
- Kepler
|
|
106
|
+
- TESS
|
|
107
|
+
- WFIRST
|
|
108
|
+
- LRO
|
|
109
|
+
- SOFIA
|
|
110
|
+
- Juno
|
|
111
|
+
- Cassini
|
|
112
|
+
- New Horizons
|
|
113
|
+
- Voyager
|
|
114
|
+
- Parker Solar Probe
|
|
115
|
+
- PSP
|
|
116
|
+
- STS
|
|
117
|
+
|
|
118
|
+
# Names and proper nouns
|
|
119
|
+
- Pluto
|
|
120
|
+
- Neptune
|
|
121
|
+
- Triton
|
|
122
|
+
- Saturn
|
|
123
|
+
- Titan
|
|
124
|
+
- Enceladus
|
|
125
|
+
- Jupiter
|
|
126
|
+
- Europa
|
|
127
|
+
- Ganymede
|
|
128
|
+
- Callisto
|
|
129
|
+
- Io
|
|
130
|
+
- Eris
|
|
131
|
+
- Ceres
|
|
132
|
+
- Vesta
|
|
133
|
+
- Mars
|
|
134
|
+
- Phobos
|
|
135
|
+
- Deimos
|
|
136
|
+
- Earth
|
|
137
|
+
- the Moon
|
|
138
|
+
- Venus
|
|
139
|
+
- Mercury
|
|
140
|
+
- the Sun
|
|
141
|
+
|
|
142
|
+
# Directional abbreviations
|
|
143
|
+
- Dr
|
|
144
|
+
- N
|
|
145
|
+
- S
|
|
146
|
+
- E
|
|
147
|
+
- W
|
|
148
|
+
- NE
|
|
149
|
+
- NW
|
|
150
|
+
- SE
|
|
151
|
+
- SW
|
|
152
|
+
- N.E.
|
|
153
|
+
- N.W.
|
|
154
|
+
- S.E.
|
|
155
|
+
- S.W.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Copyright 2025 Casey Dreier
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the “Software”), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is furnished
|
|
8
|
+
to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
|
14
|
+
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
|
15
|
+
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
16
|
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
17
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
18
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|