Skip to content
Open
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
26 changes: 24 additions & 2 deletions backend/routers/firmware.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,30 @@ async def get_omi_github_releases(cache_key: str, tag_filter: Optional[re.Patter

page += 1

# Cache for 5 minutes (even if empty, to avoid hammering GitHub)
set_generic_cache(cache_key, collected, ttl=300)
# Resilience fallback: when the unfiltered list endpoint returns empty,
# fetch /releases/latest as a single-release fallback. The list endpoint
# has historically gone soft-empty (HTTP 200 with []) during partial
# GitHub outages while single-release endpoints stayed up.
if not collected and not tag_filter:
try:
latest_url = "https://api.github.com/repos/BasedHardware/omi/releases/latest"
latest_resp = await client.get(latest_url, headers=headers)
if latest_resp.status_code == 200:
collected = [latest_resp.json()]
logger.warning("GitHub releases list returned empty; using /releases/latest fallback")
else:
logger.warning(
"GitHub /releases/latest fallback returned %d: %s",
latest_resp.status_code,
sanitize(latest_resp.text),
)
except Exception as e:
logger.warning("GitHub /releases/latest fallback failed: %s: %s", type(e).__name__, e)

# Cache successful fetches for 5 minutes; cache empty results briefly so
# we recover quickly when GitHub heals without hammering the API.
ttl = 300 if collected else 30
set_generic_cache(cache_key, collected, ttl=ttl)
return collected


Expand Down
117 changes: 112 additions & 5 deletions backend/tests/unit/test_firmware_pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,12 +183,119 @@ async def test_cached_empty_list_is_cache_hit(self):
@pytest.mark.asyncio
async def test_cache_none_triggers_fetch(self):
"""None from cache means cache miss — should fetch from GitHub."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = []
list_response = MagicMock(status_code=200)
list_response.json.return_value = []

# /releases/latest fallback is also empty (404)
latest_response = MagicMock(status_code=404, text="not found")

mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.get = AsyncMock(side_effect=[list_response, latest_response])
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)

with patch('routers.firmware.get_generic_cache', return_value=None), patch(
'routers.firmware.set_generic_cache'
) as mock_set, patch('routers.firmware.httpx.AsyncClient', return_value=mock_client), patch.dict(
'os.environ', {'GITHUB_TOKEN': 'test-token'}
):

result = await get_omi_github_releases("test_key")

assert result == []
# Empty results cache briefly so we recover quickly when GitHub heals.
mock_set.assert_called_once_with("test_key", [], ttl=30)


class TestEmptyListFallback:
"""Test the /releases/latest fallback when the list endpoint returns empty."""

@pytest.mark.asyncio
async def test_falls_back_to_latest_when_list_empty(self):
"""When GitHub list returns [] without a tag_filter, use /releases/latest."""
list_response = MagicMock(status_code=200)
list_response.json.return_value = []

latest_release = _make_release("v0.11.368+11368-macos")
latest_response = MagicMock(status_code=200)
latest_response.json.return_value = latest_release

mock_client = AsyncMock()
mock_client.get = AsyncMock(side_effect=[list_response, latest_response])
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)

with patch('routers.firmware.get_generic_cache', return_value=None), patch(
'routers.firmware.set_generic_cache'
) as mock_set, patch('routers.firmware.httpx.AsyncClient', return_value=mock_client), patch.dict(
'os.environ', {'GITHUB_TOKEN': 'test-token'}
):

result = await get_omi_github_releases("test_key")

assert result == [latest_release]
# Successful fallback caches at the normal TTL.
mock_set.assert_called_once_with("test_key", [latest_release], ttl=300)
# Confirm /releases/latest was actually called as the second request.
assert mock_client.get.call_count == 2
latest_call_url = mock_client.get.call_args_list[1].args[0]
assert latest_call_url.endswith("/releases/latest")

@pytest.mark.asyncio
async def test_no_fallback_when_tag_filter_present(self):
"""Firmware path (with tag_filter) should not invoke the latest fallback."""
list_response = MagicMock(status_code=200)
list_response.json.return_value = []

mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=list_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)

with patch('routers.firmware.get_generic_cache', return_value=None), patch(
'routers.firmware.set_generic_cache'
), patch('routers.firmware.httpx.AsyncClient', return_value=mock_client), patch.dict(
'os.environ', {'GITHUB_TOKEN': 'test-token'}
):

result = await get_omi_github_releases("test_key", tag_filter=FIRMWARE_TAG_PATTERN)

assert result == []
# Only the list endpoint is called — no fallback for firmware.
assert mock_client.get.call_count == 1

@pytest.mark.asyncio
async def test_no_fallback_when_list_returned_data(self):
"""If the list endpoint returned anything, /releases/latest is not called."""
full_page = _desktop_releases(3)

list_response = MagicMock(status_code=200)
list_response.json.return_value = full_page

mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=list_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)

with patch('routers.firmware.get_generic_cache', return_value=None), patch(
'routers.firmware.set_generic_cache'
), patch('routers.firmware.httpx.AsyncClient', return_value=mock_client), patch.dict(
'os.environ', {'GITHUB_TOKEN': 'test-token'}
):

result = await get_omi_github_releases("test_key")

assert len(result) == 3
assert mock_client.get.call_count == 1

@pytest.mark.asyncio
async def test_fallback_swallows_exception(self):
"""If /releases/latest raises, we still return the empty list (no 500)."""
list_response = MagicMock(status_code=200)
list_response.json.return_value = []

mock_client = AsyncMock()
mock_client.get = AsyncMock(side_effect=[list_response, RuntimeError("boom")])
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)

Expand All @@ -201,4 +308,4 @@ async def test_cache_none_triggers_fetch(self):
result = await get_omi_github_releases("test_key")

assert result == []
mock_set.assert_called_once_with("test_key", [], ttl=300)
mock_set.assert_called_once_with("test_key", [], ttl=30)