Skip to content

[BUG] Update check permanently fails on shared/CGNAT networks — anonymous GitHub API rate limit (403) reported as "Invalid HTTP response from GitHub" #976

Description

@skverall

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

  1. Be on a network whose public IP is shared (ISP CGNAT, or any busy NAT).
  2. Let the anonymous GitHub quota for that IP be exhausted.
  3. Open FluidVoice → menu bar icon → Check for Updates…
  4. 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:

  1. 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."
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions