Skip to content

Stop a 404 or a rate limit being read as the offsets data - #414

Merged
OhMyGuus merged 2 commits into
OhMyGuus:nightlyfrom
greluc:fix/offsets-fetch-hardening
Sep 8, 2026
Merged

Stop a 404 or a rate limit being read as the offsets data#414
OhMyGuus merged 2 commits into
OhMyGuus:nightlyfrom
greluc:fix/offsets-fetch-hardening

Conversation

@greluc

@greluc greluc commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

A 404 or a rate limit from the offsets mirror can be read as the offsets data.

Why

Both fetchers in src/main/offsetStore.ts hand the response straight to response.json():

return fetch(`${url}/lookup.json`)
	.then((response) => response.json())
	.then((data) => {
		return data as IOffsetsLookup;
	})

Nothing looks at the status, so a non-200 whose body happens to be JSON is returned as if it were the payload. Whether that throws depends on what the host chose to send back: raw.githubusercontent.com answers a missing file with the plain text 404: Not Found, which fails to parse and is caught — but an error or rate-limit page carrying JSON parses cleanly and is accepted. The offsets decide where the client reads inside the game's memory, so what happens next is not a clean failure.

Two smaller things sit alongside it:

  • One attempt per host. raw.githubusercontent.com rate limits per IP. A household where several people start the game at once, or anyone behind a shared address, can be turned away by both hosts and told to check their internet connection.
  • No timeout. A request that hangs holds up the start with nothing to cut it off.

What changed

fetchOffsetLookupJson and fetchOffsetsJson were the same function with a different path and a different error, so they are folded into one:

async function fetchJsonFromMirrors<T>(path: string, failure: string): Promise<T>

It checks response.ok, tries both hosts, tries again twice after a growing backoff, and gives each request ten seconds. The thrown value is unchanged — still Errors.LOOKUP_FETCH_ERROR / Errors.OFFSETS_FETCH_ERROR — so fetchOffsetLookup's fallback to the cached lookup still sees exactly what it expects. Net: 39 insertions, 31 deletions, one file, no new dependencies.

Verification

There is no test runner in this repository, so the check is a harness that extracts the real fetcher from offsetStore.ts and drives it against a scripted fetch. The same script runs against either revision, which is what makes the before column meaningful. The timeout and backoff constants are shrunk from seconds to milliseconds so it finishes; the control flow around them is untouched.

nightly this branch
both hosts healthy ok ok
first host 404s with a JSON body FAIL — returns {"error":"Not Found"} as the lookup ok, falls to the second host
both hosts rate-limited, healthy next round FAIL — returns {"message":"rate limited"} as the lookup ok, succeeds on the retry
a host hangs FAIL — never returns ok, times out and uses the other host
every host and round fails ok — throws LOOKUP_FETCH_ERROR ok — same error

eslint src and tsc --noEmit are clean, and prettier reports the file unchanged.

Limits, stated plainly

The harness drives the fetcher with a fake fetch; nothing here was run against the real mirror or a real rate limit, and the app was not started. The retry budget is a judgement call — three rounds over two hosts, backing off 1.5s then 3s, so a completely dead mirror now costs about 4.5 seconds before falling back to the cache rather than failing immediately. If you would rather that be shorter, or the timeout longer, say so and I will change it; the constants are together at the top.

I have not touched the caching or validation of what comes back, which is a separate question from whether the fetch itself is sound.

Disclaimer

This change was written with AI assistance (Claude Code). The author reviewed it, and every claim above was checked by running code rather than reasoned about — the before/after table is real output from the harness against both revisions. Please review it as you would any patch from a stranger; if something here is wrong I would rather hear it than have it merged.

🤖 Generated with Claude Code

Both fetchers handed the response straight to response.json() without looking at
the status, so a non-200 whose body happens to be JSON was returned as if it were
the lookup or the offsets. Whether that threw depended on what the host chose to
return: raw.githubusercontent.com answers a missing file with plain text, which
throws, but a rate limit or an error page carrying JSON is accepted.

There was also only one attempt per host and no timeout. raw.githubusercontent.com
rate limits per IP, so a household where several people start the game at once, or
anyone behind a shared address, could be turned away on both hosts and told to
check their internet connection; and a request that hung held up the start with
nothing to cut it off.

The two fetchers were the same function with a different path and error, so this
folds them into one that checks the status, tries both hosts, tries again twice
after a backoff, and times out after ten seconds. The thrown value is unchanged,
so the caller that falls back to the cached lookup still sees what it expects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Lucas Greuloch (greluc) <lucas.greuloch@gmail.com>
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

Download the artifacts for this pull request:


This service is provided by nightly.link. These artifacts will expire in 90 days and will not be available for download after that time.

@OhMyGuus
OhMyGuus merged commit 9b66518 into OhMyGuus:nightly Sep 8, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants