[V1.4] SafeCall Framework

Overview

SafeCall is a lightweight, great error handling wrapper for Roblox Lua. It simplifies safe function execution by automatically handling errors, retries, async support, rate limiting, profiling, and more, reducing crashes and improving debugging.

It works standalone or integrates seamlessly with other frameworks (e.g. Promise, ProfileStore, Knit)

Installation

  • Download the .rbxm file: SafeCall.rbxm (137.5 KB)
  • or get the model SafeCall
  • Ungroup the ReplicatedStorage model and place the Main folder inside ReplicatedStorage.
  • Also, place SafeCallExample content inside ServerScriptService.

Require it:

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local SafeCall = require(ReplicatedStorage.Main.Modules.SafeCall)
local safe = SafeCall.new()

API Reference

Constructor

  • SafeCall.new(logFunction: function?) -> SafeCall

Creates a new SafeCall instance.

  • logFunction (optional): custom error logger, defaults to warn.

Instance Methods

Call Method

Call

success, result = safe:Call(fn: function, ...any) -> (bool, any)

Calls fn safely with arguments. Returns success status and result or error.

CallWithRetry

success, result = safe:CallWithRetry(fn: function, attempts?: number, delay?: number, backoff?: number, ...any)

Calls fn with retry logic on failure.

  • attempts: number of retries (default 3)
  • delay: initial wait between retries (default 0.1s)
  • backoff: multiplier to increase delay after each retry (default 1.5)

CallAsync

promise = safe:CallAsync(fn: function, ...any) -> Promise

Calls fn async, returns a Promise that resolves or rejects based on call success.
Requires the Promise library.

Use case: Run a function asynchronously and handle its result with Promises.
Best for: Async workflows like data fetching or web requests.

Example

safe:CallAsync(function()
	return someAsyncFunction()
end):andThen(function(result)
	print("Got result:", result)
end):catch(warn)

Requires the Promise library. Errors are caught and passed to .catch.

CallDeferred

safe:CallDeferred(fn: function, ...any)
Schedules `fn` to be called safely in a deferred (non-blocking) manner.

Use case: Runs your function safely after the current thread yields.
Best for: Non-blocking operations like safe event dispatching.

Example

safe:CallDeferred(function()
	print("Runs later, but safely!")
end)

It’s like task.defer but with error safety built-in.

CallDelayed

success, result = safe:CallDelayed(delay: number, fn: function, ...any)

Waits delay seconds then calls fn safely.

Example

safe:CallDelayed(2, function()
	print("Called after 2 seconds.")
end)

Use case: Automatically wraps all functions inside a table with SafeCall error handling.
Best for: Making whole utility modules or service interfaces crash-safe without rewriting every function.

ProtectTable

protectedTable = safe:ProtectTable(tbl: table)

Returns a version of tbl where all functions are wrapped with safe calls.
Use case: Automatically wraps all functions inside a table with SafeCall error handling.
Best for: Making whole utility modules or service interfaces crash-safe without rewriting every function.

Example

local unsafeUtils = {
	PrintHello = function()
		print("Hello")
	end,
	BreakIt = function()
		error("This will crash!")
	end
}

local safeUtils = safe:ProtectTable(unsafeUtils)

safeUtils.PrintHello() --> works normally
safeUtils.BreakIt()    --> error is caught, doesn't crash

WrapEvent

connection = safe:WrapEvent(remote: RemoteEvent | BindableEvent, callback: function)

Wraps a remote or bindable event callback with safe error handling.
Use case: Wraps RemoteEvent or BindableEvent connections with SafeCall.
Best for: Secure remote handling to prevent crashes from bad data.

Example

safe:WrapEvent(RemoteEvent, function(player, data)
	print(player.Name, data)
end)

WrapFunction

safe:WrapFunction(remote: RemoteFunction | BindableFunction, callback: function)

Wraps a remote or bindable function invocation callback safely.
Use case: Wraps RemoteFunction or BindableFunction callbacks.
Best for: Validating or securing remote/bindable invokes.

Example

safe:WrapFunction(RemoteFunction, function(player, request)
	return processRequest(request)
end)

CallBatch

results = safe:CallBatch(functions: {function})

Calls a list of functions safely, returning a table of success/result pairs.
Use case: Executes a batch of functions safely, returns all results.
Best for: Running multiple tasks (e.g. setup, cleanup) with error isolation.

Example

local results = safe:CallBatch({
	function() return "ok1" end,
	function() error("bad2") end,
	function() return "ok3" end,
})

CallWithTimeout

success, result = safe:CallWithTimeout(timeout: number, fn: function, ...any)

Calls fn safely but aborts if it exceeds timeout seconds.
Use case: Ensures a function doesn’t run forever — fails if it takes too long.
Best for: External service calls, long waits.

Example

local success, result = safe:CallWithTimeout(5, function()
	while true do task.wait() end
end)

Circuit Breaker

breaker = safe:CreateCircuitBreaker(threshold?: number, resetTime?: number)

success, result = safe:CallWithCircuitBreaker(breaker, fn: function, ...any)

Creates a circuit breaker to stop calling fn if repeated failures occur, then resets after cooldown.
Use case: Temporarily disables a failing function after repeated errors.
Best for: External APIs, datastores, unstable services.

Example

local breaker = safe:CreateCircuitBreaker(3, 10) -- 3 fails, 10s cooldown

safe:CallWithCircuitBreaker(breaker, function()
	return ExternalAPI()
end)

Rate Limiter

limiter = safe:CreateRateLimiter(maxCalls?: number, timeWindow?: number)

success, result = safe:CallWithRateLimit(limiter, fn: function, ...any)

Safely connects to Roblox events/signals with automatic error handling and optional weak reference disconnect.
Use case: Limits how often a function can run within a time window.
Best for: Anti-spam, cooldowns, external APIs.

Example

local limiter = safe:CreateRateLimiter(5, 10) -- Max 5 calls per 10s

safe:CallWithRateLimit(limiter, function()
	print("Allowed call")
end)

ConnectSafe

connection = safe:ConnectSafe(signal: RBXScriptSignal, callback: function, weakRef?: Instance)

Safely connects to Roblox events/signals with automatic error handling and optional weak reference disconnect.
Use case: Safely connects to events/signals.
Best for: Cleaner .Changed, .Touched, or custom signal connections.

Example

safe:ConnectSafe(part.Touched, function(hit)
	print("Touched:", hit)
end)

Memoize

memoizedFn = safe:Memoize(fn: function, ttl?: number)

Returns a memoized version of fn with cache TTL (time-to-live).
Use case: Caches results from a function to avoid repeating work.
Best for: Expensive calculations, function caching.

Example

local slowFn = safe:Memoize(function(x)
	task.wait(2)
	return x * 2
end, 10)

Profiling

profiler = safe:CreateProfiler()

success, result = safe:CallWithProfiler(profiler, fn: function, ...any)

stats = safe:GetProfilerStats(profiler)

Profile calls for performance and error stats.
Use case: Measure performance and error stats of your functions.
Best for: Debugging slow or unstable code.

local profiler = safe:CreateProfiler()

safe:CallWithProfiler(profiler, function()
	task.wait(0.5)
	error("whoops")
end)

print(safe:GetProfilerStats(profiler))

Global Error Handlers

safe:AddGlobalHandler(handler: function)
safe:RemoveGlobalHandler(handler: function)

Add or remove global error handlers that are called on every error.
A global error handler is a function that runs every time any safe:Call() fails, no matter where it’s called in your game.

Think of it like a global listener for all uncaught errors in SafeCall.

Example

local function globalLogger(err, traceback)
	print("GLOBAL ERROR:", err)
	print("Traceback:\n", traceback)
end

safe:AddGlobalHandler(globalLogger)

safe:Call(function()
	error("Something broke!")
end)
Usage Examples

Usage Examples

Simple safe call

safe:Call(function()
	error("Oops!")
end)
-- Output: Warning printed, no crash

Safe remote event handling

local remote = game.ReplicatedStorage:WaitForChild("SomeEvent")
safe:WrapEvent(remote, function(player, data)
	print(player.Name, data)
end)

Safe async Promise call

safe:CallAsync(function()
	return Promise.new(function(resolve, reject)
		-- async logic here
		resolve("Done")
	end)
end):andThen(print):catch(warn)

Use with retry

safe:CallWithRetry(function()
	-- unstable operation
end, 5, 0.2, 2)

webhook

local HttpService = game:GetService("HttpService")
local webhookUrl = "YOUR_DISCORD_WEBHOOK_URL"

local function webhookLogger(err)
    local payload = HttpService:JSONEncode({
        username = "SafeCall Logger",
        embeds = {{
            title = "SafeCall Error",
            description = tostring(err),
            color = 16711680, -- red
            timestamp = os.date("!%Y-%m-%dT%H:%M:%SZ"),
        }}
    })

    pcall(function()
        HttpService:PostAsync(webhookUrl, payload, Enum.HttpContentType.ApplicationJson)
    end)
end

local safe = SafeCall.new(webhookLogger)
8 Likes

So I could effectively use SafeCall and wrap anything inside of it? If so, this seems more like a cool cheat code instead of dealing with each function or line of code that could error out or crash the game.

Absolutely
SafeCall is designed to be a lightweight “cheat code” for safer development — it acts like a safety net around any potentially error-prone code.

You can wrap:

  • Player data loading/saving
  • Remote events/functions
  • UI logic
  • Async calls (via Promises)
  • Even utility modules or entire tables of functions

Instead of writing pcall() a dozen times or worrying about obscure edge-case crashes, SafeCall helps centralize and standardize your error handling. Bonus: you can log errors, retry failed logic, or even send errors to a Discord webhook with one line.

It’s built for devs who want more control, less crash risk, and smoother debugging workflows.

1 Like

Wow! This is going to blow up or should at some point. Literal gem to have in any game. I am going to add this into my game very soon. I was literally going to build it from the ground up and this is exactly what I was looking for to start off with.

Quick question, can you explain Global Error Handlers a little more? I am wondering if the global error handler may be even sufficient enough for me instead wrapping my code within this each time. Am I interpreting this right? Just want to make sure.

Thank You,

A Global Error Handler lets you register a function that runs whenever any SafeCall instance catches an error. It’s like a universal callback for logging or alerting you when something breaks — without wrapping every single line manually.

Example:

safe:AddGlobalHandler(function(err, traceback)
    print("Global error caught:", err)
    -- You could log to a webhook, DataStore, console, etc.
end)

So yes — if you’ve wrapped your game’s risky logic in safe:Call(...), any error inside those will trigger your global handler. Super handy for:

  • Centralized error logging
  • Custom crash reporting
  • Discord webhook alerts
  • In-game dev console popups, etc.

BUT — You Still Need to Wrap Code

The global handler only triggers after a SafeCall-wrapped function errors — it doesn’t automatically catch all game errors unless you’re using safe:Call(...), safe:WrapEvent(...), etc.

Think of global handlers as the final layer for error response, but not a full replacement for wrapping code. If you don’t wrap it with SafeCall, it’ll still crash normally.

1 Like

Thank you for the information. I am all good to go. :slight_smile:

*Just a suggestion, it may be worth explaining some of the methods in depth as you have done for me (like help text). It really paints the FULL picture what this can really do. Solid stuff nonetheless.

1 Like

I’ve just updated the documentation—hopefully, it helps! If you notice anything missing or incorrect, feel free to let me know, and I’ll make sure to address it as quickly as possible.

Thank you for supporting the module, and best of luck with your project! I plan to make more updates soon, including a few features that I think will be really useful.

Enjoy!

2 Likes

SafeCall v1.1+ Update

New Features:

  • Context Tags — Group or label error logs for better traceability.
  • Error Filtering System — Ignore specific error messages or patterns using Lua string matching.
  • Dynamic Retry Logic — Retry only on specific error types (e.g. rate limits).
  • Safe Task Scheduler — Schedule named, repeatable safe tasks for polling or background jobs.

:warning: Note: This framework isn’t perfect — if you come across any bugs or issues, feel free to reach out and I’ll patch them up ASAP.

Installation

  • Download the SafeCall v1.1.rbxm (138.1 KB)
  • Ungroup the ReplicatedStorage model and place the Main folder inside ReplicatedStorage.
  • Also, place SafeCallExample and SafeCallTest2 content inside ServerScriptService.

Require it:

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local SafeCall = require(ReplicatedStorage.Main.Modules.SafeCall)
local safe = SafeCall.new()

Context Tags (Call)

Tag function calls for organized error logs and grouped global handler tracking.

safe:Call(function()
	error("Failed to load data")
end, "DataStore:Load")
  • Log Output: [DataStore:Load] Failed to load data
  • Passed to global error handlers as the 3rd parameter.

Error Filtering System

Ignore unwanted or known error patterns…

safe:AddErrorIgnorePattern("Missing asset id")
safe:AddErrorIgnorePattern("^Expected")

Errors matching these patterns are ignored from logs and global handlers.

Dynamic Retry Logic

Customize retry conditions based on error content.

safe:SetRetryHandler(function(err)
	return string.find(err, "429") ~= nil -- Retry only if rate-limited
end)

Applied during CallWithRetry execution.

Safe Task Scheduler

Schedule safe recurring tasks under a named key.

safe:Schedule("DataFetch", 2, function()
	-- Runs every 2 seconds
end)

safe:StopSchedule("DataFetch") -- Stop the task

Prevents duplicate tasks under the same name.

I’m looking for feedback to improve this framework for everyone. I want it to be enjoyable and useful. Your feedback will be greatly appreciated. Thank you!

1 Like

This is a gem, can you please put this on GitHub?

1 Like

First of all I would say with ConnectSafe don’t pass the arguments from the connection to the logger. A couple suggestion I would give are letting use pass our own Promise module instead of attempt to require it and allowing the option to choose between a Promise call or regular call when using things like ConnectSafe. I’ll keep tinkering around and see if I find anything else.

1 Like

Thank you for your comments - you made some excellent points.

Yes, for ConnectSafe, I agree that passing the entire argument list to the logger is not ideal. I am going to change it so that the logger only receives the error message itself and not the complete arg list of parameters.

I will also support injecting your own Promise implementation into the module rather than just requiring it. This would make the module way more positionable for users with different promise libraries.

And good point on being able to switch between promise-based execution and regular pcall inside ConnectSafe and other wrappers. I will also add a flag to allow users to switch between promise execution and pcall depending upon user workflow.

I will keep modifying it - if you see anything else, I am always open to suggestions.

I will make a GitHub post for it soon.

1 Like

SafeCall v1.2+ Update

Updated:

ConnectSafe

:warning: Note: This framework isn’t perfect — if you come across any bugs or issues, feel free to reach out and I’ll patch them up ASAP.

Installation

  • Download the SafeCall v1.2.rbxm (138.1 KB)
  • Ungroup the ReplicatedStorage model and place the Main folder inside ReplicatedStorage.
  • Also, place SafeCallExample and SafeCallTest2 content inside ServerScriptService.

Added Github

Require it:

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local SafeCall = require(ReplicatedStorage.Main.Modules.SafeCall)
local safe = SafeCall.new()

ConnectSafe

connection = safe:ConnectSafe(
	signal: RBXScriptSignal,
	callback: (...any) -> (),
	options: {
		weakRef: Instance?,
		usePromise: boolean?
	}?
)

Examples

Basic:

safe:ConnectSafe(part.Touched, function(hit)
	print("Touched:", hit)
	error("Test error") -- will be caught and logged, not crash
end)

With weakRef (auto-disconnect when instance is destroyed):

safe:ConnectSafe(button.MouseButton1Click, function()
	print("Button clicked")
end, {
	weakRef = button,
})

With Promise mode (if you’ve set a Promise module):

local Promise = require(ReplicatedStorage.Packages.Promise)
safe:SetPromiseModule(Promise)

safe:ConnectSafe(remote.OnClientEvent, function(data)
	print("Got data:", data)
	error("Promise error test")
end, {
	usePromise = true,
})
1 Like

This looks very promising, I think I should use it

1 Like

Can you upload the full project, with the Rojo and Wally files etc. I would like to make some pull requests.

1 Like

Sure, I can do that.

I will do that and release a update.

1 Like

SafeCall Now Has Full Rojo Support!

The entire SafeCall framework is now fully structured with Rojo on GitHub!

This means you can easily browse the source, contribute improvements, and submit pull requests to help evolve the module even further.

Whether you’re fixing bugs, refining performance, or adding new safety utilities, contributions are welcome!

GitHub Repository:

Hi, Im using your module now, but I dont understand something, why are there 2 functions with the same names?,

local safe_call = require(script.libs.SafeCall)
local safe = safe_call.new() -- <- for example

safe:call() -- <- wat is this?
safe:Call() -- <- basic safe call 

What is the difference between them?

If safe:call is a private function, I recommend putting _ before the name

Its just that sometimes it can be confusing and cause misunderstandings