earthscope-sdk 1.0.0b0__py3-none-any.whl → 1.0.0b1__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.
@@ -1,4 +1,4 @@
1
- __version__ = "1.0.0b0"
1
+ __version__ = "1.0.0b1"
2
2
 
3
3
  from earthscope_sdk.client import AsyncEarthScopeClient, EarthScopeClient
4
4
 
@@ -157,12 +157,14 @@ class AuthFlow(httpx.Auth):
157
157
  NoRefreshTokenError: no refresh token is present
158
158
  InvalidRefreshTokenError: the token refresh failed
159
159
  """
160
+ from httpx import HTTPStatusError, ReadTimeout
161
+
160
162
  refresh_token = self.refresh_token
161
163
  scope = scope or self._settings.scope
162
164
 
163
- r = await self._ctx.httpx_client.post(
165
+ request = self._ctx.httpx_client.build_request(
166
+ "POST",
164
167
  f"{self._settings.domain}oauth/token",
165
- auth=None, # override client default
166
168
  headers={"content-type": "application/x-www-form-urlencoded"},
167
169
  data={
168
170
  "grant_type": "refresh_token",
@@ -171,9 +173,22 @@ class AuthFlow(httpx.Auth):
171
173
  "scopes": scope,
172
174
  },
173
175
  )
174
- if r.status_code != 200:
175
- logger.error(f"error during token refresh: {r.content}")
176
+
177
+ try:
178
+ async for attempt in self._settings.retry.retry_context(ReadTimeout):
179
+ with attempt:
180
+ r = await self._ctx.httpx_client.send(request, auth=None)
181
+ r.raise_for_status()
182
+ except HTTPStatusError as e:
183
+ logger.error(
184
+ f"error during token refresh ({attempt.num} attempts): {e.response.content}"
185
+ )
176
186
  raise InvalidRefreshTokenError("refresh token exchange failed")
187
+ except Exception as e:
188
+ logger.error(
189
+ f"error during token refresh ({attempt.num} attempts)", exc_info=e
190
+ )
191
+ raise InvalidRefreshTokenError("refresh token exchange failed") from e
177
192
 
178
193
  # add previous refresh token to new tokens if omitted from resp
179
194
  # (i.e. we have a non-rotating refresh token)
@@ -18,7 +18,7 @@ class UserBaseService(SdkService):
18
18
  url=f"{self.resources.api_url}beta/user/credentials/aws/{role}",
19
19
  )
20
20
 
21
- resp = await self._send(req)
21
+ resp = await self._send_with_retries(req)
22
22
 
23
23
  return AwsTemporaryCredentials.model_validate_json(resp.content)
24
24
 
@@ -34,6 +34,6 @@ class UserBaseService(SdkService):
34
34
  url=f"{self.resources.api_url}beta/user/profile",
35
35
  )
36
36
 
37
- resp = await self._send(req)
37
+ resp = await self._send_with_retries(req)
38
38
 
39
39
  return UserProfile.model_validate_json(resp.content)
@@ -52,3 +52,8 @@ class SdkService:
52
52
  resp.raise_for_status()
53
53
 
54
54
  return resp
55
+
56
+ async def _send_with_retries(self, request: "Request"):
57
+ async for attempt in self.ctx.settings.http.retry.retry_context():
58
+ with attempt:
59
+ return await self._send(request=request)
@@ -1,11 +1,13 @@
1
1
  import base64
2
2
  import binascii
3
3
  import datetime as dt
4
+ import functools
4
5
  from contextlib import suppress
5
6
  from enum import Enum
6
7
  from functools import cached_property
7
- from typing import Annotated, Any, Optional, Union
8
+ from typing import Annotated, Any, Optional, Type, Union
8
9
 
10
+ from annotated_types import Ge, Gt
9
11
  from pydantic import (
10
12
  AliasChoices,
11
13
  BaseModel,
@@ -86,7 +88,7 @@ class Tokens(BaseModel):
86
88
  return None
87
89
 
88
90
  with suppress(IndexError, binascii.Error, ValidationError):
89
- payload_b64 = self.access_token.get_secret_value().split(".")[1]
91
+ payload_b64 = self.access_token.get_secret_value().split(".", 2)[1]
90
92
  payload = base64.b64decode(payload_b64 + "==") # extra padding
91
93
  return AccessTokenBody.model_validate_json(payload)
92
94
 
@@ -121,6 +123,76 @@ class Tokens(BaseModel):
121
123
  raise ValueError("At least one of access token and refresh token is required.")
122
124
 
123
125
 
126
+ class RetrySettings(BaseModel):
127
+ """
128
+ Retry configuration for the [Stamina library](https://stamina.hynek.me/en/stable/index.html)
129
+ """
130
+
131
+ # same defaults as AWS SDK "standard" mode:
132
+ # https://boto3.amazonaws.com/v1/documentation/api/latest/guide/retries.html#standard-retry-mode
133
+
134
+ attempts: Annotated[int, Ge(0)] = 3
135
+ timeout: Timedelta = dt.timedelta(seconds=20)
136
+
137
+ wait_initial: Timedelta = dt.timedelta(milliseconds=100)
138
+ wait_max: Timedelta = dt.timedelta(seconds=5)
139
+ wait_jitter: Timedelta = dt.timedelta(seconds=1)
140
+ wait_exp_base: Annotated[float, Gt(0)] = 2
141
+
142
+ async def retry_context(self, *retry_exc: Type[Exception]):
143
+ """
144
+ Obtain a [Stamina](https://stamina.hynek.me/en/stable/index.html) retry iterator.
145
+ """
146
+ from stamina import retry_context
147
+
148
+ retry_on = functools.partial(self.is_retriable, retry_exc=retry_exc)
149
+
150
+ ctx = retry_context(
151
+ on=retry_on,
152
+ attempts=self.attempts,
153
+ timeout=self.timeout,
154
+ wait_initial=self.wait_initial,
155
+ wait_jitter=self.wait_jitter,
156
+ wait_max=self.wait_max,
157
+ wait_exp_base=self.wait_exp_base,
158
+ )
159
+ async for attempt in ctx:
160
+ yield attempt
161
+
162
+ def is_retriable(
163
+ self,
164
+ exc: Exception,
165
+ *args,
166
+ retry_exc: tuple[Type[Exception]] = (),
167
+ **kwargs,
168
+ ) -> bool:
169
+ """
170
+ Check if the given exception can be retried
171
+ """
172
+ if retry_exc and isinstance(exc, retry_exc):
173
+ return True
174
+
175
+ return False
176
+
177
+
178
+ class HttpRetrySettings(RetrySettings):
179
+ status_codes: set[int] = {429, 500, 502, 503, 504}
180
+
181
+ def is_retriable(
182
+ self,
183
+ exc: Exception,
184
+ *args,
185
+ **kwargs,
186
+ ) -> bool:
187
+ from httpx import HTTPStatusError
188
+
189
+ if isinstance(exc, HTTPStatusError):
190
+ if exc.response.status_code in self.status_codes:
191
+ return True
192
+
193
+ return super().is_retriable(exc, *args, **kwargs)
194
+
195
+
124
196
  class AuthFlowSettings(Tokens):
125
197
  """
126
198
  Auth flow configuration
@@ -135,6 +207,14 @@ class AuthFlowSettings(Tokens):
135
207
  scope: str = "offline_access"
136
208
  client_secret: Optional[SecretStr] = None
137
209
 
210
+ # Auth exchange retries
211
+ retry: HttpRetrySettings = HttpRetrySettings(
212
+ attempts=5,
213
+ timeout=dt.timedelta(seconds=30),
214
+ wait_initial=dt.timedelta(seconds=1),
215
+ wait_jitter=dt.timedelta(seconds=3),
216
+ )
217
+
138
218
  @cached_property
139
219
  def auth_flow_type(self) -> AuthFlowType:
140
220
  if self.client_secret is not None:
@@ -157,6 +237,9 @@ class HttpSettings(BaseModel):
157
237
  timeout_connect: Timedelta = dt.timedelta(seconds=5)
158
238
  timeout_read: Timedelta = dt.timedelta(seconds=5)
159
239
 
240
+ # automatically retry requests
241
+ retry: HttpRetrySettings = HttpRetrySettings()
242
+
160
243
  # Other
161
244
  user_agent: str = f"earthscope-sdk py/{__version__}"
162
245
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: earthscope-sdk
3
- Version: 1.0.0b0
3
+ Version: 1.0.0b1
4
4
  Summary: An SDK for EarthScope API
5
5
  Author-email: EarthScope <data-help@earthscope.org>
6
6
  License: Apache License
@@ -214,7 +214,8 @@ Requires-Python: >=3.9
214
214
  Description-Content-Type: text/markdown
215
215
  License-File: LICENSE
216
216
  Requires-Dist: httpx>=0.27.0
217
- Requires-Dist: pydantic-settings[toml]>=2.7.0
217
+ Requires-Dist: pydantic-settings[toml]>=2.8.0
218
+ Requires-Dist: stamina>=24.3.0
218
219
  Provides-Extra: dev
219
220
  Requires-Dist: bumpver; extra == "dev"
220
221
  Requires-Dist: build; extra == "dev"
@@ -224,6 +225,7 @@ Requires-Dist: pip-tools; extra == "dev"
224
225
  Requires-Dist: pytest-httpx; extra == "dev"
225
226
  Requires-Dist: pytest-asyncio; extra == "dev"
226
227
  Requires-Dist: ruff; extra == "dev"
228
+ Dynamic: license-file
227
229
 
228
230
  # EarthScope SDK
229
231
 
@@ -1,28 +1,28 @@
1
- earthscope_sdk/__init__.py,sha256=6InyrqE0KEsb_XBBKCbUIb8s0LTJ6N20HFsrO-rHVtI,156
1
+ earthscope_sdk/__init__.py,sha256=GUJAs2cToEo9f8MaCUP_VnRsyqZWNhsN-pId5vzPk4U,156
2
2
  earthscope_sdk/auth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
- earthscope_sdk/auth/auth_flow.py,sha256=HZyLrt8o3I-0KC7XRg9W0n2NAVXX7EOl9pG-5blv7sA,9613
3
+ earthscope_sdk/auth/auth_flow.py,sha256=6jBQxLJ2gAxXGgtZYXDH1yg5L_WUmgu7fRvW4JoCi4Q,10198
4
4
  earthscope_sdk/auth/client_credentials_flow.py,sha256=1GyDSIR1OgYP4u0xZoTov1u_YhY1AzHFpOcBCzY1h6E,2769
5
5
  earthscope_sdk/auth/device_code_flow.py,sha256=dC5Ffj3HzBguRxSHCZYvTe1MD3C-iKf2AlanGuRKNvI,7922
6
6
  earthscope_sdk/auth/error.py,sha256=eC33Bw1HaBEJE7-eI2krtE__5PxStc3EyiYO12v0kVw,693
7
7
  earthscope_sdk/client/__init__.py,sha256=JotTr5oTiiOsUc0RTg82EVCUSg_-u80Qu_R0-crCXkY,139
8
8
  earthscope_sdk/client/_client.py,sha256=ai7WdsTOYglA6bLkT-Wntvxlke6nSaGHwqrtg5PEy80,833
9
9
  earthscope_sdk/client/user/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- earthscope_sdk/client/user/_base.py,sha256=nut1Ojsksohqy3X3L5FPDQ-rh-BmHLJ6sId5xVqLal0,1050
10
+ earthscope_sdk/client/user/_base.py,sha256=8dn4pfQMwVDpF0E6dl6P6HJuNVvozUzfgUGefnPXMnw,1076
11
11
  earthscope_sdk/client/user/_service.py,sha256=wRktOZF5GXajXXxij3Nkule6wvuWOV0vn4QsA1IXVHc,3063
12
12
  earthscope_sdk/client/user/models.py,sha256=drZAMwOYC1NVCzBZQhNL-pPTB28SURKfoZF8HdjlIj8,1214
13
13
  earthscope_sdk/common/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
14
  earthscope_sdk/common/_sync_runner.py,sha256=h_A2pSEjZCLj7ov50M6cWHVoX6eXVmGzz5nX0MwLWDY,4131
15
15
  earthscope_sdk/common/client.py,sha256=g5ZTNhFm33H68J9pWD5fDu760Yd5cBdfQmsbU3t8D_4,2156
16
16
  earthscope_sdk/common/context.py,sha256=vrCB_Ez-98Ir7c0GrCe-g7DuRCgc9vPaoRWFYf5q8Ko,5138
17
- earthscope_sdk/common/service.py,sha256=qBz6OV8rQf3WQojubEVfQ4HYeeKNN3_uIcXuOdvfH8w,1287
17
+ earthscope_sdk/common/service.py,sha256=SCUZVJA3jFaEPeFrOf0v9osf2UpqldhlFmirOYWJjxM,1506
18
18
  earthscope_sdk/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
19
  earthscope_sdk/config/_compat.py,sha256=P3F5y_Kf5zp9m9uOhl1Bp3ke6expxq4Sm9AeVaBbAHk,4610
20
20
  earthscope_sdk/config/_util.py,sha256=RZ6zvKrvjUkO7i69s7AVoIDhamRg4x71CAZLnucr9QM,1249
21
21
  earthscope_sdk/config/error.py,sha256=jh25q-b317lAvp32WwQw0zdYoV-MxZtg-n5FgZOMymI,95
22
- earthscope_sdk/config/models.py,sha256=CarL0O6RjFtufsc-q7g61uBEvETLjQr6HSmjCc0EVig,5775
22
+ earthscope_sdk/config/models.py,sha256=1334Rxzw4qDLSdQg9btxFQySBOCb8TEW6J95M-lyKEc,8198
23
23
  earthscope_sdk/config/settings.py,sha256=I2DwEvfmETcaYbSvUybs0EIih0yiJO9D46WnWzKPqbo,8812
24
- earthscope_sdk-1.0.0b0.dist-info/LICENSE,sha256=E_MrVXxRaMQNpvZhsDuz_J9N_ux7dlL_WpYSsE391HU,11349
25
- earthscope_sdk-1.0.0b0.dist-info/METADATA,sha256=jbeHzNrmHRZUGOFai1WmCDK1CQ-kWSbIsaNRjHq_WhA,17935
26
- earthscope_sdk-1.0.0b0.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
27
- earthscope_sdk-1.0.0b0.dist-info/top_level.txt,sha256=zTtIT9yN3JPJF7TqmTzqQcAvZZe4pAm907DLoGa5T_E,15
28
- earthscope_sdk-1.0.0b0.dist-info/RECORD,,
24
+ earthscope_sdk-1.0.0b1.dist-info/licenses/LICENSE,sha256=E_MrVXxRaMQNpvZhsDuz_J9N_ux7dlL_WpYSsE391HU,11349
25
+ earthscope_sdk-1.0.0b1.dist-info/METADATA,sha256=BHeAUzZ882lmEExnoVKIywGSYvIbyNdnIh8BzRJs2Ng,17988
26
+ earthscope_sdk-1.0.0b1.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
27
+ earthscope_sdk-1.0.0b1.dist-info/top_level.txt,sha256=zTtIT9yN3JPJF7TqmTzqQcAvZZe4pAm907DLoGa5T_E,15
28
+ earthscope_sdk-1.0.0b1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.8.0)
2
+ Generator: setuptools (78.1.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5