From b08518d71c01066c33d4acadc1cf4b087b0de203 Mon Sep 17 00:00:00 2001 From: everix1992 Date: Mon, 10 Aug 2026 10:37:23 -0500 Subject: [PATCH 1/4] Fix private leagues missing from the Trader league list The trade site league endpoint only recognizes cookie sessions and silently ignores the OAuth bearer token, so private leagues never appeared after the OAuth port. Fetch them from the account API (which honors the token) and refresh the league list after login or a background token refresh. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Me8GVT6W3FLuZ8ugfYGwVv --- src/Classes/TradeQuery.lua | 45 ++++++++---------------------- src/Classes/TradeQueryRequests.lua | 21 ++++++++++++-- 2 files changed, 31 insertions(+), 35 deletions(-) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index aa7c29a4636..886cb06e6e6 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -68,38 +68,6 @@ end) --- Method to pull down and interpret available leagues from PoE -function TradeQueryClass:PullLeagueList() - launch:DownloadPage( - self.hostName .. "api/leagues?type=main&compact=1", - function(response, errMsg) - if errMsg then - self:SetNotice(self.controls.pbNotice, "Error: " .. tostring(errMsg)) - return "POE ERROR", "Error: "..errMsg - else - local json_data = dkjson.decode(response.body) - if not json_data then - self:SetNotice(self.controls.pbNotice, "Failed to Get PoE League List response") - return - end - table.sort(json_data, function(a, b) - if a.endAt == nil then return false end - if b.endAt == nil then return true end - return a.id < b.id - end) - self.itemsTab.leagueDropList = {} - for _, league_data in pairs(json_data) do - if not league_data.id:find("SSF") then - t_insert(self.itemsTab.leagueDropList,league_data.id) - end - end - self.controls.league:SetList(self.itemsTab.leagueDropList) - self.controls.league.selIndex = 1 - self.pbLeague = self.itemsTab.leagueDropList[self.controls.league.selIndex] - end - end) -end - --- @param currencyId string --- @param amount integer --- @return number? @@ -317,7 +285,10 @@ function TradeQueryClass:PriceItem() if main.api.authToken then main.api:ValidateAuth(function(valid) if valid then - return + -- if the token was refreshed after the league lists were fetched, refetch them so private leagues appear + if self.controls.realm and self.leaguesFetchToken ~= main.api.authToken then + self:UpdateRealms() + end else main.api:ResetDetails() end @@ -335,6 +306,7 @@ function TradeQueryClass:PriceItem() main.tokenExpiry = main.api.tokenExpiry main:SaveSettings() + self:UpdateRealms() TradeQueryClass:SetNotice(self.controls.pbNotice, "") else self.loginStatus = colorCodes.WARNING.."Not authenticated" @@ -1348,8 +1320,15 @@ function TradeQueryClass:UpdateRealms() -- use trade leagues api to get trade leagues including private leagues is valid. self.allLeagues = {} + -- remember which token fetched the league lists, so a later login or token refresh triggers a refetch + self.leaguesFetchToken = main.api.authToken + local leaguesTbl = self.allLeagues for _, realmId in pairs (self.realmIds) do self.tradeQueryRequests:FetchLeagues(realmId, function(leagues, errMsg) + if leaguesTbl ~= self.allLeagues then + -- superseded by a newer refetch (e.g. after re-authentication) + return + end if errMsg then self:SetNotice(self.controls.pbNotice, "Using Fallback Error while fetching league list: "..errMsg) end diff --git a/src/Classes/TradeQueryRequests.lua b/src/Classes/TradeQueryRequests.lua index 3bda7452a4a..63f6aeaf2b4 100644 --- a/src/Classes/TradeQueryRequests.lua +++ b/src/Classes/TradeQueryRequests.lua @@ -443,7 +443,8 @@ function TradeQueryRequestsClass:FetchSearchQuery(realm, league, queryId, callba }) end ---- Fetches the list of all available leagues using trade league API +--- Fetches the list of all available leagues using trade league API, +--- appending private leagues from the account API when authenticated ---@param realm string ---@param callback fun(query:table, errMsg:string) function TradeQueryRequestsClass:FetchLeagues(realm, callback) @@ -469,7 +470,23 @@ function TradeQueryRequestsClass:FetchLeagues(realm, callback) table.insert(leagues, value.id) end end - callback(leagues, errMsg) + if not main.api.authToken then + return callback(leagues, errMsg) + end + -- the trade site endpoint ignores OAuth tokens, so private leagues have to come from the account API + launch:DownloadPage(main.api.baseUrl .. "/account/leagues", function(accountResponse, accountErrMsg) + if not accountErrMsg then + local account_data = dkjson.decode(accountResponse.body) + if account_data and account_data.leagues then + for _, league in ipairs(account_data.leagues) do + if league.realm == realm and league.privateLeagueUrl then + table.insert(leagues, league.id) + end + end + end + end + callback(leagues, errMsg) + end, {header = header}) end, {header = header} ) From cc1eb2435bbd7b618c532f3915ccbd046aa7bc8d Mon Sep 17 00:00:00 2001 From: everix1992 Date: Mon, 10 Aug 2026 11:08:42 -0500 Subject: [PATCH 2/4] Address review findings on private league fetching - Build the account API auth header at send time instead of reusing one captured before the trade request, so a token refreshed mid-flight no longer 401s silently - Fetch /account/leagues once per refresh (memoized per token, in-flight requests shared) instead of once per realm - Guard both league-fetch callback paths against stale or duplicate writes when a login/refresh refetch supersedes an in-flight fetch - Record the fetch token only on a clean fetch so failures retry, and surface private-league fetch failures as a notice - Refetch league lists on logout so private leagues leave the dropdown - Only run the auth-probe search when authenticated - Don't send the bearer token to the trade-site league endpoint (it ignores it), and tolerate a missing realm field on account leagues Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Me8GVT6W3FLuZ8ugfYGwVv --- src/Classes/TradeQuery.lua | 56 ++++++++++++++--------- src/Classes/TradeQueryRequests.lua | 73 ++++++++++++++++++++++-------- 2 files changed, 88 insertions(+), 41 deletions(-) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 886cb06e6e6..b10a63aab9d 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -322,6 +322,9 @@ function TradeQueryClass:PriceItem() main.tokenExpiry = nil main.api.tokenExpiry = nil main:SaveSettings() + + -- refetch the league lists so private leagues are removed from the dropdowns + self:UpdateRealms() end end) self.controls.tradeAuthButton.tooltipText = [[ @@ -443,7 +446,13 @@ Highest Weight - Displays the order retrieved from trade]] if self.allLeagues[self.pbRealm] then setLeagueDropList() else - self.tradeQueryRequests:FetchLeagues(self.pbRealm, function(leagues, errMsg) + local leaguesTbl = self.allLeagues + local fetchRealm = self.pbRealm + self.tradeQueryRequests:FetchLeagues(fetchRealm, function(leagues, errMsg) + if leaguesTbl ~= self.allLeagues or self.allLeagues[fetchRealm] then + -- superseded by a newer refetch, or another fetch already filled this realm + return + end if errMsg then self:SetNotice(self.controls.pbNotice, "Error while fetching league list: "..errMsg) return @@ -458,8 +467,10 @@ Highest Weight - Displays the order retrieved from trade]] t_insert(sorted_leagues, "Hardcore") t_insert(sorted_leagues, "Ruthless") t_insert(sorted_leagues, "Hardcore Ruthless") - self.allLeagues[self.pbRealm] = sorted_leagues - setLeagueDropList() + self.allLeagues[fetchRealm] = sorted_leagues + if fetchRealm == self.pbRealm then + setLeagueDropList() + end end) end end) @@ -1320,36 +1331,37 @@ function TradeQueryClass:UpdateRealms() -- use trade leagues api to get trade leagues including private leagues is valid. self.allLeagues = {} - -- remember which token fetched the league lists, so a later login or token refresh triggers a refetch - self.leaguesFetchToken = main.api.authToken local leaguesTbl = self.allLeagues for _, realmId in pairs (self.realmIds) do - self.tradeQueryRequests:FetchLeagues(realmId, function(leagues, errMsg) - if leaguesTbl ~= self.allLeagues then - -- superseded by a newer refetch (e.g. after re-authentication) + self.tradeQueryRequests:FetchLeagues(realmId, function(leagues, errMsg, privateLeaguesFailed) + if leaguesTbl ~= self.allLeagues or self.allLeagues[realmId] then + -- superseded by a newer refetch, or the realm dropdown's own fetch already filled this realm return end if errMsg then self:SetNotice(self.controls.pbNotice, "Using Fallback Error while fetching league list: "..errMsg) + elseif privateLeaguesFailed then + self:SetNotice(self.controls.pbNotice, "Failed to fetch private leagues") + else + -- remember which token produced a clean fetch, so a later login or token refresh triggers a refetch + self.leaguesFetchToken = main.api.authToken end - for _, league in ipairs(leagues) do - if not self.allLeagues[realmId] then self.allLeagues[realmId] = {} end - t_insert(self.allLeagues[realmId], league) - end + self.allLeagues[realmId] = leagues setRealmDropList() - end) end -- perform a generic search to make sure the authorization is valid. - self.tradeQueryRequests:PerformSearch("pc", "Standard", [[{"query":{"status":{"option":"online"},"stats":[{"type":"and","filters":[]}]},"sort":{"price":"asc"}}]], function(response, errMsg) - if errMsg then - -- a 403 here likely means that the user has an outdated scope - if errMsg == "Response code: 403" then - main.api:ResetDetails() - errMsg = errMsg .. "\nPlease re-authenticate" + if main.api.authToken then + self.tradeQueryRequests:PerformSearch("pc", "Standard", [[{"query":{"status":{"option":"online"},"stats":[{"type":"and","filters":[]}]},"sort":{"price":"asc"}}]], function(response, errMsg) + if errMsg then + -- a 403 here likely means that the user has an outdated scope + if errMsg == "Response code: 403" then + main.api:ResetDetails() + errMsg = errMsg .. "\nPlease re-authenticate" + end + self:SetNotice(self.controls.pbNotice, "Error: " .. tostring(errMsg)) end - self:SetNotice(self.controls.pbNotice, "Error: " .. tostring(errMsg)) - end - end) + end) + end end diff --git a/src/Classes/TradeQueryRequests.lua b/src/Classes/TradeQueryRequests.lua index 63f6aeaf2b4..9d76aef1b28 100644 --- a/src/Classes/TradeQueryRequests.lua +++ b/src/Classes/TradeQueryRequests.lua @@ -443,12 +443,57 @@ function TradeQueryRequestsClass:FetchSearchQuery(realm, league, queryId, callba }) end +--- Fetches the account's private leagues from the account API (the trade site +--- endpoint ignores OAuth tokens, so they can only come from there). +--- Memoized per token; concurrent calls share one request. Failures are not +--- cached, so a later fetch retries. +---@param callback fun(privateLeagues:table, ok:boolean) +function TradeQueryRequestsClass:FetchPrivateLeagues(callback) + local token = main.api.authToken + if not token then + return callback({}, true) + end + local cache = self.privateLeaguesCache + if cache and cache.token == token then + if cache.leagues then + return callback(cache.leagues, true) + end + -- request already in flight, share its result + table.insert(cache.pending, callback) + return + end + cache = { token = token, pending = { callback } } + self.privateLeaguesCache = cache + launch:DownloadPage(main.api.baseUrl .. "/account/leagues", function(response, errMsg) + local privateLeagues + if not errMsg then + local json_data = dkjson.decode(response.body) + if json_data and json_data.leagues then + privateLeagues = {} + for _, league in ipairs(json_data.leagues) do + if league.privateLeagueUrl then + table.insert(privateLeagues, league) + end + end + end + end + if privateLeagues then + cache.leagues = privateLeagues + elseif self.privateLeaguesCache == cache then + self.privateLeaguesCache = nil + end + for _, cb in ipairs(cache.pending) do + cb(privateLeagues or {}, privateLeagues ~= nil) + end + cache.pending = {} + end, {header = "Authorization: Bearer " .. token}) +end + --- Fetches the list of all available leagues using trade league API, --- appending private leagues from the account API when authenticated ---@param realm string ----@param callback fun(query:table, errMsg:string) +---@param callback fun(leagues:table, errMsg:string?, privateLeaguesFailed:boolean?) function TradeQueryRequestsClass:FetchLeagues(realm, callback) - local header = "Authorization: Bearer " .. (main.api.authToken or "") launch:DownloadPage( self.hostName .. "api/trade/data/leagues", function(response, errMsg) @@ -470,25 +515,15 @@ function TradeQueryRequestsClass:FetchLeagues(realm, callback) table.insert(leagues, value.id) end end - if not main.api.authToken then - return callback(leagues, errMsg) - end - -- the trade site endpoint ignores OAuth tokens, so private leagues have to come from the account API - launch:DownloadPage(main.api.baseUrl .. "/account/leagues", function(accountResponse, accountErrMsg) - if not accountErrMsg then - local account_data = dkjson.decode(accountResponse.body) - if account_data and account_data.leagues then - for _, league in ipairs(account_data.leagues) do - if league.realm == realm and league.privateLeagueUrl then - table.insert(leagues, league.id) - end - end + self:FetchPrivateLeagues(function(privateLeagues, ok) + for _, league in ipairs(privateLeagues) do + if (league.realm or "pc") == realm then + table.insert(leagues, league.id) end end - callback(leagues, errMsg) - end, {header = header}) - end, - {header = header} + callback(leagues, nil, not ok) + end) + end ) end From 744d6af43cdb4d188b8b9146b7c0d46f5f0c579c Mon Sep 17 00:00:00 2001 From: everix1992 Date: Mon, 10 Aug 2026 15:25:55 -0500 Subject: [PATCH 3/4] Consolidate league fetching into a shared per-realm helper - Extract TradeQuery:FetchLeaguesForRealm so the supersede guard, base-league sorting, notices, and retry bookkeeping exist in one place instead of two diverged copies; failed fetches now leave the realm unset so they retry, and the fetch token is only recorded once every realm has a clean list - Restore the league dropdown selection by name, since a refetch can change the list order - Route the account leagues request through PoEAPI:DownloadWithRateLimit, gaining token refresh, 401 retry, and rate limiting - Refetch leagues from a single choke point via a new onAuthReset hook fired by PoEAPI:ResetDetails, covering logout, failed refreshes, and outdated scopes; the logout button now just calls ResetDetails - Coalesce concurrent token refreshes in ValidateAuth: the refresh token is single-use, so two in-flight refreshes could invalidate the session Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Me8GVT6W3FLuZ8ugfYGwVv --- src/Classes/PoEAPI.lua | 25 +++++- src/Classes/TradeQuery.lua | 121 +++++++++++++++++------------ src/Classes/TradeQueryRequests.lua | 20 +++-- 3 files changed, 102 insertions(+), 64 deletions(-) diff --git a/src/Classes/PoEAPI.lua b/src/Classes/PoEAPI.lua index 43b8d3b3c77..12dd4083af7 100644 --- a/src/Classes/PoEAPI.lua +++ b/src/Classes/PoEAPI.lua @@ -32,6 +32,20 @@ function PoEAPIClass:ValidateAuth(callback) ConPrintf("Validating auth token") if self.tokenExpiry < os.time() then ConPrintf("Auth token expired") + -- coalesce concurrent validations: the refresh token is single-use, + -- so a second in-flight refresh would invalidate the session + if self.refreshCallbacks then + table.insert(self.refreshCallbacks, callback) + return + end + self.refreshCallbacks = { callback } + local function finish(valid, errMsg) + local callbacks = self.refreshCallbacks + self.refreshCallbacks = nil + for _, cb in ipairs(callbacks) do + cb(valid, errMsg) + end + end -- here recreate the token with the refresh_token local formText = "client_id=pob&grant_type=refresh_token&refresh_token=" .. self.refreshToken launch:DownloadPage("https://www.pathofexile.com/oauth/token", function(response, errMsg) @@ -39,22 +53,22 @@ function PoEAPIClass:ValidateAuth(callback) if errMsg then ConPrintf("Failed to recreate auth token: %s", errMsg) self:ResetDetails() - callback(false, errMsg) + finish(false, errMsg) return end local responseLua = dkjson.decode(response.body) if not responseLua then self:ResetDetails() - callback(false, "Malformed response") + finish(false, "Malformed response") else self.authToken = responseLua.access_token self.refreshToken = responseLua.refresh_token self.tokenExpiry = os.time() + responseLua.expires_in self:UpdateMain() self.retries = 0 - callback(true) + finish(true) end - + end, { body = formText }) else callback(true) @@ -75,6 +89,9 @@ function PoEAPIClass:ResetDetails() self.refreshToken = nil self.tokenExpiry = nil self:UpdateMain() + if self.onAuthReset then + self.onAuthReset() + end end --- updates main so that API details are saved across restarts diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index b10a63aab9d..f8f9935563b 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -282,6 +282,14 @@ function TradeQueryClass:PriceItem() end end + -- refetch the league lists whenever authorization is reset (logout, failed + -- refresh, outdated scope) so private leagues don't linger in the dropdowns + main.api.onAuthReset = function() + if self.controls.realm and self.leaguesFetchToken ~= main.api.authToken then + self:UpdateRealms() + end + end + if main.api.authToken then main.api:ValidateAuth(function(valid) if valid then @@ -290,6 +298,7 @@ function TradeQueryClass:PriceItem() self:UpdateRealms() end else + -- clear the dead token; the league refetch happens via onAuthReset main.api:ResetDetails() end end) @@ -315,16 +324,7 @@ function TradeQueryClass:PriceItem() self.clickTime = os.time() -- LOGOUT else - main.lastToken = nil - main.api.authToken = nil - main.lastRefreshToken = nil - main.api.refreshToken = nil - main.tokenExpiry = nil - main.api.tokenExpiry = nil - main:SaveSettings() - - -- refetch the league lists so private leagues are removed from the dropdowns - self:UpdateRealms() + main.api:ResetDetails() end end) self.controls.tradeAuthButton.tooltipText = [[ @@ -441,33 +441,21 @@ Highest Weight - Displays the order retrieved from trade]] self.controls.league:SetList(self.itemsTab.leagueDropList) -- invalidate selIndex to trigger select function call in the SetSel self.controls.league.selIndex = nil - self.controls.league:SetSel(self.pbLeagueIndex) + -- restore the selection by league name, as a refetch can change the list order + local selIndex = self.pbLeagueIndex + for index, league in ipairs(self.itemsTab.leagueDropList) do + if league == self.pbLeague then + selIndex = index + break + end + end + self.controls.league:SetSel(selIndex) end if self.allLeagues[self.pbRealm] then setLeagueDropList() else - local leaguesTbl = self.allLeagues local fetchRealm = self.pbRealm - self.tradeQueryRequests:FetchLeagues(fetchRealm, function(leagues, errMsg) - if leaguesTbl ~= self.allLeagues or self.allLeagues[fetchRealm] then - -- superseded by a newer refetch, or another fetch already filled this realm - return - end - if errMsg then - self:SetNotice(self.controls.pbNotice, "Error while fetching league list: "..errMsg) - return - end - local sorted_leagues = { } - for _, league in ipairs(leagues) do - if league ~= "Standard" and league ~= "Ruthless" and league ~= "Hardcore" and league ~= "Hardcore Ruthless" then - t_insert(sorted_leagues, league) - end - end - t_insert(sorted_leagues, "Standard") - t_insert(sorted_leagues, "Hardcore") - t_insert(sorted_leagues, "Ruthless") - t_insert(sorted_leagues, "Hardcore Ruthless") - self.allLeagues[fetchRealm] = sorted_leagues + self:FetchLeaguesForRealm(fetchRealm, function() if fetchRealm == self.pbRealm then setLeagueDropList() end @@ -1310,6 +1298,55 @@ function TradeQueryClass:GetTotalPriceString() return text end +-- Fetches the league list for one realm into self.allLeagues, with the base +-- leagues sorted to the end. onDone is called only after a successful fill; +-- failures show a notice and leave the realm unset so a later fetch retries it. +function TradeQueryClass:FetchLeaguesForRealm(realmId, onDone) + local leaguesTbl = self.allLeagues + self.tradeQueryRequests:FetchLeagues(realmId, function(leagues, errMsg, privateLeaguesFailed) + if leaguesTbl ~= self.allLeagues or self.allLeagues[realmId] then + -- superseded by a newer refetch, or another fetch already filled this realm + return + end + if errMsg then + self:SetNotice(self.controls.pbNotice, "Error while fetching league list: "..errMsg) + return + end + if privateLeaguesFailed then + self.leaguesFetchDirty = true + self:SetNotice(self.controls.pbNotice, "Failed to fetch private leagues") + end + local sorted_leagues = { } + for _, league in ipairs(leagues) do + if league ~= "Standard" and league ~= "Ruthless" and league ~= "Hardcore" and league ~= "Hardcore Ruthless" then + t_insert(sorted_leagues, league) + end + end + t_insert(sorted_leagues, "Standard") + t_insert(sorted_leagues, "Hardcore") + t_insert(sorted_leagues, "Ruthless") + t_insert(sorted_leagues, "Hardcore Ruthless") + self.allLeagues[realmId] = sorted_leagues + -- record the token only once every realm has a clean list, so an + -- incomplete pass is retried on the next popup open or auth change + if not self.leaguesFetchDirty then + local complete = true + for _, id in pairs(self.realmIds) do + if not self.allLeagues[id] then + complete = false + break + end + end + if complete then + self.leaguesFetchToken = main.api.authToken + end + end + if onDone then + onDone() + end + end) +end + -- Method to update realms and leagues function TradeQueryClass:UpdateRealms() local function setRealmDropList() @@ -1331,24 +1368,10 @@ function TradeQueryClass:UpdateRealms() -- use trade leagues api to get trade leagues including private leagues is valid. self.allLeagues = {} - local leaguesTbl = self.allLeagues + self.leaguesFetchToken = nil + self.leaguesFetchDirty = nil for _, realmId in pairs (self.realmIds) do - self.tradeQueryRequests:FetchLeagues(realmId, function(leagues, errMsg, privateLeaguesFailed) - if leaguesTbl ~= self.allLeagues or self.allLeagues[realmId] then - -- superseded by a newer refetch, or the realm dropdown's own fetch already filled this realm - return - end - if errMsg then - self:SetNotice(self.controls.pbNotice, "Using Fallback Error while fetching league list: "..errMsg) - elseif privateLeaguesFailed then - self:SetNotice(self.controls.pbNotice, "Failed to fetch private leagues") - else - -- remember which token produced a clean fetch, so a later login or token refresh triggers a refetch - self.leaguesFetchToken = main.api.authToken - end - self.allLeagues[realmId] = leagues - setRealmDropList() - end) + self:FetchLeaguesForRealm(realmId, setRealmDropList) end -- perform a generic search to make sure the authorization is valid. diff --git a/src/Classes/TradeQueryRequests.lua b/src/Classes/TradeQueryRequests.lua index 9d76aef1b28..9132c07996a 100644 --- a/src/Classes/TradeQueryRequests.lua +++ b/src/Classes/TradeQueryRequests.lua @@ -444,7 +444,8 @@ function TradeQueryRequestsClass:FetchSearchQuery(realm, league, queryId, callba end --- Fetches the account's private leagues from the account API (the trade site ---- endpoint ignores OAuth tokens, so they can only come from there). +--- endpoint ignores OAuth tokens, so they can only come from there), with +--- token refresh and rate limiting handled by PoEAPI. --- Memoized per token; concurrent calls share one request. Failures are not --- cached, so a later fetch retries. ---@param callback fun(privateLeagues:table, ok:boolean) @@ -464,16 +465,13 @@ function TradeQueryRequestsClass:FetchPrivateLeagues(callback) end cache = { token = token, pending = { callback } } self.privateLeaguesCache = cache - launch:DownloadPage(main.api.baseUrl .. "/account/leagues", function(response, errMsg) + main.api:DownloadWithRateLimit("account-leagues-request-limit", "/account/leagues", function(json_data, errMsg) local privateLeagues - if not errMsg then - local json_data = dkjson.decode(response.body) - if json_data and json_data.leagues then - privateLeagues = {} - for _, league in ipairs(json_data.leagues) do - if league.privateLeagueUrl then - table.insert(privateLeagues, league) - end + if not errMsg and json_data and json_data.leagues then + privateLeagues = {} + for _, league in ipairs(json_data.leagues) do + if league.privateLeagueUrl then + table.insert(privateLeagues, league) end end end @@ -486,7 +484,7 @@ function TradeQueryRequestsClass:FetchPrivateLeagues(callback) cb(privateLeagues or {}, privateLeagues ~= nil) end cache.pending = {} - end, {header = "Authorization: Bearer " .. token}) + end) end --- Fetches the list of all available leagues using trade league API, From e0969b19905ef0353297a267388ccee7f3e5f014 Mon Sep 17 00:00:00 2001 From: everix1992 Date: Mon, 10 Aug 2026 17:25:26 -0500 Subject: [PATCH 4/4] Fix rate limiter crash on unknown policies and auth-change races - InsertRequest crashed on any policy missing from the pendingRequests init table, which broke the first authenticated account-leagues fetch; initialize the policy entry on demand and cover it with a spec - Discard an in-flight token refresh whose refresh token no longer matches current state, so a logout or new login during the refresh is not overwritten by the stale result - Track the token a league-fetch pass started with, closing the gap where a logout during an in-flight authenticated pass compared nil == nil and skipped the refetch, leaving private leagues visible - Discard private-league results that complete after logout, and re-key the cache after a transparent token rotation so it still hits Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Me8GVT6W3FLuZ8ugfYGwVv --- spec/System/TestTradeQueryRateLimiter_spec.lua | 12 ++++++++++++ src/Classes/PoEAPI.lua | 9 ++++++++- src/Classes/TradeQuery.lua | 14 ++++++++++---- src/Classes/TradeQueryRateLimiter.lua | 5 ++++- src/Classes/TradeQueryRequests.lua | 14 ++++++++++++++ 5 files changed, 48 insertions(+), 6 deletions(-) diff --git a/spec/System/TestTradeQueryRateLimiter_spec.lua b/spec/System/TestTradeQueryRateLimiter_spec.lua index 4542385fdf2..e76f8b3fe7d 100644 --- a/spec/System/TestTradeQueryRateLimiter_spec.lua +++ b/spec/System/TestTradeQueryRateLimiter_spec.lua @@ -63,6 +63,18 @@ describe("TradeQueryRateLimiter", function() end) end) + describe("InsertRequest", function() + -- Pass: Full request lifecycle completes for a policy the limiter wasn't initialized with + -- Fail: Error indexing pendingRequests, breaking requests under new policies (e.g. the account API) + it("handles policies it was not initialized with", function() + local limiter = new("TradeQueryRateLimiter") + local now = os.time() + assert.are.equal(now, limiter:NextRequestTime("account-leagues-request-limit", now)) + local requestId = limiter:InsertRequest("account-leagues-request-limit") + limiter:FinishRequest("account-leagues-request-limit", requestId) + end) + end) + describe("AgeOutRequests", function() -- Pass: Removes old stamps, decrements to 1 -- Fail: Stale data persists, indicating aging bug, perpetual blocking diff --git a/src/Classes/PoEAPI.lua b/src/Classes/PoEAPI.lua index 12dd4083af7..090c33f5cfa 100644 --- a/src/Classes/PoEAPI.lua +++ b/src/Classes/PoEAPI.lua @@ -47,9 +47,16 @@ function PoEAPIClass:ValidateAuth(callback) end end -- here recreate the token with the refresh_token - local formText = "client_id=pob&grant_type=refresh_token&refresh_token=" .. self.refreshToken + local refreshToken = self.refreshToken + local formText = "client_id=pob&grant_type=refresh_token&refresh_token=" .. refreshToken launch:DownloadPage("https://www.pathofexile.com/oauth/token", function(response, errMsg) ConPrintf("Recreating auth token") + if self.refreshToken ~= refreshToken then + -- authorization changed while the refresh was in flight (logout or + -- new login); discard this result rather than overwrite the new state + finish(false, "Authorization changed during refresh") + return + end if errMsg then ConPrintf("Failed to recreate auth token: %s", errMsg) self:ResetDetails() diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index f8f9935563b..83cf32e2b9d 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -283,9 +283,11 @@ function TradeQueryClass:PriceItem() end -- refetch the league lists whenever authorization is reset (logout, failed - -- refresh, outdated scope) so private leagues don't linger in the dropdowns + -- refresh, outdated scope) so private leagues don't linger in the dropdowns; + -- an in-flight authenticated pass may already have stored private leagues, + -- so the pass token has to be checked as well as the completed-fetch token main.api.onAuthReset = function() - if self.controls.realm and self.leaguesFetchToken ~= main.api.authToken then + if self.controls.realm and (self.leaguesFetchToken ~= main.api.authToken or self.leaguesPassToken ~= main.api.authToken) then self:UpdateRealms() end end @@ -293,8 +295,9 @@ function TradeQueryClass:PriceItem() if main.api.authToken then main.api:ValidateAuth(function(valid) if valid then - -- if the token was refreshed after the league lists were fetched, refetch them so private leagues appear - if self.controls.realm and self.leaguesFetchToken ~= main.api.authToken then + -- if the token was refreshed after the league lists were fetched, refetch them + -- so private leagues appear; a pass already running with this token counts + if self.controls.realm and self.leaguesFetchToken ~= main.api.authToken and self.leaguesPassToken ~= main.api.authToken then self:UpdateRealms() end else @@ -1370,6 +1373,9 @@ function TradeQueryClass:UpdateRealms() self.allLeagues = {} self.leaguesFetchToken = nil self.leaguesFetchDirty = nil + -- the token this pass started with; leaguesFetchToken stays nil until the + -- pass completes, so auth-change checks need this to see in-flight passes + self.leaguesPassToken = main.api.authToken for _, realmId in pairs (self.realmIds) do self:FetchLeaguesForRealm(realmId, setRealmDropList) end diff --git a/src/Classes/TradeQueryRateLimiter.lua b/src/Classes/TradeQueryRateLimiter.lua index 08cf07afecc..e10167c2dba 100644 --- a/src/Classes/TradeQueryRateLimiter.lua +++ b/src/Classes/TradeQueryRateLimiter.lua @@ -222,8 +222,11 @@ function TradeQueryRateLimiterClass:InsertRequest(policy, timestamp, time) end local requestId = self.requestId self.requestId = self.requestId + 1 + if self.pendingRequests[policy] == nil then + self.pendingRequests[policy] = {} + end table.insert(self.pendingRequests[policy], requestId) - return requestId + return requestId end function TradeQueryRateLimiterClass:FinishRequest(policy, requestId) diff --git a/src/Classes/TradeQueryRequests.lua b/src/Classes/TradeQueryRequests.lua index 9132c07996a..78414db0ff7 100644 --- a/src/Classes/TradeQueryRequests.lua +++ b/src/Classes/TradeQueryRequests.lua @@ -466,6 +466,20 @@ function TradeQueryRequestsClass:FetchPrivateLeagues(callback) cache = { token = token, pending = { callback } } self.privateLeaguesCache = cache main.api:DownloadWithRateLimit("account-leagues-request-limit", "/account/leagues", function(json_data, errMsg) + if not main.api.authToken then + -- logged out while the request was in flight; discard the result + if self.privateLeaguesCache == cache then + self.privateLeaguesCache = nil + end + for _, cb in ipairs(cache.pending) do + cb({}, true) + end + cache.pending = {} + return + end + -- the request may have transparently rotated the token; re-key the cache + -- so callers holding the new token still hit it + cache.token = main.api.authToken local privateLeagues if not errMsg and json_data and json_data.leagues then privateLeagues = {}