Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions spec/System/TestTradeQueryRateLimiter_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 29 additions & 5 deletions src/Classes/PoEAPI.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
174 changes: 97 additions & 77 deletions src/Classes/TradeQuery.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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)
Expand All @@ -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"
Expand All @@ -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 = [[
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand All @@ -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
5 changes: 4 additions & 1 deletion src/Classes/TradeQueryRateLimiter.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading