credenshare 0.1.2__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.
@@ -0,0 +1,106 @@
1
+ """CredenShare SDK — end-to-end encrypted secret sharing.
2
+
3
+ Encryption happens here, on your machine. The content key never reaches CredenShare, which
4
+ is what makes "we cannot read your data" a property of the system rather than a promise.
5
+
6
+ from credenshare import CredenShare
7
+
8
+ crs = CredenShare(credential="crs_sk_live_...")
9
+ share = crs.shares.create(
10
+ title="Staging deploy credentials",
11
+ fields=[{"key": "Password", "value": "s3cr3t", "type": "password"}],
12
+ )
13
+ print(share.link) # contains the key in its fragment - treat it as the secret
14
+
15
+ The implementation follows the published wire specification, and its correctness is defined
16
+ by `conformance/vectors.v1.json` rather than by this code.
17
+ """
18
+
19
+ from . import webhooks
20
+ from .client import (
21
+ CredenShare,
22
+ Credential,
23
+ Share,
24
+ ShareList,
25
+ ShareSummary,
26
+ )
27
+ from .crypto import (
28
+ FIELD_TYPES,
29
+ SeedKeypair,
30
+ access_token,
31
+ custody_keypair,
32
+ decode_fragment,
33
+ decrypt_content,
34
+ encode_fragment,
35
+ encrypt_content,
36
+ keypair_from_seed,
37
+ new_content_key,
38
+ passcode_verifier,
39
+ unwrap_with_seed,
40
+ wrap_to_public_key,
41
+ )
42
+ from .errors import (
43
+ ApiError,
44
+ AuthenticationError,
45
+ CredenShareError,
46
+ CredentialFormatError,
47
+ CustodySecretMissingError,
48
+ CustodySecretTransmittedError,
49
+ DeliveryUnknownError,
50
+ IdempotencyConflictError,
51
+ InvalidArgumentError,
52
+ InvalidFieldError,
53
+ MalformedKeyError,
54
+ MissingKeyError,
55
+ NotFoundError,
56
+ PermissionError_,
57
+ QuotaExceededError,
58
+ RateLimitError,
59
+ ServiceUnavailableError,
60
+ WireFormatError,
61
+ )
62
+ from .webhooks import WebhookVerificationError
63
+
64
+ __version__ = "0.1.0"
65
+
66
+ __all__ = [
67
+ "__version__",
68
+ "CredenShare",
69
+ "Credential",
70
+ "Share",
71
+ "ShareList",
72
+ "ShareSummary",
73
+ "webhooks",
74
+ "FIELD_TYPES",
75
+ "SeedKeypair",
76
+ "access_token",
77
+ "custody_keypair",
78
+ "decode_fragment",
79
+ "decrypt_content",
80
+ "encode_fragment",
81
+ "encrypt_content",
82
+ "keypair_from_seed",
83
+ "new_content_key",
84
+ "passcode_verifier",
85
+ "unwrap_with_seed",
86
+ "wrap_to_public_key",
87
+ "ApiError",
88
+ "AuthenticationError",
89
+ "CredenShareError",
90
+ "CredentialFormatError",
91
+ "CustodySecretMissingError",
92
+ "InvalidArgumentError",
93
+ "InvalidFieldError",
94
+ "WebhookVerificationError",
95
+ "CustodySecretTransmittedError",
96
+ "DeliveryUnknownError",
97
+ "IdempotencyConflictError",
98
+ "MalformedKeyError",
99
+ "MissingKeyError",
100
+ "NotFoundError",
101
+ "PermissionError_",
102
+ "QuotaExceededError",
103
+ "RateLimitError",
104
+ "ServiceUnavailableError",
105
+ "WireFormatError",
106
+ ]
credenshare/client.py ADDED
@@ -0,0 +1,581 @@
1
+ """The CredenShare API client.
2
+
3
+ Everything sensitive happens before a request is built. By the time anything reaches the
4
+ network it is ciphertext plus metadata, and the content key exists only in the link this
5
+ client hands back to you.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ import uuid
12
+ from dataclasses import dataclass
13
+ from typing import Any, Dict, Iterator, List, Optional, Sequence
14
+ from urllib.parse import quote
15
+
16
+ import httpx
17
+
18
+ from . import crypto
19
+ from .errors import (
20
+ ApiError,
21
+ AuthenticationError,
22
+ CredentialFormatError,
23
+ CustodySecretMissingError,
24
+ CustodySecretTransmittedError,
25
+ DeliveryUnknownError,
26
+ IdempotencyConflictError,
27
+ InvalidArgumentError,
28
+ NotFoundError,
29
+ PermissionError_,
30
+ QuotaExceededError,
31
+ RateLimitError,
32
+ ServiceUnavailableError,
33
+ )
34
+
35
+ __all__ = ["CredenShare", "Credential", "Share", "ShareList", "ShareSummary"]
36
+
37
+ DEFAULT_BASE_URL = "https://api.credenshare.io/v1"
38
+ DEFAULT_LINK_ORIGIN = "https://crs.sh"
39
+
40
+ #: The most pages :meth:`_Shares.iter_all` will walk before giving up.
41
+ #:
42
+ #: The end of a result set is not always knowable: a server that omits every paging figure and
43
+ #: returns full pages forever cannot be told from a very large account. An unbounded walk
44
+ #: against one never returns. At the default page size of 100 this is ten million shares.
45
+ MAX_PAGES = 100_000
46
+
47
+ #: The only accepted encryption type. Plaintext creates are refused by the server, and this
48
+ #: client has no way to express one.
49
+ ENCRYPTION_TYPE = "e2ee-aes256-gcm"
50
+
51
+ #: The API's numeric code for an exhausted plan allowance. Distinguished from other 403s
52
+ #: because waiting does not help and the remedy is a plan change, not a retry.
53
+ _QUOTA_EXCEEDED_CODE = 61
54
+
55
+ #: The API's numeric code for an Idempotency-Key replayed with a different body.
56
+ _IDEMPOTENCY_CONFLICT_CODE = 105
57
+
58
+ #: The identical request is still in flight. Wait briefly and repeat it unchanged - the
59
+ #: opposite remedy to 105, which means the body genuinely differed.
60
+ _IDEMPOTENCY_IN_FLIGHT_CODE = 106
61
+
62
+ #: Retries for network failures. Only connection and timeout errors are retried, never an
63
+ #: HTTP status: a 5xx may have committed, and this client cannot tell. A create is safe to
64
+ #: retry because the Idempotency-Key and the body are both identical on the second attempt —
65
+ #: which is the entire reason the header is mandatory.
66
+ DEFAULT_MAX_RETRIES = 2
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Credential:
71
+ """A parsed API credential: ``crs_sk_live_<keyId>.<authSecret>[.<custodySecret>]``.
72
+
73
+ The custody secret is held here but is NEVER placed in a request. It is a separate secret
74
+ precisely so the server cannot reconstruct the custody private key — deriving it from the
75
+ auth secret, which is transmitted on every call, would mean the server *could* decrypt.
76
+ Not that it would; that it could.
77
+ """
78
+
79
+ key_id: str
80
+ auth_secret: str
81
+ custody_secret: Optional[str] = None
82
+
83
+ @classmethod
84
+ def parse(cls, raw: str) -> "Credential":
85
+ text = (raw or "").strip()
86
+ if not text.startswith("crs_sk_live_"):
87
+ raise CredentialFormatError(
88
+ "a credential starts with 'crs_sk_live_'; this does not look like one"
89
+ )
90
+ parts = text.split(".")
91
+ if len(parts) not in (2, 3) or not all(parts):
92
+ raise CredentialFormatError(
93
+ "a credential is 'crs_sk_live_<keyId>.<authSecret>' with an optional "
94
+ f"'.<custodySecret>'; this has {len(parts)} part(s)"
95
+ )
96
+ return cls(
97
+ key_id=parts[0][len("crs_sk_live_") :],
98
+ auth_secret=parts[1],
99
+ custody_secret=parts[2] if len(parts) == 3 else None,
100
+ )
101
+
102
+ @property
103
+ def bearer(self) -> str:
104
+ """The two-part value sent in the Authorization header.
105
+
106
+ Assembled from the parts rather than by trimming the original string, so a third part
107
+ cannot survive a formatting mistake and reach the wire.
108
+ """
109
+ return f"crs_sk_live_{self.key_id}.{self.auth_secret}"
110
+
111
+ @property
112
+ def has_custody(self) -> bool:
113
+ return self.custody_secret is not None
114
+
115
+ def custody_public_key(self) -> str:
116
+ """The base64url custody public key to register for account custody.
117
+
118
+ Only the public half leaves this machine.
119
+ """
120
+ if self.custody_secret is None:
121
+ raise CredentialFormatError(
122
+ "this credential has no custody secret, so no custody keypair exists"
123
+ )
124
+ return crypto.custody_keypair(self.custody_secret).public_key_b64url
125
+
126
+ def __repr__(self) -> str: # pragma: no cover - formatting only
127
+ # Never render the secrets. A credential in a traceback or a log line is a credential
128
+ # that has to be rotated.
129
+ custody = "with custody" if self.has_custody else "no custody"
130
+ return f"<Credential {self.key_id} ({custody})>"
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class Share:
135
+ """A created share, and the only place its link exists."""
136
+
137
+ short_code: str
138
+ #: The full recipient link, INCLUDING the key fragment. Treat this as the secret itself:
139
+ #: anyone holding it can read the content, and CredenShare cannot.
140
+ link: str
141
+ #: The content key, if you need to build your own link or decrypt later.
142
+ content_key: bytes
143
+ expired_at: Optional[str] = None
144
+ custody: Optional[str] = None
145
+
146
+ def __repr__(self) -> str: # pragma: no cover - formatting only
147
+ # The link carries the key, so it is not printed by default.
148
+ return f"<Share {self.short_code} (link withheld from repr)>"
149
+
150
+
151
+ @dataclass(frozen=True)
152
+ class ShareSummary:
153
+ """Metadata for a share. Never content, and never a key.
154
+
155
+ Deliberately thin, because the API is: `/v1` returns the short code and the expiry and
156
+ nothing else. There is no `title` here even though you supply one on create — the server
157
+ does not return it, and an attribute that is always ``None`` reads as a broken field
158
+ rather than as an absent one.
159
+ """
160
+
161
+ short_code: str
162
+ expired_at: Optional[str] = None
163
+
164
+
165
+ class ShareList(List[ShareSummary]):
166
+ """A page of shares, with the paging figures attached.
167
+
168
+ A plain list would leave a caller guessing whether more exists, and a caller who has to
169
+ guess guesses wrong — usually by stopping at the first short page.
170
+ """
171
+
172
+ def __init__(
173
+ self,
174
+ rows: Sequence[ShareSummary],
175
+ *,
176
+ page: int = 1,
177
+ limit: int = 25,
178
+ total: Optional[int] = None,
179
+ total_pages: Optional[int] = None,
180
+ ) -> None:
181
+ super().__init__(rows)
182
+ self.page = page
183
+ self.limit = limit
184
+ self.total = total
185
+ self.total_pages = total_pages
186
+
187
+ @property
188
+ def has_more(self) -> bool:
189
+ """Whether another page exists.
190
+
191
+ Falls back rather than answering ``False`` when the server sends no paging figures:
192
+ reporting "no more" on a full page is what makes :meth:`iter_all` stop after page one
193
+ and return a fraction of the account as though it were all of it. Node, Go and Rust
194
+ all fall back; Python was the only one that did not.
195
+ """
196
+ if self.total_pages is not None:
197
+ return self.page < self.total_pages
198
+ if self.total is not None:
199
+ return self.page * self.limit < self.total
200
+ # Nothing to go on but the page itself: a full page probably has a successor.
201
+ return len(self) >= self.limit > 0
202
+
203
+
204
+ class _Shares:
205
+ """The ``/v1/shares`` surface."""
206
+
207
+ def __init__(self, client: "CredenShare") -> None:
208
+ self._client = client
209
+
210
+ def create(
211
+ self,
212
+ *,
213
+ title: str,
214
+ fields: Sequence[Dict[str, Any]],
215
+ description: Optional[str] = None,
216
+ passcode: Optional[str] = None,
217
+ expired_at: Optional[str] = None,
218
+ access_counts_left: Optional[int] = None,
219
+ timed_view: Optional[int] = None,
220
+ idempotency_key: Optional[str] = None,
221
+ content_key: Optional[bytes] = None,
222
+ custody: bool = False,
223
+ item_key_wrap: Optional[str] = None,
224
+ organization_id: Optional[str] = None,
225
+ ) -> Share:
226
+ """Encrypt ``fields`` locally and create a share.
227
+
228
+ Each field is ``{"key": <label>, "value": <content>, "type": <one of FIELD_TYPES>}``.
229
+ ``key`` is the visible label — not ``label``, ``name`` or ``title``, which are
230
+ silently ignored and would render every field blank. This client refuses those
231
+ rather than letting the mistake through.
232
+
233
+ Returns a :class:`Share` whose ``link`` contains the content key in its fragment.
234
+ That link is the secret; CredenShare never sees the key and cannot recover it.
235
+
236
+ ``idempotency_key`` is generated per call unless you pass one. Passing your own does
237
+ NOT make a second call a no-op: encryption is randomised per call — a fresh salt and
238
+ IV every time — so the body differs and the API refuses with
239
+ :class:`IdempotencyConflictError`. That is the header working, not failing. What it
240
+ protects is a network retry, where the same already-encrypted request is sent again;
241
+ this client performs those itself. Pass a key to control the value for your own
242
+ tracing, not to deduplicate calls.
243
+
244
+ ``custody=True`` additionally wraps the content key to the custody public key derived
245
+ from your credential's third part, so the share is readable from the dashboard later.
246
+ Without it — the default — an API-created share is custody ``"none"``: the link is the
247
+ only way back to the content, and losing it loses the secret. The custody secret is
248
+ used locally to derive a public key and is never transmitted. Pass ``item_key_wrap``
249
+ instead to supply a wrap you computed yourself.
250
+
251
+ ``content_key`` lets you create a share under a key you already hold — a link you
252
+ handed out before the create, or a fixed key in a test. It does not make the request
253
+ body reproducible. Never reuse a key across shares whose content differs: this client
254
+ draws a fresh IV each time, which is what keeps that safe, but nothing here can stop
255
+ you from carrying a key somewhere that does not.
256
+ """
257
+ content_key = content_key if content_key is not None else crypto.new_content_key()
258
+
259
+ if custody and item_key_wrap is not None:
260
+ raise InvalidArgumentError("pass either custody=True or item_key_wrap, not both")
261
+ if custody:
262
+ secret = self._client.credential.custody_secret
263
+ if not secret:
264
+ raise CustodySecretMissingError(
265
+ "custody=True needs a three-part credential "
266
+ "'crs_sk_live_<keyId>.<authSecret>.<custodySecret>'; this one has two "
267
+ "parts, so there is no custody key to wrap to"
268
+ )
269
+ item_key_wrap = crypto.wrap_to_public_key(
270
+ content_key, crypto.custody_keypair(secret).public_key_raw
271
+ )
272
+
273
+ blob = crypto.encrypt_content(content_key, fields, passcode)
274
+
275
+ body: Dict[str, Any] = {
276
+ "title": title,
277
+ "encryption_type": ENCRYPTION_TYPE,
278
+ "data": blob,
279
+ "access_token": crypto.access_token(content_key),
280
+ }
281
+ if description is not None:
282
+ body["description"] = description
283
+ if passcode is not None:
284
+ body["passcode_verifier"] = crypto.passcode_verifier(passcode)
285
+ if expired_at is not None:
286
+ body["expired_at"] = expired_at
287
+ if access_counts_left is not None:
288
+ body["access_counts_left"] = access_counts_left
289
+ if timed_view is not None:
290
+ body["timed_view"] = timed_view
291
+ if item_key_wrap is not None:
292
+ body["item_key_wrap"] = item_key_wrap
293
+ if organization_id is not None:
294
+ body["organization_id"] = organization_id
295
+
296
+ # Required by the API, not optional. A retried automation must not create a second
297
+ # copy of a credential in the world, with its own link and audit trail, that the
298
+ # caller does not know exists.
299
+ headers = {"Idempotency-Key": idempotency_key or str(uuid.uuid4())}
300
+
301
+ data = self._client._request("POST", "/shares", json=body, headers=headers)
302
+ short_code = data["short_code"]
303
+ return Share(
304
+ short_code=short_code,
305
+ link=self._client.link_for(short_code, content_key),
306
+ content_key=content_key,
307
+ expired_at=data.get("expired_at"),
308
+ custody=data.get("custody"),
309
+ )
310
+
311
+ def list(self, *, limit: int = 25, page: int = 1) -> ShareList:
312
+ """One page of the account's shares, newest first. Metadata only.
313
+
314
+ The returned list carries ``page``, ``limit``, ``total``, ``total_pages`` and
315
+ ``has_more``. Use :meth:`iter_all` to walk every page.
316
+ """
317
+ data = self._client._request("GET", "/shares", params={"limit": limit, "page": page})
318
+ rows = data.get("shares") or data.get("data") or []
319
+ pagination = data.get("pagination") or {}
320
+ return ShareList(
321
+ [
322
+ ShareSummary(short_code=r["short_code"], expired_at=r.get("expired_at"))
323
+ for r in rows
324
+ ],
325
+ page=pagination.get("page", page),
326
+ limit=pagination.get("limit", limit),
327
+ total=pagination.get("total"),
328
+ total_pages=pagination.get("total_pages"),
329
+ )
330
+
331
+ def iter_all(self, *, limit: int = 100) -> Iterator[ShareSummary]:
332
+ """Every share, page by page.
333
+
334
+ Written here because the hand-rolled version is usually wrong in the same way: it
335
+ stops on the first page shorter than ``limit``, which is a page the server is
336
+ entitled to return in the middle of a result set.
337
+ """
338
+ page = 1
339
+ while True:
340
+ batch = self.list(limit=limit, page=page)
341
+ yield from batch
342
+ if not batch.has_more:
343
+ return
344
+
345
+ # The API echoing a page number other than the one asked for makes progress
346
+ # unobservable, so no termination condition can be trusted. Refuse rather than
347
+ # loop - Go and Rust do the same.
348
+ if batch.page != page:
349
+ raise ApiError(
350
+ f"asked for page {page} and the API answered with page {batch.page}, so "
351
+ f"paging cannot be trusted to terminate"
352
+ )
353
+ if page >= MAX_PAGES:
354
+ raise ApiError(
355
+ f"stopped after {MAX_PAGES} pages without the API signalling the end of "
356
+ f"the result set. A walk that cannot terminate is worse than one that "
357
+ f"stops and says so."
358
+ )
359
+ page += 1
360
+
361
+ def get(self, short_code: str) -> ShareSummary:
362
+ """One share's metadata.
363
+
364
+ Does not consume a view, evaluate a passcode, or return content. A share belonging to
365
+ another account reports exactly as one that does not exist.
366
+ """
367
+ data = self._client._request("GET", f"/shares/{quote(short_code, safe='')}")
368
+ return ShareSummary(
369
+ short_code=data.get("short_code", short_code),
370
+ expired_at=data.get("expired_at"),
371
+ )
372
+
373
+ def expire(self, short_code: str) -> None:
374
+ """Expire a share immediately.
375
+
376
+ Irreversible: afterwards the content is unrecoverable by anyone, including
377
+ CredenShare — the key was never ours, and now the ciphertext is gone too.
378
+
379
+ The share is REMOVED, not flagged. A later :meth:`get` raises
380
+ :class:`NotFoundError` rather than returning a row with an expiry set, and it drops
381
+ out of :meth:`list`. Worth knowing if you reconcile against your own records: a
382
+ share you expired and one that never existed look identical afterwards.
383
+
384
+ A key can only expire shares its own account created. A short code belonging to
385
+ somebody else reports as not-found, so this cannot be used to probe for shares
386
+ elsewhere — and an organization-scoped key cannot expire a colleague's share even
387
+ though :meth:`list` shows it.
388
+ """
389
+ self._client._request("DELETE", f"/shares/{quote(short_code, safe='')}")
390
+
391
+
392
+ class CredenShare:
393
+ """The API client.
394
+
395
+ ``credential`` accepts the two- or three-part form. If you pass the three-part one, the
396
+ custody secret stays on this machine — it is used to derive a keypair locally and is
397
+ never transmitted.
398
+ """
399
+
400
+ def __init__(
401
+ self,
402
+ credential: str,
403
+ *,
404
+ base_url: str = DEFAULT_BASE_URL,
405
+ link_origin: str = DEFAULT_LINK_ORIGIN,
406
+ timeout: float = 30.0,
407
+ max_retries: int = DEFAULT_MAX_RETRIES,
408
+ transport: Optional[httpx.BaseTransport] = None,
409
+ ) -> None:
410
+ self.credential = Credential.parse(credential)
411
+ self._max_retries = max(0, max_retries)
412
+ self._base_url = base_url.rstrip("/")
413
+ self._link_origin = link_origin.rstrip("/")
414
+ self._client = httpx.Client(
415
+ base_url=self._base_url,
416
+ timeout=timeout,
417
+ transport=transport,
418
+ headers={
419
+ "Authorization": f"Bearer {self.credential.bearer}",
420
+ "Content-Type": "application/json",
421
+ "User-Agent": _user_agent(),
422
+ },
423
+ )
424
+ self.shares = _Shares(self)
425
+
426
+ # ── context manager ──────────────────────────────────────────────────────────────
427
+
428
+ def __enter__(self) -> "CredenShare":
429
+ return self
430
+
431
+ def __exit__(self, *exc: Any) -> None:
432
+ self.close()
433
+
434
+ def close(self) -> None:
435
+ self._client.close()
436
+
437
+ # ── links ────────────────────────────────────────────────────────────────────────
438
+
439
+ def link_for(self, short_code: str, content_key: bytes) -> str:
440
+ """Assemble a recipient link.
441
+
442
+ The key lives in the fragment, which browsers never send to a server. That is what
443
+ makes the link readable by its holder and opaque to us.
444
+ """
445
+ return f"{self._link_origin}/{short_code}#{crypto.encode_fragment(content_key)}"
446
+
447
+ def read_link(self, link: str, passcode: Optional[str] = None) -> List[Dict[str, Any]]:
448
+ """Fetch and decrypt a share from a full link.
449
+
450
+ Not implemented against ``/v1``: the recipient path is deliberately absent from the
451
+ API, because bearer auth skips the proof-of-work and captcha gates that protect it,
452
+ and exposing it to a credential would be an enumeration bypass. Open the link in a
453
+ browser, or decrypt a blob you already hold with
454
+ :func:`credenshare.decrypt_content`.
455
+ """
456
+ raise NotImplementedError(
457
+ "the recipient read path is not exposed over the API by design; open the link in "
458
+ "a browser, or use decrypt_content() on a blob you already have"
459
+ )
460
+
461
+ # ── transport ────────────────────────────────────────────────────────────────────
462
+
463
+ def _request(
464
+ self,
465
+ method: str,
466
+ path: str,
467
+ *,
468
+ json: Optional[Dict[str, Any]] = None,
469
+ params: Optional[Dict[str, Any]] = None,
470
+ headers: Optional[Dict[str, str]] = None,
471
+ ) -> Dict[str, Any]:
472
+ # Belt and braces. `bearer` is assembled from parts so a custody secret cannot reach
473
+ # the header, but this asserts the property at the boundary rather than trusting a
474
+ # constructor three files away.
475
+ if self.credential.custody_secret and self.credential.custody_secret in (
476
+ self._client.headers.get("Authorization") or ""
477
+ ):
478
+ raise CustodySecretTransmittedError(
479
+ "the custody secret was about to be transmitted; rotate this credential"
480
+ )
481
+
482
+ # Retry only the failures that prove nothing was received. A 5xx might have
483
+ # committed and this client cannot tell, so it is surfaced rather than repeated. A
484
+ # POST is safe to repeat here because the body and the Idempotency-Key are both
485
+ # byte-identical on the second attempt — which is what the header is for.
486
+ attempt = 0
487
+ delivered = False
488
+ while True:
489
+ try:
490
+ response = self._client.request(
491
+ method, path, json=json, params=params, headers=headers
492
+ )
493
+ break
494
+ except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as exc:
495
+ # A read timeout is not a connect failure. The request was written to an
496
+ # established connection, so the server may have committed it; saying
497
+ # "nothing was created" here is what makes a caller retry with a new key
498
+ # and end up with two copies of one secret.
499
+ delivered = delivered or isinstance(exc, httpx.ReadTimeout)
500
+ if attempt >= self._max_retries:
501
+ if delivered:
502
+ raise DeliveryUnknownError(
503
+ f"the request was delivered but no response was read after "
504
+ f"{attempt + 1} attempt(s): {exc}",
505
+ attempts=attempt + 1,
506
+ ) from exc
507
+ raise ServiceUnavailableError(
508
+ f"could not reach the API after {attempt + 1} attempt(s): {exc}"
509
+ ) from exc
510
+ # Plain exponential backoff, no jitter: the retry count is 2 by default, so
511
+ # a thundering herd is not the failure mode worth complicating this for.
512
+ time.sleep(0.5 * (2**attempt))
513
+ attempt += 1
514
+
515
+ if response.is_success:
516
+ if not response.content:
517
+ return {}
518
+ try:
519
+ parsed: Dict[str, Any] = response.json()
520
+ except ValueError:
521
+ return {}
522
+ return parsed
523
+ raise _error_for(response)
524
+
525
+
526
+ def _error_for(response: httpx.Response) -> ApiError:
527
+ """Map a failed response onto an error whose type implies the remedy."""
528
+ message = f"HTTP {response.status_code}"
529
+ code: Optional[int] = None
530
+ try:
531
+ parsed = response.json()
532
+ # A JSON body that is not an object (a bare string, a list) is still a failed
533
+ # response. Reading .get off it would raise AttributeError from inside the error
534
+ # path, replacing the API's error with a bug in this client.
535
+ payload = parsed if isinstance(parsed, dict) else {}
536
+ message = payload.get("message") or (
537
+ parsed[:200] if isinstance(parsed, str) and parsed else message
538
+ )
539
+ code = payload.get("error_code")
540
+ except ValueError:
541
+ if response.text:
542
+ message = response.text[:200]
543
+
544
+ request_id = response.headers.get("x-request-id") or response.headers.get("x-amzn-requestid")
545
+ kwargs = {"status": response.status_code, "code": code, "request_id": request_id}
546
+
547
+ if response.status_code == 401:
548
+ return AuthenticationError(message, **kwargs)
549
+ if response.status_code == 403:
550
+ # A spent allowance is a 403 like a missing scope, but the remedies are opposite:
551
+ # one needs a plan change, the other a different key. The numeric code is what
552
+ # separates them.
553
+ if code == _QUOTA_EXCEEDED_CODE:
554
+ return QuotaExceededError(message, **kwargs)
555
+ return PermissionError_(message, **kwargs)
556
+ if response.status_code == 404:
557
+ return NotFoundError(message, **kwargs)
558
+ if response.status_code == 409 and code in (
559
+ _IDEMPOTENCY_CONFLICT_CODE,
560
+ _IDEMPOTENCY_IN_FLIGHT_CODE,
561
+ ):
562
+ # Both arrive as IdempotencyConflictError so `except` keeps working; err.code
563
+ # separates them, and the docstring says which is which. 106 previously fell through
564
+ # to a bare ApiError, so the documented discrimination could not be performed at all.
565
+ return IdempotencyConflictError(message, **kwargs)
566
+ if response.status_code == 429:
567
+ retry_after = response.headers.get("retry-after")
568
+ return RateLimitError(
569
+ message,
570
+ retry_after=int(retry_after) if retry_after and retry_after.isdigit() else None,
571
+ **kwargs,
572
+ )
573
+ if response.status_code == 503:
574
+ return ServiceUnavailableError(message, **kwargs)
575
+ return ApiError(message, **kwargs)
576
+
577
+
578
+ def _user_agent() -> str:
579
+ from . import __version__
580
+
581
+ return f"credenshare-python/{__version__}"