Fix requests hanging ~2 minutes and disconnect not working on a dead server - #258
Merged
taylorcox75 merged 1 commit intoSep 20, 2026
Conversation
…server A GET against an unreachable server used to take ~43s (4 retry attempts x the full 10s axios timeout, plus 500/1000/1500ms backoff) and, with TanStack Query's own retry: 2 stacked on top for the torrent/transfer polls, ~2 minutes before anything surfaced. Disconnect additionally awaited a logout POST with nothing to cancel it, so it looked broken too. - services/api/client.ts: withRetry now treats connectionTimeout as a total budget for the whole logical request instead of a per-attempt allowance, capping each attempt's axios timeout to the remaining budget (with a 1s floor) and skipping a retry whose backoff would blow the deadline. Added a session-scoped AbortController (abortInFlight()), used by default in get/post/postFormData/postUrlEncoded when the caller doesn't pass its own signal, and wired into setServer()/clearCookies() so every session teardown cancels in-flight requests. ERR_CANCELED now gets its own normalized message instead of falling into the generic branch — it was already correctly excluded from retry and from RECONNECTABLE_MESSAGES, this just makes it identifiable. - services/server-manager.ts: disconnect() no longer awaits logout() — it fires it and calls apiClient.abortInFlight() so a hung logout can't block the disconnect button. - context/TorrentContext.tsx, context/TransferContext.tsx: set retry: 0 on the two polling queries, since the client already retries/backs off and a 2-3s poll gains nothing from TanStack retrying on top. Fixes #254
taylorcox75
deleted the
bugfix/#254-request-timeout-and-cancellation
branch
September 20, 2026 23:58
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #254. Reporter's repro: connect over Wi-Fi, switch to mobile so the server becomes unreachable, open torrent detail or Transfer — infinite loading, and the disconnect button doesn't work.
Root cause chain:
get()retried (withRetry), with 4 attempts (retryAttemptsdefaults to 3) each carrying the full axios timeout (default 10s), plus 500/1000/1500ms backoff between them — ≈43s per logical GET against a dead server.retry: 2on the torrent/transfer polling queries stacked another 3x on top — ≈129s (~2 min) before any error surfaced, and the polls kept restarting so the UI never settled.ServerManager.disconnect()awaited a logout POST with nothing to cancel it if the server was unreachable, and nothing aborted the in-flight GETs — they landed afterward.What changed:
services/api/client.tswithRetrynow treatsconnectionTimeoutas a total budget for the whole logical request rather than a per-attempt allowance: each attempt's axiostimeoutis capped to the remaining budget (with a 1s floor so the last attempt isn't handed ~0ms), and a retry is skipped rather than slept if the backoff alone would blow the deadline. Net effect: a GET against a dead server now fails in ~connectionTimeout, not ~43s.AbortController(abortInFlight()), used by default inget/post/postFormData/postUrlEncodedwhenever the caller doesn't pass its own signal, and wired intosetServer()/clearCookies()so every session teardown (server switch, disconnect) cancels in-flight requests.ERR_CANCELEDnow gets its own normalized message ("Request canceled.") instead of falling into the generic branch. It was already correctly excluded from retry (isRetriableError) and fromRECONNECTABLE_MESSAGES(hooks/useReactiveReconnect.ts) — this just makes the thrown error identifiable rather than reusing the timeout message.services/server-manager.ts—disconnect()no longerawaitslogout()to completion; it fires it (errors ignored, as before) and callsapiClient.abortInFlight()immediately, so disconnect returns promptly instead of waiting out a hung logout against an unreachable server.context/TorrentContext.tsx,context/TransferContext.tsx— setretry: 0on the two polling queries (refetchInterval2s/3s). The client already retries/backs off internally; a query-level retry on top of that was the multiplier that turned a bounded client-side failure into ~2 minutes. Did not touch the global default inservices/query-client.ts— other one-shot queries rely on it.Per AGENTS.md scope discipline,
app/(tabs)/(torrents)/index.tsx(Task B territory) was not touched.Test plan
npx tsc --noEmit— exit 0npm test— 78/78 suites, 1140/1140 tests passing (added coverage intests/services/client.test.tsfor the retry budget/floor behavior,ERR_CANCELEDnot being retried and getting its own message, andabortInFlight()cancelling in-flightget/post/postFormData/postUrlEncodedcalls that didn't pass their own signal — including that a caller-supplied signal is left untouched; plustests/services/server-manager.test.tscoverage thatdisconnect()doesn't hang on a never-resolving logout)npm run lint— 0 errors, 37 warnings (documented baseline)npm run format— clean🤖 Generated with Claude Code