Describe the bug
The automatic and manual update check fails permanently with:
Update Check Failed
Unable to check for updates. Please try again later.
Error: Invalid HTTP response from GitHub.
This is not a network outage and not a malformed release. SimpleUpdater.fetchReleases calls the GitHub REST API unauthenticated, which GitHub limits to 60 requests/hour per public IP address. On a network behind ISP CGNAT (a shared public IP), that budget is consumed by unrelated traffic within minutes of every hourly reset, so the app receives HTTP 403 essentially every time it checks.
fetchReleases maps any non-2xx status to .invalidResponse, so a rate limit is presented to the user as a malformed-response error. There is no hint that the real cause is a quota, and no fallback, so users on shared IPs never learn about new releases — precisely the situation where an auto-updater matters most.
SimpleUpdater.swift (current main, 0038d64-era):
let (data, response) = try await URLSession.shared.data(from: releasesURL)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw SimpleUpdateError.invalidResponse // -> "Invalid HTTP response from GitHub."
}
Reproduction steps
- Be on a network whose public IP is shared (ISP CGNAT, or any busy NAT).
- Let the anonymous GitHub quota for that IP be exhausted.
- Open FluidVoice → menu bar icon → Check for Updates…
- Observe the "Update Check Failed / Invalid HTTP response from GitHub." dialog.
The same request the app makes can be verified directly:
curl -sS -o /dev/null -w '%{http_code}\n' \
https://api.github.com/repos/altic-dev/Fluid-oss/releases
# 403
{"message":"API rate limit exceeded for <my public IP>. (But here's the good news:
Authenticated requests get a higher rate limit. ...)"}
Note that altic-dev/Fluid-oss returns 301 to altic-dev/FluidVoice; URLSession follows it, so the rename is not the cause here — the 403 after the redirect is.
Expected behavior
- The update check succeeds (or at minimum reports an accurate, actionable reason).
- A rate limit should surface as something like "GitHub is temporarily rate-limiting this network — will retry later", not as an invalid-response error.
- Most importantly, the app should not spend the shared quota at all in the steady state. Conditional requests that return
304 Not Modified do not count against the rate limit, so ETag caching reduces consumption to effectively zero once a release list has been fetched.
Actual behavior
- The check fails with
403, reported as Invalid HTTP response from GitHub.
- It stays broken for ~50 minutes of every hour, because the 60/hour budget is re-consumed within minutes of each reset by other clients on the same public IP.
- The Changelog screen shows "Showing saved release notes. Refresh failed." for the same reason.
- Consequence: users on shared IPs silently miss real releases.
Measurements on the affected machine, with zero GitHub traffic originating from it (no FluidVoice check, no other local client — confirmed with lsof sampling that only the diagnostic curl/gh calls reached api.github.com):
13:03:29 used=33
13:05:30 used=47 # +14 requests in 2 minutes (~7 req/min) from other clients
That is roughly 420 requests/hour against a 60/hour budget. FluidVoice itself is a negligible consumer — it checks once per hour (SettingsStore.shouldCheckForUpdates()):
[11:47:16] [DEBUG] [AppDelegate] Periodic update check timer fired
[11:47:19] [INFO] [AppDelegate] Performing automatic update check for altic-dev/Fluid-oss
[11:47:19] [DEBUG] [AppDelegate] Automatic update check failed: Invalid HTTP response from GitHub.
The release-selection logic itself is correct and can be ruled out. Replaying sortedCandidateReleases / parseSemanticVersion against the live release list yields the expected answer, so the failure is purely transport-level:
v1.6.9 published=2026-08-18T08:01:44Z assets=[Fluid-oss-1.6.9.dmg, Fluid-oss-1.6.9.zip]
v1.6.8 published=2026-08-11T04:14:38Z
...
>>> selected: v1.6.9 (installed: 1.6.9) -> correctly "up to date"
The windows-* tags are correctly ignored (windows-v0.0.10 is a prerelease; windows-runtime-parakeet-v0.4.0-fv1 fails numeric major/minor parsing). Sorting by semantic version rather than published_at is what keeps the much newer Windows tags from winning — worth preserving in any fix.
Suggested fix
Cache the release list and its ETag, revalidate with If-None-Match, and give the rate limit its own error case:
private var cachedReleases: [GHRelease]?
private var cachedETag: String?
private func fetchReleases(owner: String, repo: String) async throws -> [GHRelease] {
guard let url = URL(string: "https://api.github.com/repos/\(owner)/\(repo)/releases") else {
throw SimpleUpdateError.invalidURL
}
var request = URLRequest(url: url)
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
request.setValue("FluidVoice", forHTTPHeaderField: "User-Agent")
if let etag = self.cachedETag {
request.setValue(etag, forHTTPHeaderField: "If-None-Match")
}
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw SimpleUpdateError.invalidResponse
}
switch http.statusCode {
case 304:
// Conditional hit: free of charge, cached list is still authoritative.
if let cached = self.cachedReleases { return cached }
case 200..<300:
let releases = try JSONDecoder().decode([GHRelease].self, from: data)
self.cachedReleases = releases
self.cachedETag = http.value(forHTTPHeaderField: "ETag")
return releases
case 403, 429:
// Rate limit exhausted for this (possibly shared) IP.
if let cached = self.cachedReleases { return cached }
throw SimpleUpdateError.rateLimited
default:
break
}
throw SimpleUpdateError.invalidResponse
}
case rateLimited
// ...
case .rateLimited:
return "GitHub is temporarily rate-limiting this network. FluidVoice will retry later."
Two further hardening ideas, in priority order:
- Persist
cachedReleases + cachedETag to disk (e.g. Application Support). This also lets ChangelogView render release notes instead of falling back to "Showing saved release notes. Refresh failed."
- Non-API fallback.
https://github.com/<owner>/<repo>/releases.atom is not subject to the REST rate limit and carries tag name, publish date and notes. It has no asset URLs, so it could drive "an update exists" while download URLs are derived or fetched later.
App Version
1.6.9 (build 20)
macOS Version
macOS 27.0 (build 26A428)
Architecture
Apple Silicon
Logs or crash report
[11:47:16.327] [DEBUG] [AppDelegate] Periodic update check timer fired
[11:47:19.514] [INFO] [AppDelegate] Performing automatic update check for altic-dev/Fluid-oss
[11:47:19.858] [DEBUG] [AppDelegate] Automatic update check failed: Invalid HTTP response from GitHub.
$ curl -sS -o /dev/null -w '%{http_code}\n' \
https://api.github.com/repos/altic-dev/Fluid-oss/releases
403
Affected public IP is an Uzbektelecom JSC (UZ) shared/CGNAT address, which is why the anonymous quota is permanently saturated.
Describe the bug
The automatic and manual update check fails permanently with:
This is not a network outage and not a malformed release.
SimpleUpdater.fetchReleasescalls the GitHub REST API unauthenticated, which GitHub limits to 60 requests/hour per public IP address. On a network behind ISP CGNAT (a shared public IP), that budget is consumed by unrelated traffic within minutes of every hourly reset, so the app receivesHTTP 403essentially every time it checks.fetchReleasesmaps any non-2xx status to.invalidResponse, so a rate limit is presented to the user as a malformed-response error. There is no hint that the real cause is a quota, and no fallback, so users on shared IPs never learn about new releases — precisely the situation where an auto-updater matters most.SimpleUpdater.swift(currentmain,0038d64-era):Reproduction steps
The same request the app makes can be verified directly:
{"message":"API rate limit exceeded for <my public IP>. (But here's the good news: Authenticated requests get a higher rate limit. ...)"}Note that
altic-dev/Fluid-ossreturns301toaltic-dev/FluidVoice;URLSessionfollows it, so the rename is not the cause here — the403after the redirect is.Expected behavior
304 Not Modifieddo not count against the rate limit, so ETag caching reduces consumption to effectively zero once a release list has been fetched.Actual behavior
403, reported asInvalid HTTP response from GitHub.Measurements on the affected machine, with zero GitHub traffic originating from it (no FluidVoice check, no other local client — confirmed with
lsofsampling that only the diagnosticcurl/ghcalls reachedapi.github.com):That is roughly 420 requests/hour against a 60/hour budget. FluidVoice itself is a negligible consumer — it checks once per hour (
SettingsStore.shouldCheckForUpdates()):The release-selection logic itself is correct and can be ruled out. Replaying
sortedCandidateReleases/parseSemanticVersionagainst the live release list yields the expected answer, so the failure is purely transport-level:The
windows-*tags are correctly ignored (windows-v0.0.10is a prerelease;windows-runtime-parakeet-v0.4.0-fv1fails numeric major/minor parsing). Sorting by semantic version rather thanpublished_atis what keeps the much newer Windows tags from winning — worth preserving in any fix.Suggested fix
Cache the release list and its ETag, revalidate with
If-None-Match, and give the rate limit its own error case:Two further hardening ideas, in priority order:
cachedReleases+cachedETagto disk (e.g. Application Support). This also letsChangelogViewrender release notes instead of falling back to "Showing saved release notes. Refresh failed."https://github.com/<owner>/<repo>/releases.atomis not subject to the REST rate limit and carries tag name, publish date and notes. It has no asset URLs, so it could drive "an update exists" while download URLs are derived or fetched later.App Version
1.6.9 (build 20)
macOS Version
macOS 27.0 (build 26A428)
Architecture
Apple Silicon
Logs or crash report
Affected public IP is an Uzbektelecom JSC (UZ) shared/CGNAT address, which is why the anonymous quota is permanently saturated.