Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
17 changes: 15 additions & 2 deletions src/volcano_sdk/_function_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
16 changes: 11 additions & 5 deletions src/volcano_sdk/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down
29 changes: 29 additions & 0 deletions tests/unit/test_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading