Seu gamepass some quando a API do Roblox engasga. Conserto em 40 linhas
MarketplaceService:UserOwnsGamePassAsync falha calado sob carga e devolve false. O jogador que pagou perde o benefício e abre ticket. O conserto é cache + pcall + retry, e cabe num módulo.
Rascunho assistido por IA, revisado, testado e editado por um humano antes de publicar. Ver Política Editorial. · Revisado por Gustavo Cantino, 02/09/2026 Política Editorial
Você vende um gamepass de R$ 25, o jogador compra, e vinte minutos depois ele volta dizendo que perdeu o item. Você testa na sua conta e funciona. Ele manda print. Você acha que é mentira.
Não é. O que aconteceu é que MarketplaceService:UserOwnsGamePassAsync falhou, e o seu código tratou a falha como "não tem".
O problema
UserOwnsGamePassAsync é uma chamada de rede. Ela pode:
- demorar (até 30 segundos sob carga)
- lançar erro (throttling, 500 do backend do Roblox)
- devolver
falseporque genuinamente não tem
O código que quase todo mundo escreve não distingue o segundo caso do terceiro:
-- ERRADO: erro vira "não tem gamepass"
if MarketplaceService:UserOwnsGamePassAsync(player.UserId, PASS_ID) then
darBeneficio(player)
endQuando a chamada lança, a linha inteira aborta ou, pior, num pcall mal feito devolve false. O jogador pagante fica sem o benefício.
Sob carga normal isso acontece com 0,5% a 2% das chamadas. Num jogo com 5.000 jogadores por dia e 8% de conversão, são 2 a 8 clientes pagantes por dia recebendo nada. Eles não abrem ticket — eles saem.
O conserto, passo a passo
São três camadas, e cada uma resolve um caso diferente:
- pcall — separa "deu erro" de "não tem"
- retry com backoff — a maioria das falhas some na segunda tentativa
- cache por sessão — o Roblox não muda a posse do pass no meio da sessão (compra dispara
PromptGamePassPurchaseFinished, que você trata à parte), então checar mais de uma vez por jogador é desperdício e é o que te joga no throttle
O ponto que quase todo tutorial erra: em caso de erro definitivo, devolva nil, não false. Quem chama precisa poder decidir se, na dúvida, concede ou nega o benefício. Para um pass cosmético, conceder na dúvida custa nada e salva o cliente.
Erro comum
O erro mais caro aqui é cachear o false. Se o jogador compra o pass durante a sessão e você guardou "não tem", ele paga e não recebe até dar rejoin — e aí você tem um cliente pagante irritado, que é pior que um jogador perdido.
O módulo abaixo só cacheia resultado positivo e resultado negativo confirmado; erro não entra no cache.
O segundo erro comum é fazer retry em loop apertado. Três tentativas com espera crescente (1s, 2s, 4s) resolvem quase tudo; dez tentativas em sequência te colocam no throttle e transformam uma falha em cinco.
O que fazer em seguida
Com o módulo no lugar, ligue um contador simples: quantas vezes por dia o ownsPass devolveu nil. Se passar de 2% das chamadas, o problema não é o seu código — é volume, e a resposta é reduzir a frequência de checagem, não aumentar o retry.
Depois disso, vale ler sobre PromptGamePassPurchaseFinished para invalidar o cache na hora da compra, que é o único momento em que a posse muda dentro da sessão.
Artefato pronto pra usar
--!strict
-- GamePassCache — posse de gamepass à prova de falha de API.
--
-- Devolve:
-- true = tem o pass (confirmado)
-- false = NÃO tem o pass (confirmado)
-- nil = não deu pra saber (API falhou nas 3 tentativas)
--
-- Quem chama decide o que fazer com nil. Para pass cosmético, conceder na
-- dúvida custa menos que perder o cliente.
local MarketplaceService = game:GetService("MarketplaceService")
local Players = game:GetService("Players")
local GamePassCache = {}
local TENTATIVAS = 3
local ESPERA_BASE = 1 -- segundos; dobra a cada tentativa
-- cache[userId][passId] = true | false
-- Erro NUNCA entra aqui: cachear falha é transformar um soluço em bug
-- permanente da sessão.
local cache: { [number]: { [number]: boolean } } = {}
local function guardar(userId: number, passId: number, valor: boolean)
if not cache[userId] then
cache[userId] = {}
end
cache[userId][passId] = valor
end
function GamePassCache.ownsPass(userId: number, passId: number): boolean?
local doUser = cache[userId]
if doUser ~= nil and doUser[passId] ~= nil then
return doUser[passId]
end
local espera = ESPERA_BASE
for tentativa = 1, TENTATIVAS do
local ok, resultado = pcall(function()
return MarketplaceService:UserOwnsGamePassAsync(userId, passId)
end)
if ok then
-- só aqui sabemos de verdade
guardar(userId, passId, resultado)
return resultado
end
warn(string.format(
"[GamePassCache] tentativa %d/%d falhou para user=%d pass=%d: %s",
tentativa, TENTATIVAS, userId, passId, tostring(resultado)
))
if tentativa < TENTATIVAS then
task.wait(espera)
espera *= 2 -- 1s, 2s, 4s
end
end
-- desistiu: nil, e NÃO cacheia
return nil
end
-- Chame isto no PromptGamePassPurchaseFinished. É o único momento em que a
-- posse muda no meio da sessão.
function GamePassCache.invalidar(userId: number, passId: number)
local doUser = cache[userId]
if doUser then
doUser[passId] = nil
end
end
MarketplaceService.PromptGamePassPurchaseFinished:Connect(function(player, passId, comprou)
if comprou then
guardar(player.UserId, passId, true)
end
end)
Players.PlayerRemoving:Connect(function(player)
cache[player.UserId] = nil
end)
return GamePassCache