Skip to content

Commit 4371deb

Browse files
Merge pull request #23 from musher-dev/fix/hub-fallback-on-401
fix: fall back to hub endpoints on 401 for public bundles
2 parents cac6e08 + b75bba0 commit 4371deb

2 files changed

Lines changed: 76 additions & 5 deletions

File tree

src/musher/_client.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
)
1717
from musher._cache import BundleCache
1818
from musher._config import MusherConfig, get_config
19-
from musher._errors import APIError, IntegrityError
19+
from musher._errors import APIError, AuthenticationError, IntegrityError
2020
from musher._http import HTTPTransport
2121
from musher._types import AssetType, BundleRef
2222

@@ -102,10 +102,23 @@ async def resolve(self, ref: str) -> ResolveResult:
102102
if parsed.digest:
103103
params["digest"] = parsed.digest
104104

105-
response = await self._http.get(
106-
f"/v1/namespaces/{parsed.namespace}/bundles/{parsed.slug}:resolve",
107-
params=params or None,
108-
)
105+
try:
106+
response = await self._http.get(
107+
f"/v1/namespaces/{parsed.namespace}/bundles/{parsed.slug}:resolve",
108+
params=params or None,
109+
)
110+
except AuthenticationError:
111+
response = await self._http.get(
112+
f"/v1/hub/bundles/{parsed.namespace}/{parsed.slug}:resolve",
113+
params=params or None,
114+
)
115+
except APIError as exc:
116+
if exc.status != 403: # noqa: PLR2004
117+
raise
118+
response = await self._http.get(
119+
f"/v1/hub/bundles/{parsed.namespace}/{parsed.slug}:resolve",
120+
params=params or None,
121+
)
109122
response_data: dict[str, object] = response.json() # pyright: ignore[reportAny]
110123
result = ResolveResult.model_validate(response_data)
111124

@@ -277,6 +290,8 @@ async def _pull_version(self, namespace: str, slug: str, version: str) -> dict[s
277290
f"/v1/namespaces/{namespace}/bundles/{slug}/versions/{version}:pull",
278291
)
279292
return response.json() # pyright: ignore[reportAny]
293+
except AuthenticationError:
294+
pass # No token or invalid — try public hub endpoint
280295
except APIError as exc:
281296
if exc.status != 403: # noqa: PLR2004
282297
raise

tests/test_client.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,62 @@ async def test_pull_hub_fallback(self, config: MusherConfig):
253253
assert bundle.version == "1.0.0"
254254
assert len(bundle.files()) == 1
255255

256+
@respx.mock
257+
async def test_pull_hub_fallback_401(self, config: MusherConfig):
258+
"""When namespaced :pull returns 401, falls back to hub :pull."""
259+
respx.get(f"{_BASE}/v1/namespaces/myorg/bundles/my-bundle:resolve").mock(
260+
return_value=httpx.Response(200, json=_RESOLVE_RESPONSE)
261+
)
262+
# Namespaced :pull returns 401 (no API key)
263+
respx.get(f"{_BASE}/v1/namespaces/myorg/bundles/my-bundle/versions/1.0.0:pull").mock(
264+
return_value=httpx.Response(401, json={"detail": "Invalid or missing API token"})
265+
)
266+
# Hub :pull succeeds
267+
respx.get(f"{_BASE}/v1/hub/bundles/myorg/my-bundle/versions/1.0.0:pull").mock(
268+
return_value=httpx.Response(200, json=_PULL_RESPONSE)
269+
)
270+
async with AsyncClient(config=config) as client:
271+
bundle = await client.pull("myorg/my-bundle:1.0.0")
272+
assert isinstance(bundle, Bundle)
273+
assert bundle.version == "1.0.0"
274+
assert len(bundle.files()) == 1
275+
276+
@respx.mock
277+
async def test_resolve_hub_fallback_401(self, config: MusherConfig):
278+
"""When namespaced :resolve returns 401, falls back to hub :resolve."""
279+
respx.get(f"{_BASE}/v1/namespaces/myorg/bundles/my-bundle:resolve").mock(
280+
return_value=httpx.Response(401, json={"detail": "Invalid or missing API token"})
281+
)
282+
respx.get(f"{_BASE}/v1/hub/bundles/myorg/my-bundle:resolve").mock(
283+
return_value=httpx.Response(200, json=_RESOLVE_RESPONSE)
284+
)
285+
async with AsyncClient(config=config) as client:
286+
result = await client.resolve("myorg/my-bundle:1.0.0")
287+
assert isinstance(result, ResolveResult)
288+
assert result.version == "1.0.0"
289+
290+
@respx.mock
291+
async def test_resolve_hub_fallback_403(self, config: MusherConfig):
292+
"""When namespaced :resolve returns 403, falls back to hub :resolve."""
293+
respx.get(f"{_BASE}/v1/namespaces/myorg/bundles/my-bundle:resolve").mock(
294+
return_value=httpx.Response(
295+
403,
296+
json={
297+
"type": "https://api.platform.musher.dev/errors/forbidden",
298+
"title": "Forbidden",
299+
"status": 403,
300+
"detail": "Not authorized",
301+
},
302+
)
303+
)
304+
respx.get(f"{_BASE}/v1/hub/bundles/myorg/my-bundle:resolve").mock(
305+
return_value=httpx.Response(200, json=_RESOLVE_RESPONSE)
306+
)
307+
async with AsyncClient(config=config) as client:
308+
result = await client.resolve("myorg/my-bundle:1.0.0")
309+
assert isinstance(result, ResolveResult)
310+
assert result.version == "1.0.0"
311+
256312

257313
class TestClient:
258314
def test_instantiation(self, config: MusherConfig):

0 commit comments

Comments
 (0)