From eec3f6eaa6bc477ff341a3a559d342a7f702dac7 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:24:14 -0400 Subject: [PATCH] fix(functions): preserve cached resolution error metadata --- docs/functions.md | 2 +- src/volcano_sdk/_function_resolution.py | 17 +++++++++++++-- src/volcano_sdk/functions.py | 16 +++++++++----- tests/unit/test_functions.py | 29 +++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/docs/functions.md b/docs/functions.md index f73dc60c..67b37652 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -24,7 +24,7 @@ print(result.data) print(result.version) ``` -The SDK resolves the function's invocation endpoint and caches it for the lifetime provided by Volcano. +The SDK resolves the function's invocation endpoint and caches it for the lifetime provided by Volcano. Repeated resolution of a missing name raises a new `NotFoundError` with the original message, status, and error code while the cached miss remains valid. Allow outbound requests to the resolved function domain as well as the API host. Deployments without a separate function domain use the API invocation endpoint. diff --git a/src/volcano_sdk/_function_resolution.py b/src/volcano_sdk/_function_resolution.py index f229a066..251e5604 100644 --- a/src/volcano_sdk/_function_resolution.py +++ b/src/volcano_sdk/_function_resolution.py @@ -12,8 +12,12 @@ import threading import time from dataclasses import dataclass +from typing import TYPE_CHECKING from urllib.parse import urlsplit +if TYPE_CHECKING: + from .errors import NotFoundError + MAX_ENTRIES = 1024 NEGATIVE_TTL_SECONDS = 30.0 @@ -41,6 +45,9 @@ class CachedOutcome: """A cached resolve result: a resolution, or a remembered miss when None.""" resolution: FunctionResolution | None + message: str = "Function was not found" + code: str | None = None + retry_after: int | None = None @dataclass(frozen=True, slots=True) @@ -122,13 +129,19 @@ def store( _store((api_url, authorization, name), CachedOutcome(resolution), ttl_seconds) -def store_missing(api_url: str, authorization: str, name: str) -> None: +def store_missing( + api_url: str, authorization: str, name: str, error: NotFoundError +) -> None: """Remember briefly that a name does not resolve. A caller retrying an unknown name in a loop would otherwise re-ask the server on every attempt. """ - _store((api_url, authorization, name), CachedOutcome(None), NEGATIVE_TTL_SECONDS) + _store( + (api_url, authorization, name), + CachedOutcome(None, str(error), error.code, error.retry_after), + NEGATIVE_TTL_SECONDS, + ) def _store( diff --git a/src/volcano_sdk/functions.py b/src/volcano_sdk/functions.py index 4f92c45b..324ef71e 100644 --- a/src/volcano_sdk/functions.py +++ b/src/volcano_sdk/functions.py @@ -28,7 +28,6 @@ ) _INVALID_FUNCTION_RESPONSE = "Expected a complete function response" _INVALID_FUNCTION_PAYLOAD = "Function payload must be a mapping" -_UNKNOWN_FUNCTION = "Function was not found" _HTTP_SUCCESS_MIN = 200 _HTTP_SUCCESS_MAX = 300 _HTTP_NOT_FOUND = 404 @@ -214,7 +213,12 @@ def _cached( if cached is None: return None if cached.resolution is None: - raise NotFoundError(_UNKNOWN_FUNCTION, status=_HTTP_NOT_FOUND) + raise NotFoundError( + cached.message, + status=_HTTP_NOT_FOUND, + code=cached.code, + retry_after=cached.retry_after, + ) return cached.resolution def _resolve_uncached( @@ -229,9 +233,11 @@ def _resolve_uncached( authorization=authorization, name=name, ) - if int(resolved.status_code) == _HTTP_NOT_FOUND: - _function_resolution.store_missing(api_url, authorization, name) - payload = response_payload(resolved, _HTTP_SUCCESS_MIN) + try: + payload = response_payload(resolved, _HTTP_SUCCESS_MIN) + except NotFoundError as error: + _function_resolution.store_missing(api_url, authorization, name, error) + raise resolution = self._resolution(payload, api_url) _function_resolution.store( api_url, authorization, name, resolution, self._cache_ttl(payload) diff --git a/tests/unit/test_functions.py b/tests/unit/test_functions.py index cdc82b7f..05a418d9 100644 --- a/tests/unit/test_functions.py +++ b/tests/unit/test_functions.py @@ -491,3 +491,32 @@ def __call__(self) -> float: def advance(self, seconds: float) -> None: self._now += seconds + + +def test_functions_preserves_owned_error_metadata_in_negative_cache() -> None: + transport = FakeFunctionsTransport() + transport.resolve_response = FakeResponse( + 404, {"error": "Unknown function", "code": "function_missing"}, {} + ) + client = functions_client(transport) + with pytest.raises(NotFoundError) as first: + client.functions.invoke("missing-function") + assert ( + str(first.value), + first.value.status, + first.value.code, + first.value.retry_after, + ) == ("Unknown function", 404, "function_missing", None) + first.value.args = ("changed",) + first.value.code = "changed" + first.value.retry_after = 99 + with pytest.raises(NotFoundError) as second: + client.functions.invoke("missing-function") + assert ( + str(second.value), + second.value.status, + second.value.code, + second.value.retry_after, + ) == ("Unknown function", 404, "function_missing", None) + assert second.value is not first.value + assert transport.resolve_calls == 1