diff --git a/spec/System/TestTradeQueryRateLimiter_spec.lua b/spec/System/TestTradeQueryRateLimiter_spec.lua index c1457f2be0..6e4dc12c88 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 7a9031df05..d357b03068 100644 --- a/src/Classes/PoEAPI.lua +++ b/src/Classes/PoEAPI.lua @@ -36,29 +36,50 @@ 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 + 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() - 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) @@ -79,6 +100,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 7ea2fcedf0..6dd29095aa 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -72,38 +72,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? @@ -318,11 +286,26 @@ 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; + -- 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 or self.leaguesPassToken ~= main.api.authToken) then + self:UpdateRealms() + end + end + 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; 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 + -- clear the dead token; the league refetch happens via onAuthReset main.api:ResetDetails() end end) @@ -339,6 +322,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" @@ -347,13 +331,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() + main.api:ResetDetails() end end) self.controls.tradeAuthButton.tooltipText = [[ @@ -470,28 +448,24 @@ 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 - self.tradeQueryRequests:FetchLeagues(self.pbRealm, function(leagues, errMsg) - if errMsg then - self:SetNotice(self.controls.pbNotice, "Error while fetching league list: "..errMsg) - return + local fetchRealm = self.pbRealm + self:FetchLeaguesForRealm(fetchRealm, function() + if fetchRealm == self.pbRealm then + setLeagueDropList() 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[self.pbRealm] = sorted_leagues - setLeagueDropList() end) end end) @@ -1330,6 +1304,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() @@ -1351,29 +1374,26 @@ function TradeQueryClass:UpdateRealms() -- use trade leagues api to get trade leagues including private leagues is valid. 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.tradeQueryRequests:FetchLeagues(realmId, function(leagues, errMsg) - if errMsg then - self:SetNotice(self.controls.pbNotice, "Using Fallback Error while fetching league list: "..errMsg) - end - for _, league in ipairs(leagues) do - if not self.allLeagues[realmId] then self.allLeagues[realmId] = {} end - t_insert(self.allLeagues[realmId], league) - end - setRealmDropList() - - end) + self:FetchLeaguesForRealm(realmId, setRealmDropList) 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/TradeQueryRateLimiter.lua b/src/Classes/TradeQueryRateLimiter.lua index f98c59f669..fcaf087187 100644 --- a/src/Classes/TradeQueryRateLimiter.lua +++ b/src/Classes/TradeQueryRateLimiter.lua @@ -225,8 +225,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 b006712380..6636be08c2 100644 --- a/src/Classes/TradeQueryRequests.lua +++ b/src/Classes/TradeQueryRequests.lua @@ -445,11 +445,69 @@ function TradeQueryRequestsClass:FetchSearchQuery(realm, league, queryId, callba }) end ---- Fetches the list of all available leagues using trade league API +--- Fetches the account's private leagues from the account API (the trade site +--- 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) +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 + 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 = {} + for _, league in ipairs(json_data.leagues) do + if league.privateLeagueUrl then + table.insert(privateLeagues, league) + 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) +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) @@ -471,9 +529,15 @@ function TradeQueryRequestsClass:FetchLeagues(realm, callback) table.insert(leagues, value.id) end end - callback(leagues, errMsg) - end, - {header = header} + 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, nil, not ok) + end) + end ) end