kc-sdk-python 5.3.0__tar.gz → 5.3.1__tar.gz

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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kc-sdk-python
3
- Version: 5.3.0
3
+ Version: 5.3.1
4
4
  Summary: Kvindo Cloud Python SDK — typed client for managing Kvindo Cloud infrastructure (VMs, S3, Kubernetes, load balancers, VPCs, PostgreSQL) via the REST API
5
5
  Author: Kvindo
6
6
  License-Expression: MIT
@@ -313,6 +313,60 @@ class KcResourceGetByLabelsResponse(object):
313
313
  # outside this set is an unexpected transport/server failure and is raised instead.
314
314
  _HANDLED_STATUS_CODES = [200, 400, 401, 403, 409, 422]
315
315
 
316
+ # Defaults for the SubmitLockBusy/TransactionDeleteLockBusy retry below - overridable per-call
317
+ # for a caller that wants a shorter fail-fast budget (e.g. an interactive script) instead of
318
+ # blocking like an async job worker would.
319
+ _DEFAULT_LOCK_BUSY_RETRY_BUDGET_SECONDS = 1200 # ~20min, matches kc_svc_job.py's existing precedent
320
+ _DEFAULT_LOCK_BUSY_RETRY_DELAY_SECONDS = 30 # fallback when Retry-After is absent/unparseable
321
+
322
+
323
+ def _parse_retry_after_seconds(value) -> float:
324
+ """Parse an HTTP `Retry-After` header value.
325
+
326
+ This API only ever sends the RFC 9110 delta-seconds form (a plain integer string) -
327
+ see `CloudApiControllerBase.SetRetryAfterIfLockBusy` on the server side. Falls back to
328
+ the default delay on a missing or malformed header rather than raising, since a
329
+ busy-lock retry should never itself crash on a header-parsing edge case.
330
+ """
331
+ try:
332
+ return max(0.0, float(value))
333
+ except (TypeError, ValueError):
334
+ return _DEFAULT_LOCK_BUSY_RETRY_DELAY_SECONDS
335
+
336
+
337
+ def _retry_while_lock_busy(perform_request, is_busy, retry_budget_seconds):
338
+ """Call `perform_request()` (returns `(requests.Response, parsed_result)`), retrying
339
+ while `is_busy(parsed_result)` is True, for up to `retry_budget_seconds` of wall-clock
340
+ time (measured end-to-end across the whole loop, including each `perform_request()`
341
+ call's own latency, not just time spent sleeping between attempts - a slow busy request
342
+ itself eats into the budget, which is deliberate: the contract is "give up trying after
343
+ this much real time has passed", not "sleep this much total"). Returns the last
344
+ `(response, parsed_result)` pair - either the first non-busy result, or the last busy
345
+ one once the budget is exhausted (never raises on exhaustion; exhaustion just means the
346
+ caller sees the same busy errorCode/errorMessage they'd have seen immediately before
347
+ this retry existed).
348
+
349
+ Any exception raised by `perform_request()` or `is_busy()` propagates immediately,
350
+ uncaught - a malformed response or a genuine HTTP failure is a different problem than
351
+ lock contention and must never be swallowed by a retry meant only for the latter.
352
+ """
353
+ deadline = time.monotonic() + retry_budget_seconds
354
+ attempt = 0
355
+ while True:
356
+ attempt += 1
357
+ response, result = perform_request()
358
+ if not is_busy(result):
359
+ return response, result
360
+ remaining = deadline - time.monotonic()
361
+ if remaining <= 0:
362
+ return response, result
363
+ delay = min(_parse_retry_after_seconds(response.headers.get("Retry-After")), remaining)
364
+ logger.warning(
365
+ f"{result.errorCode} (attempt {attempt}), retrying in {delay:.0f}s "
366
+ f"({remaining:.0f}s left in retry budget)"
367
+ )
368
+ time.sleep(delay)
369
+
316
370
 
317
371
  class KcResourceClient:
318
372
  """Client for a single resource type of the Kvindo Cloud API.
@@ -357,13 +411,23 @@ class KcResourceClient:
357
411
  "Content-Type": "application/json-patch+json",
358
412
  }
359
413
 
360
- def delete(self, id: str, wait=False) -> KcResourceDeleteResponse:
414
+ def delete(
415
+ self,
416
+ id: str,
417
+ wait=False,
418
+ transaction_delete_lock_retry_budget_seconds: float = _DEFAULT_LOCK_BUSY_RETRY_BUDGET_SECONDS,
419
+ ) -> KcResourceDeleteResponse:
361
420
  """Delete a resource by id (asynchronous).
362
421
 
363
422
  Args:
364
423
  id: id of the resource to delete.
365
424
  wait: if True, block (up to 300s) until the delete reconciles via
366
425
  `wait_request_satisfied` before returning.
426
+ transaction_delete_lock_retry_budget_seconds: how long to keep retrying a
427
+ TransactionDeleteLockBusy response (only reachable when deleting a
428
+ `transaction` resource specifically) before giving up and returning it
429
+ as-is. Default ~20min. Pass 0 to disable retrying and get the immediate
430
+ fire-once behavior back.
367
431
 
368
432
  Returns:
369
433
  KcResourceDeleteResponse with `requestId` to poll, or `errorMessage`/
@@ -374,24 +438,30 @@ class KcResourceClient:
374
438
  """
375
439
  url = f"{self.__api_url}/api/v1/{self.__resource_type}/{id}"
376
440
 
377
- response = create_http_client_with_retries(verify_ssl=self.__verify_ssl).delete(url, headers=self.__headers())
441
+ def _do_delete():
442
+ response = create_http_client_with_retries(verify_ssl=self.__verify_ssl).delete(url, headers=self.__headers())
443
+ logger.debug(
444
+ f"Got {response.status_code} status code while making request DELETE {url}\nResponse body: {response.text}",
445
+ extra=self.__log_extra,
446
+ )
447
+ if response.status_code not in _HANDLED_STATUS_CODES:
448
+ raise Exception(
449
+ f"Got {response.status_code} status code while making request DELETE {url}\nResponse body: {response.text}"
450
+ )
451
+ return response, KcResourceDeleteResponse.Schema().load(response.json())
378
452
 
379
- logger.debug(
380
- f"Got {response.status_code} status code while making request DELETE {url}\nResponse body: {response.text}",
381
- extra=self.__log_extra,
453
+ _, result = _retry_while_lock_busy(
454
+ _do_delete,
455
+ lambda r: r.errorCode == KcApiModificationErrorCode.TransactionDeleteLockBusy,
456
+ transaction_delete_lock_retry_budget_seconds,
382
457
  )
383
-
384
- if response.status_code in _HANDLED_STATUS_CODES:
385
- result: KcResourceDeleteResponse = KcResourceDeleteResponse.Schema().load(
386
- response.json()
387
- )
388
- if wait:
389
- self.wait_request_satisfied(result.requestId, 300)
390
- return result
391
- else:
392
- raise Exception(
393
- f"Got {response.status_code} status code while making request DELETE {url}\nResponse body: {response.text}"
394
- )
458
+ # requestId is only set on a genuinely-accepted delete - a still-busy result after the
459
+ # retry budget is exhausted has requestId=None, and wait_request_satisfied(None, ...)
460
+ # would be a meaningless poll against a nonexistent request. Only wait on a real
461
+ # acceptance.
462
+ if wait and result.requestId is not None:
463
+ self.wait_request_satisfied(result.requestId, 300)
464
+ return result
395
465
 
396
466
  def read(self, id: str) -> KcResourceReadResponse:
397
467
  """Read a single resource by id.
@@ -528,7 +598,11 @@ class KcResourceClient:
528
598
 
529
599
  return result
530
600
 
531
- def create_or_update(self, data: dict) -> KcResourceCreateResponse:
601
+ def create_or_update(
602
+ self,
603
+ data: dict,
604
+ submit_lock_retry_budget_seconds: float = _DEFAULT_LOCK_BUSY_RETRY_BUDGET_SECONDS,
605
+ ) -> KcResourceCreateResponse:
532
606
  """Create a resource, or update it if one with the same id already exists.
533
607
 
534
608
  Idempotent on the resource id: if no id is present in `data` (neither
@@ -540,6 +614,13 @@ class KcResourceClient:
540
614
  data: the resource body, either the kubectl-style envelope
541
615
  ({"metadata": {...}, "spec": {...}}) or the flat shape. **Mutated
542
616
  in place** to inject the generated id when absent.
617
+ submit_lock_retry_budget_seconds: how long to keep retrying a
618
+ SubmitLockBusy response (the per-organization submit lock losing a
619
+ concurrent-submit race - safe to retry the identical request, see
620
+ `KcApiModificationErrorCode.SubmitLockBusy`) before giving up and
621
+ returning it as-is. Default ~20min, matching kc_svc_job.py's own
622
+ retry budget for this same error. Pass 0 to disable retrying and get
623
+ the immediate fire-once behavior back.
543
624
 
544
625
  Returns:
545
626
  KcResourceCreateResponse with `requestId`/`resourceId`, or
@@ -565,19 +646,24 @@ class KcResourceClient:
565
646
 
566
647
  url = f"{self.__api_url}/api/v1/{self.__resource_type}"
567
648
 
568
- response = create_http_client_with_retries(verify_ssl=self.__verify_ssl).put(url, json=data, headers=self.__headers())
649
+ def _do_put():
650
+ response = create_http_client_with_retries(verify_ssl=self.__verify_ssl).put(url, json=data, headers=self.__headers())
651
+ logger.debug(
652
+ f"Got {response.status_code} status code while making request PUT {url}\nRequest body: {data}\nResponse body: {response.text}",
653
+ extra=self.__log_extra,
654
+ )
655
+ if response.status_code not in _HANDLED_STATUS_CODES:
656
+ raise Exception(
657
+ f"Got {response.status_code} status code while making request PUT {url}\nRequest body: {data}\nResponse body: {response.text}"
658
+ )
659
+ return response, KcResourceCreateResponse.Schema().load(response.json())
569
660
 
570
- logger.debug(
571
- f"Got {response.status_code} status code while making request PUT {url}\nRequest body: {data}\nResponse body: {response.text}",
572
- extra=self.__log_extra,
661
+ _, result = _retry_while_lock_busy(
662
+ _do_put,
663
+ lambda r: r.errorCode == KcApiModificationErrorCode.SubmitLockBusy,
664
+ submit_lock_retry_budget_seconds,
573
665
  )
574
-
575
- if response.status_code in _HANDLED_STATUS_CODES:
576
- return KcResourceCreateResponse.Schema().load(response.json())
577
- else:
578
- raise Exception(
579
- f"Got {response.status_code} status code while making request PUT {url}\nRequest body: {data}\nResponse body: {response.text}"
580
- )
666
+ return result
581
667
 
582
668
  def create(self, data: dict) -> KcResourceCreateResponse:
583
669
  """Left for compatibility! Use create_or_update instead.
@@ -587,13 +673,18 @@ class KcResourceClient:
587
673
  """
588
674
  return self.create_or_update(data)
589
675
 
590
- def update(self, data: dict) -> KcResourceUpdateResponse:
676
+ def update(
677
+ self,
678
+ data: dict,
679
+ submit_lock_retry_budget_seconds: float = _DEFAULT_LOCK_BUSY_RETRY_BUDGET_SECONDS,
680
+ ) -> KcResourceUpdateResponse:
591
681
  """Left for compatibility! Use create_or_update instead.
592
682
 
593
683
  Args:
594
684
  data: see `create_or_update`.
685
+ submit_lock_retry_budget_seconds: see `create_or_update`.
595
686
  """
596
- return self.create_or_update(data)
687
+ return self.create_or_update(data, submit_lock_retry_budget_seconds)
597
688
 
598
689
 
599
690
  class KcClient:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kc-sdk-python
3
- Version: 5.3.0
3
+ Version: 5.3.1
4
4
  Summary: Kvindo Cloud Python SDK — typed client for managing Kvindo Cloud infrastructure (VMs, S3, Kubernetes, load balancers, VPCs, PostgreSQL) via the REST API
5
5
  Author: Kvindo
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kc-sdk-python"
7
- version = "5.3.0"
7
+ version = "5.3.1"
8
8
  description = "Kvindo Cloud Python SDK — typed client for managing Kvindo Cloud infrastructure (VMs, S3, Kubernetes, load balancers, VPCs, PostgreSQL) via the REST API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
File without changes
File without changes
File without changes