Scripting Lua

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.

por Gustavo Cantino2 min de leitura

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 false porque 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)
end

Quando 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:

  1. pcall — separa "deu erro" de "não tem"
  2. retry com backoff — a maioria das falhas some na segunda tentativa
  3. 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

GamePassCache.lua — ModuleScript em ServerScriptService
lua
--!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