kc-sdk-python 5.3.0__tar.gz → 5.3.2__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.2
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.
@@ -499,9 +569,8 @@ class KcResourceClient:
499
569
  """Poll a change request once per second until it finishes or times out.
500
570
 
501
571
  Returns as soon as the request succeeds (`succeeded == True`) or fails
502
- (both `errorMessage` and `errorCode` set). On timeout it returns the last
503
- (still-pending) status rather than raising — inspect `succeeded` to tell
504
- the cases apart.
572
+ (`errorCode` set). On timeout it returns the last (still-pending) status
573
+ rather than raising — inspect `succeeded` to tell the cases apart.
505
574
 
506
575
  Args:
507
576
  request_id: the `requestId` to poll.
@@ -513,7 +582,21 @@ class KcResourceClient:
513
582
  result = self.read_request(request_id)
514
583
 
515
584
  i = 0
516
- while result.succeeded == False and result.errorMessage == None:
585
+ # Keep polling past a non-None errorMessage as long as errorCode is still None - the
586
+ # reconciler routinely stamps a benign, expected "please wait" errorMessage (e.g.
587
+ # "Temporary error. Waiting for all changes to finish first.") on EVERY throttled/gated
588
+ # retry tick while a resource is still legitimately in progress (see the various
589
+ # CloudCancelReconciliationException call sites across avant.cloud's reconcilers) -
590
+ # errorCode is what actually distinguishes a terminal failure. The old condition here
591
+ # (`result.errorMessage == None`) stopped polling and returned `succeeded=False` the
592
+ # moment ANY transient message appeared, without checking errorCode at all. Confirmed
593
+ # live via ES logs 2026-09-21 (Kubernetes E2E SDK test): the loop exited on
594
+ # `{"succeeded":false,"errorMessage":"Temporary error. Waiting for all changes to finish
595
+ # first.","errorCode":null}` mid-reconcile, and the caller (DocGuideExamples' Kubernetes
596
+ # example, and any real caller that doesn't separately check `.succeeded`) proceeded to
597
+ # read a resource that hadn't actually finished yet - e.g. status.kubeconfig was still
598
+ # None, crashing `f.write(kubeconfig)`.
599
+ while result.succeeded == False and result.errorCode == None:
517
600
  i = i + 1
518
601
  if i > timeout_seconds:
519
602
  return result # timed out while still pending
@@ -523,12 +606,16 @@ class KcResourceClient:
523
606
 
524
607
  if result.succeeded == True:
525
608
  return result # applied successfully
526
- if result.errorMessage != None and result.errorCode != None:
609
+ if result.errorCode != None:
527
610
  return result # failed terminally
528
611
 
529
612
  return result
530
613
 
531
- def create_or_update(self, data: dict) -> KcResourceCreateResponse:
614
+ def create_or_update(
615
+ self,
616
+ data: dict,
617
+ submit_lock_retry_budget_seconds: float = _DEFAULT_LOCK_BUSY_RETRY_BUDGET_SECONDS,
618
+ ) -> KcResourceCreateResponse:
532
619
  """Create a resource, or update it if one with the same id already exists.
533
620
 
534
621
  Idempotent on the resource id: if no id is present in `data` (neither
@@ -540,6 +627,13 @@ class KcResourceClient:
540
627
  data: the resource body, either the kubectl-style envelope
541
628
  ({"metadata": {...}, "spec": {...}}) or the flat shape. **Mutated
542
629
  in place** to inject the generated id when absent.
630
+ submit_lock_retry_budget_seconds: how long to keep retrying a
631
+ SubmitLockBusy response (the per-organization submit lock losing a
632
+ concurrent-submit race - safe to retry the identical request, see
633
+ `KcApiModificationErrorCode.SubmitLockBusy`) before giving up and
634
+ returning it as-is. Default ~20min, matching kc_svc_job.py's own
635
+ retry budget for this same error. Pass 0 to disable retrying and get
636
+ the immediate fire-once behavior back.
543
637
 
544
638
  Returns:
545
639
  KcResourceCreateResponse with `requestId`/`resourceId`, or
@@ -565,19 +659,24 @@ class KcResourceClient:
565
659
 
566
660
  url = f"{self.__api_url}/api/v1/{self.__resource_type}"
567
661
 
568
- response = create_http_client_with_retries(verify_ssl=self.__verify_ssl).put(url, json=data, headers=self.__headers())
662
+ def _do_put():
663
+ response = create_http_client_with_retries(verify_ssl=self.__verify_ssl).put(url, json=data, headers=self.__headers())
664
+ logger.debug(
665
+ f"Got {response.status_code} status code while making request PUT {url}\nRequest body: {data}\nResponse body: {response.text}",
666
+ extra=self.__log_extra,
667
+ )
668
+ if response.status_code not in _HANDLED_STATUS_CODES:
669
+ raise Exception(
670
+ f"Got {response.status_code} status code while making request PUT {url}\nRequest body: {data}\nResponse body: {response.text}"
671
+ )
672
+ return response, KcResourceCreateResponse.Schema().load(response.json())
569
673
 
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,
674
+ _, result = _retry_while_lock_busy(
675
+ _do_put,
676
+ lambda r: r.errorCode == KcApiModificationErrorCode.SubmitLockBusy,
677
+ submit_lock_retry_budget_seconds,
573
678
  )
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
- )
679
+ return result
581
680
 
582
681
  def create(self, data: dict) -> KcResourceCreateResponse:
583
682
  """Left for compatibility! Use create_or_update instead.
@@ -587,13 +686,18 @@ class KcResourceClient:
587
686
  """
588
687
  return self.create_or_update(data)
589
688
 
590
- def update(self, data: dict) -> KcResourceUpdateResponse:
689
+ def update(
690
+ self,
691
+ data: dict,
692
+ submit_lock_retry_budget_seconds: float = _DEFAULT_LOCK_BUSY_RETRY_BUDGET_SECONDS,
693
+ ) -> KcResourceUpdateResponse:
591
694
  """Left for compatibility! Use create_or_update instead.
592
695
 
593
696
  Args:
594
697
  data: see `create_or_update`.
698
+ submit_lock_retry_budget_seconds: see `create_or_update`.
595
699
  """
596
- return self.create_or_update(data)
700
+ return self.create_or_update(data, submit_lock_retry_budget_seconds)
597
701
 
598
702
 
599
703
  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.2
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.2"
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