|
46 | 46 | from convilyn._version import __version__ |
47 | 47 | from convilyn.exceptions import ( |
48 | 48 | APIError, |
| 49 | + ChargeUnavailableError, |
| 50 | + FreeTierBlockedError, |
| 51 | + InsufficientCreditsError, |
49 | 52 | PlanRequiredError, |
50 | 53 | QuotaExceededError, |
51 | 54 | RateLimitError, |
52 | 55 | RetryExhaustedError, |
53 | 56 | S3UploadError, |
| 57 | + SpecNotPricedError, |
54 | 58 | ) |
55 | 59 |
|
56 | 60 | # Backend emits any of these on 402 to signal a plan-tier mismatch. |
|
65 | 69 | } |
66 | 70 | ) |
67 | 71 |
|
| 72 | +# Backend emits either of these on 403 when a Free-plan gate refuses a run |
| 73 | +# before any charge (``services/billing/quote.py`` ``QuoteFreeBlocked``). Same |
| 74 | +# shape and same rationale as the frozenset above: a third Free gate is one |
| 75 | +# line here and changes no dispatch. |
| 76 | +_FREE_TIER_BLOCKED_CODES: frozenset[str] = frozenset( |
| 77 | + { |
| 78 | + "spec_not_allowed_on_free", |
| 79 | + "free_cost_cap_exceeded", |
| 80 | + } |
| 81 | +) |
| 82 | + |
| 83 | +# 409 billing refusals, keyed to their class. A dict rather than an `if`/`elif` |
| 84 | +# chain because the two mean OPPOSITE things about retrying — permanent vs |
| 85 | +# transient — and a reader should see that as two rows, not as an order of |
| 86 | +# tests (`coding-style.md` O). |
| 87 | +_BILLING_CONFLICT_ERRORS: dict[str, type[APIError]] = { |
| 88 | + "SPEC_NOT_PRICED": SpecNotPricedError, |
| 89 | + "CHARGE_UNAVAILABLE": ChargeUnavailableError, |
| 90 | +} |
| 91 | + |
68 | 92 | DEFAULT_BASE_URL = "https://api.convilyn.corenovus.com" |
69 | 93 | DEFAULT_TIMEOUT = 30.0 |
70 | 94 | ENV_BASE_URL = "CONVILYN_BASE_URL" |
@@ -527,10 +551,15 @@ def _decode_error(response: httpx.Response) -> APIError: |
527 | 551 | * 429 → :class:`RateLimitError` |
528 | 552 | * 402 + code in :data:`_PLAN_REQUIRED_CODES` → :class:`PlanRequiredError` |
529 | 553 | * 402 + code = ``QUOTA_EXCEEDED`` → :class:`QuotaExceededError` |
| 554 | + * 402 + code = ``INSUFFICIENT_CREDITS`` → :class:`InsufficientCreditsError` |
| 555 | + * 403 + code in :data:`_FREE_TIER_BLOCKED_CODES` → :class:`FreeTierBlockedError` |
| 556 | + * 409 + code in :data:`_BILLING_CONFLICT_ERRORS` → that class |
530 | 557 | * Otherwise → base :class:`APIError` |
531 | 558 |
|
532 | | - Unknown 402 codes fall through to ``APIError`` — forward-compat for |
533 | | - new tier signals the SDK doesn't know about yet. |
| 559 | + Unknown codes fall through to ``APIError`` — forward-compat for signals the |
| 560 | + SDK doesn't know about yet. That is deliberate on EVERY status here, not |
| 561 | + just 402: a 403 the SDK cannot name is still a 403, and inventing a type for |
| 562 | + it would assert a remediation nobody verified. |
534 | 563 | """ |
535 | 564 | status = response.status_code |
536 | 565 | try: |
@@ -572,6 +601,40 @@ def _decode_error(response: httpx.Response) -> APIError: |
572 | 601 | ), |
573 | 602 | upgrade_url=upgrade_url, |
574 | 603 | ) |
| 604 | + if code == "INSUFFICIENT_CREDITS": |
| 605 | + # These two ride INSIDE `details`, not beside it — the refusal is |
| 606 | + # built as `detail={code, message, details={requiredCredits, ...}}` |
| 607 | + # (`api/v1/goal_lane/_charging.py`), so `inner` holds the dict and |
| 608 | + # not the numbers. Reading them off `inner` would silently produce |
| 609 | + # None on every real refusal while looking exactly like the |
| 610 | + # QUOTA_EXCEEDED branch above. |
| 611 | + credit_detail = details if isinstance(details, dict) else {} |
| 612 | + return InsufficientCreditsError( |
| 613 | + status, |
| 614 | + code, |
| 615 | + message, |
| 616 | + details, |
| 617 | + required_credits=_coerce_int( |
| 618 | + _first_present(credit_detail, "requiredCredits", "required_credits") |
| 619 | + ), |
| 620 | + available_credits=_coerce_int( |
| 621 | + _first_present(credit_detail, "availableCredits", "available_credits") |
| 622 | + ), |
| 623 | + ) |
| 624 | + |
| 625 | + if status == 403 and code in _FREE_TIER_BLOCKED_CODES: |
| 626 | + return FreeTierBlockedError( |
| 627 | + status, |
| 628 | + code, |
| 629 | + message, |
| 630 | + details, |
| 631 | + upgrade_url=inner.get("upgrade_url") or inner.get("upgradeUrl"), |
| 632 | + ) |
| 633 | + |
| 634 | + if status == 409: |
| 635 | + conflict = _BILLING_CONFLICT_ERRORS.get(code) |
| 636 | + if conflict is not None: |
| 637 | + return conflict(status, code, message, details) |
575 | 638 |
|
576 | 639 | return APIError(status, code, message, details) |
577 | 640 |
|
@@ -635,6 +698,25 @@ def _flatten_error_envelope(payload: dict[str, Any]) -> dict[str, Any]: |
635 | 698 | return nested if isinstance(nested, dict) else payload |
636 | 699 |
|
637 | 700 |
|
| 701 | +def _first_present(payload: dict[str, Any], *keys: str) -> Any: |
| 702 | + """The first key that is PRESENT, not the first that is truthy. |
| 703 | +
|
| 704 | + ``payload.get("a") or payload.get("a_snake")`` is the idiom used elsewhere |
| 705 | + in this module for camel/snake aliases, and it is wrong for any field whose |
| 706 | + zero value is meaningful. ``availableCredits`` is exactly that field: it is |
| 707 | + ``0`` for the caller :class:`InsufficientCreditsError` exists for — someone |
| 708 | + whose balance is empty — and ``0 or None`` is ``None``, so the number would |
| 709 | + read as "the server did not send it" on the most common refusal there is. |
| 710 | +
|
| 711 | + Returns ``None`` when no key is present, which :func:`_coerce_int` then |
| 712 | + passes through as the genuine "unknown". |
| 713 | + """ |
| 714 | + for key in keys: |
| 715 | + if key in payload: |
| 716 | + return payload[key] |
| 717 | + return None |
| 718 | + |
| 719 | + |
638 | 720 | def _coerce_int(value: Any) -> int | None: |
639 | 721 | """Best-effort ``int`` coercion for wire fields that may arrive as str.""" |
640 | 722 | if value is None: |
|
0 commit comments