|
| 1 | +"""Runtime SDK ↔ backend contract-version guard. |
| 2 | +
|
| 3 | +Complements the *build-time* cross-version compatibility tests (``tests/compat``): |
| 4 | +
|
| 5 | +- **Build-time** (CI): is this *client* compatible with the window of supported server |
| 6 | + contracts (``min-supported``..``current``)? |
| 7 | +- **Runtime** (this module): is the *server* the SDK is pointed at within that window? |
| 8 | +
|
| 9 | +It runs once at ACP/worker startup, reads the backend's contract version (the version |
| 10 | +the server already reports via ``/openapi.json`` ``info.version``), and **fails fast with |
| 11 | +an actionable error** if the backend is older than this SDK supports — instead of the |
| 12 | +mismatch surfacing later as opaque 500s / missing-field errors deep in a request. |
| 13 | +
|
| 14 | +``MIN_BACKEND_CONTRACT`` is the same source of truth as the ``min-supported`` server |
| 15 | +contract in ``tests/compat/server_specs/manifest.json``: the oldest agentex backend this |
| 16 | +SDK version supports. Bump both together when a breaking change raises the floor. |
| 17 | +""" |
| 18 | + |
| 19 | +from __future__ import annotations |
| 20 | + |
| 21 | +import os |
| 22 | +import re |
| 23 | + |
| 24 | +import httpx |
| 25 | + |
| 26 | +from agentex.lib.utils.logging import make_logger |
| 27 | + |
| 28 | +logger = make_logger(__name__) |
| 29 | + |
| 30 | +# Oldest agentex backend contract this SDK is compatible with. |
| 31 | +# Keep in sync with the `min-supported` spec in tests/compat (#407); the version axis |
| 32 | +# itself comes from scale-agentex release tags (#321). Bump on a breaking SDK change. |
| 33 | +MIN_BACKEND_CONTRACT = "0.1.0" |
| 34 | + |
| 35 | +SKIP_ENV = "AGENTEX_SKIP_VERSION_CHECK" |
| 36 | + |
| 37 | +# Full-string SemVer. Accepts: `1.2.3`, leading `v`, surrounding whitespace, `-prerelease` |
| 38 | +# (captured), `+build` (ignored). Anchored at both ends so a malformed tail (`0.1.0rc1`, |
| 39 | +# `0.1.0.1`) is rejected → None → "unknown, proceed", not silently coerced to stable `0.1.0`. |
| 40 | +_VERSION_RE = re.compile( |
| 41 | + r"^\s*v?(\d+)\.(\d+)\.(\d+)" # major.minor.patch |
| 42 | + r"(?:-([0-9A-Za-z.-]+))?" # optional -prerelease (captured) |
| 43 | + r"(?:\+[0-9A-Za-z.-]+)?" # optional +build metadata (ignored) |
| 44 | + r"\s*$" |
| 45 | +) |
| 46 | + |
| 47 | + |
| 48 | +class IncompatibleBackendError(RuntimeError): |
| 49 | + """Raised when the agentex backend is older than this SDK's minimum supported contract.""" |
| 50 | + |
| 51 | + |
| 52 | +def _parse(version: str | None) -> tuple[int, int, int, str | None] | None: |
| 53 | + """Parse ``major.minor.patch[-prerelease]`` → ``(major, minor, patch, prerelease)``. |
| 54 | +
|
| 55 | + ``prerelease`` is the raw dot-separated identifier string (e.g. ``"rc.1"``), or None for |
| 56 | + a stable release. Build metadata (after ``+``) is ignored. Returns None if unparseable. |
| 57 | + """ |
| 58 | + m = _VERSION_RE.match(version or "") |
| 59 | + if not m: |
| 60 | + return None |
| 61 | + return (int(m.group(1)), int(m.group(2)), int(m.group(3)), m.group(4) or None) |
| 62 | + |
| 63 | + |
| 64 | +# Comparable SemVer precedence key. The 4th element keeps a uniform shape across stable and |
| 65 | +# prerelease so the whole tuple is orderable: (rank, identifiers), where stable rank 1 > prerelease |
| 66 | +# rank 0 (and the identifier list is only ever compared when both sides are prereleases, rank 0). |
| 67 | +_PreKey = tuple[int, int, int, tuple[int, list[tuple[int, int, str]]]] |
| 68 | + |
| 69 | + |
| 70 | +def _precedence_key(parsed: tuple[int, int, int, str | None]) -> _PreKey: |
| 71 | + """SemVer §11 precedence key (directly comparable with ``<``). |
| 72 | +
|
| 73 | + A stable release outranks any prerelease of the same triplet (``0.1.0-rc.1 < 0.1.0``); |
| 74 | + among prereleases, numeric identifiers rank below alphanumeric and compare field-by-field, |
| 75 | + with a longer identifier list outranking a shorter prefix-equal one. |
| 76 | + """ |
| 77 | + major, minor, patch, prerelease = parsed |
| 78 | + if prerelease is None: |
| 79 | + return (major, minor, patch, (1, [])) # stable sorts above every prerelease |
| 80 | + identifiers: list[tuple[int, int, str]] = [] |
| 81 | + for ident in prerelease.split("."): |
| 82 | + if ident.isdigit(): |
| 83 | + identifiers.append((0, int(ident), "")) # numeric: lowest class, numeric order |
| 84 | + else: |
| 85 | + identifiers.append((1, 0, ident)) # alphanumeric: higher class, lexical order |
| 86 | + return (major, minor, patch, (0, identifiers)) |
| 87 | + |
| 88 | + |
| 89 | +def _truthy(name: str) -> bool: |
| 90 | + return os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "on") |
| 91 | + |
| 92 | + |
| 93 | +async def fetch_backend_version(base_url: str, *, timeout: float = 5.0) -> str | None: |
| 94 | + """Return the backend's reported contract version (``/openapi.json`` ``info.version``), or None.""" |
| 95 | + url = base_url.rstrip("/") + "/openapi.json" |
| 96 | + try: |
| 97 | + async with httpx.AsyncClient(timeout=timeout) as client: |
| 98 | + resp = await client.get(url) |
| 99 | + resp.raise_for_status() |
| 100 | + return (resp.json().get("info") or {}).get("version") |
| 101 | + except Exception as exc: # noqa: BLE001 - any failure → unknown, handled by caller |
| 102 | + logger.warning("backend version guard: could not fetch %s (%s)", url, exc) |
| 103 | + return None |
| 104 | + |
| 105 | + |
| 106 | +async def assert_backend_compatible( |
| 107 | + base_url: str | None, |
| 108 | + *, |
| 109 | + min_version: str = MIN_BACKEND_CONTRACT, |
| 110 | + sdk_version: str | None = None, |
| 111 | +) -> None: |
| 112 | + """Fail fast at startup if the backend is older than ``min_version``. |
| 113 | +
|
| 114 | + No-op (warns, does not raise) when: |
| 115 | + - ``AGENTEX_SKIP_VERSION_CHECK`` is set (explicit bypass), |
| 116 | + - ``base_url`` is unset, |
| 117 | + - the backend version can't be determined (unreachable / unparseable) — a transient |
| 118 | + blip or a contract-less server shouldn't crash startup. |
| 119 | +
|
| 120 | + Raises ``IncompatibleBackendError`` only when the backend version is *known* and older |
| 121 | + than ``min_version``. |
| 122 | + """ |
| 123 | + if _truthy(SKIP_ENV): |
| 124 | + logger.warning("%s set — skipping backend version guard", SKIP_ENV) |
| 125 | + return |
| 126 | + if not base_url: |
| 127 | + return |
| 128 | + |
| 129 | + if sdk_version is None: |
| 130 | + from agentex._version import __version__ as sdk_version # local import to avoid cycles |
| 131 | + |
| 132 | + backend_version = await fetch_backend_version(base_url) |
| 133 | + if backend_version is None: |
| 134 | + logger.warning( |
| 135 | + "backend version guard: could not determine backend version at %s; proceeding " |
| 136 | + "(set %s=1 to silence).", |
| 137 | + base_url, |
| 138 | + SKIP_ENV, |
| 139 | + ) |
| 140 | + return |
| 141 | + |
| 142 | + backend, minimum = _parse(backend_version), _parse(min_version) |
| 143 | + if backend is None or minimum is None: |
| 144 | + logger.warning( |
| 145 | + "backend version guard: unparseable version(s) backend=%r min=%r; proceeding.", |
| 146 | + backend_version, |
| 147 | + min_version, |
| 148 | + ) |
| 149 | + return |
| 150 | + |
| 151 | + if _precedence_key(backend) < _precedence_key(minimum): |
| 152 | + raise IncompatibleBackendError( |
| 153 | + f"agentex-sdk {sdk_version} requires agentex backend >= {min_version}, " |
| 154 | + f"but {base_url} reports {backend_version}. " |
| 155 | + f"Upgrade the backend, or pin agentex-sdk to a version compatible with backend " |
| 156 | + f"{backend_version}. (Set {SKIP_ENV}=1 to bypass at your own risk.)" |
| 157 | + ) |
| 158 | + |
| 159 | + logger.info( |
| 160 | + "backend version guard OK: sdk=%s backend=%s (min=%s)", |
| 161 | + sdk_version, |
| 162 | + backend_version, |
| 163 | + min_version, |
| 164 | + ) |
0 commit comments