Error Handling

Proper error handling patterns

Master advanced error handling patterns with NOPE-PRO. This tutorial covers everything from basic error handling to complex recovery strategies.

Understanding Error Types

String Errors (Quick & Simple)

Best for prototyping and simple cases:

1public Result<Player, string> LoadPlayer(string id)
2{
3    if (string.IsNullOrEmpty(id))
4        return "Invalid player ID";
5        
6    if (!File.Exists($"saves/{id}.json"))
7        return "Player save not found";
8        
9    // ... load player
10    return player;
11}

Pros: Quick to write, easy to understand
Cons: No structure, hard to handle programmatically

Enum Errors (Structured)

Better for production code with known error cases:

1public enum PlayerError
2{
3    InvalidId,
4    NotFound,
5    CorruptedData,
6    VersionMismatch,
7    PermissionDenied
8}
9
10public Result<Player, PlayerError> LoadPlayer(string id)
11{
12    if (string.IsNullOrEmpty(id))
13        return PlayerError.InvalidId;
14        
15    if (!File.Exists($"saves/{id}.json"))
16        return PlayerError.NotFound;
17        
18    // ... more checks
19    return player;
20}
21
22// Handle specific errors
23LoadPlayer("player123")
24    .Match(
25        onSuccess: player =>
26        {
27            InitializePlayer(player);
28            return Unit.Value;
29        },
30        onFailure: error =>
31        {
32            switch (error)
33            {
34                case PlayerError.NotFound:
35                    CreateNewPlayer();
36                    break;
37                case PlayerError.VersionMismatch:
38                    MigratePlayerData();
39                    break;
40                default:
41                    ShowErrorDialog(error.ToString());
42                    break;
43            }
44            return Unit.Value;
45        }
46    );

Custom Error Classes (Most Flexible)

Best for complex applications:

1// Base error class
2public abstract class GameError
3{
4    public string Message { get; }
5    public DateTime Timestamp { get; }
6    
7    protected GameError(string message)
8    {
9        Message = message;
10        Timestamp = DateTime.Now;
11    }
12}
13
14// Specific error types
15public class ValidationError : GameError
16{
17    public string FieldName { get; }
18    public object AttemptedValue { get; }
19    
20    public ValidationError(string fieldName, object attemptedValue, string message)
21        : base(message)
22    {
23        FieldName = fieldName;
24        AttemptedValue = attemptedValue;
25    }
26}
27
28public class NetworkError : GameError
29{
30    public int StatusCode { get; }
31    public string Endpoint { get; }
32    
33    public NetworkError(int statusCode, string endpoint, string message)
34        : base(message)
35    {
36        StatusCode = statusCode;
37        Endpoint = endpoint;
38    }
39}
40
41// Usage
42public Result<Player, GameError> ValidatePlayer(Player player)
43{
44    if (player.Level < 1 || player.Level > 100)
45        return new ValidationError("Level", player.Level, 
46            "Level must be between 1 and 100");
47            
48    if (string.IsNullOrEmpty(player.Name))
49        return new ValidationError("Name", null, 
50            "Player name is required");
51            
52    return player;
53}

Error Recovery Patterns

Pattern 1: Fallback Values

1public class ConfigLoader
2{
3    public GameConfig LoadConfig()
4    {
5        return LoadFromFile()
6            .OrElse(LoadFromPlayerPrefs)
7            .OrElse(LoadFromResources)
8            .Match(
9                config => config,
10                errorMessage => 
11                {
12                    Debug.LogError($"Failed to load game config: {errorMessage}");
13                    return GetDefaultConfig();
14                }
15            );
16    }
17    
18    private Result<GameConfig, string> LoadFromFile()
19    {
20        return Result.Of(
21            () => JsonUtility.FromJson<GameConfig>(File.ReadAllText("config.json")),
22            ex => "File not found or invalid"
23        );
24    }
25    
26    private Result<GameConfig, string> LoadFromPlayerPrefs()
27    {
28        var json = PlayerPrefs.GetString("GameConfig", "");
29        return string.IsNullOrEmpty(json)
30            ? "No config in PlayerPrefs"
31            : JsonUtility.FromJson<GameConfig>(json);
32    }
33    
34    private Result<GameConfig, string> LoadFromResources()
35    {
36        var configAsset = Resources.Load<TextAsset>("DefaultConfig");
37        return configAsset != null
38            ? JsonUtility.FromJson<GameConfig>(configAsset.text)
39            : "No default config in Resources";
40    }
41    
42    private GameConfig GetDefaultConfig()
43    {
44        return new GameConfig
45        {
46            Difficulty = Difficulty.Normal,
47            SoundVolume = 0.8f,
48            MusicVolume = 0.5f
49        };
50    }
51}

Pattern 2: Retry with Backoff

1public class NetworkService
2{
3    public async UniTask<Result<T, NetworkError>> RequestWithRetry<T>(
4        string endpoint,
5        int maxRetries = 3,
6        float baseDelay = 1f)
7    {
8        Result<T, NetworkError> result = default;
9        
10        for (int attempt = 0; attempt < maxRetries; attempt++)
11        {
12            result = await MakeRequest<T>(endpoint);
13            
14            if (result.IsSuccess)
15                return result;
16                
17            // Check if error is retryable
18            if (!IsRetryable(result.Error))
19                return result;
20                
21            // Exponential backoff
22            if (attempt < maxRetries - 1)
23            {
24                var delay = baseDelay * Mathf.Pow(2, attempt);
25                Debug.Log($"Retry {attempt + 1}/{maxRetries} after {delay}s");
26                await UniTask.Delay(TimeSpan.FromSeconds(delay));
27            }
28        }
29        
30        return result.MapError(e => new NetworkError(
31            e.StatusCode,
32            e.Endpoint,
33            $"{e.Message} (after {maxRetries} attempts)"
34        ));
35    }
36    
37    private bool IsRetryable(NetworkError error)
38    {
39        // Retry on timeout or server errors
40        return error.StatusCode >= 500 || error.StatusCode == 408;
41    }
42}

Pattern 3: Graceful Degradation

1public class PlayerDataService
2{
3    public Result<PlayerStats, string> LoadPlayerStats(string playerId)
4    {
5        return LoadFullStats(playerId)
6            .EnableDebug("LoadStats")
7            .MapError(error => 
8            {
9                Debug.LogWarning($"Full stats failed: {error}");
10                return error;
11            })
12            .OrElse(() => LoadCachedStats(playerId)
13                .Map(stats => 
14                {
15                    stats.IsFromCache = true;
16                    return stats;
17                }))
18            .OrElse(() => CreateMinimalStats(playerId));
19    }
20    
21    private Result<PlayerStats, string> LoadFullStats(string playerId)
22    {
23        // Load from server
24        return Result<PlayerStats, string>.Failure("Server unavailable");
25    }
26    
27    private Result<PlayerStats, string> LoadCachedStats(string playerId)
28    {
29        var cached = PlayerPrefs.GetString($"stats_{playerId}", "");
30        if (string.IsNullOrEmpty(cached))
31            return "No cached stats";
32            
33        return Result.Of(
34            () => JsonUtility.FromJson<PlayerStats>(cached),
35            ex => "Cached stats corrupted"
36        );
37    }
38    
39    private Result<PlayerStats, string> CreateMinimalStats(string playerId)
40    {
41        Debug.Log("Creating minimal stats for offline play");
42        return new PlayerStats
43        {
44            PlayerId = playerId,
45            Level = 1,
46            Experience = 0,
47            IsFromCache = true,
48            IsMinimal = true
49        };
50    }
51}

Pattern 4: Error Aggregation

1public class BatchProcessor
2{
3    public class BatchResult
4    {
5        public List<Item> SuccessfulItems { get; } = new();
6        public List<(Item item, string error)> FailedItems { get; } = new();
7        
8        public bool HasErrors => FailedItems.Count > 0;
9        public float SuccessRate => SuccessfulItems.Count / 
10            (float)(SuccessfulItems.Count + FailedItems.Count);
11    }
12    
13    public BatchResult ProcessItems(List<Item> items)
14    {
15        var result = new BatchResult();
16        
17        foreach (var item in items)
18        {
19            ProcessItem(item)
20                .EnableDebug($"Process_{item.id}")
21                .Match<Unit>(
22                    onSuccess: processedItem => 
23                    {
24                        result.SuccessfulItems.Add(processedItem);
25                        return Unit.Value;
26                    },
27                    onFailure: error => 
28                    {
29                        result.FailedItems.Add((item, error));
30                        Debug.LogWarning($"Failed to process {item.id}: {error}");
31                        
32                        return Unit.Value;
33                    }
34                );
35        }
36        
37        Debug.Log($"Batch complete: {result.SuccessRate:P} success rate");
38        return result;
39    }
40    
41    private Result<Item, string> ProcessItem(Item item)
42    {
43        return ValidateItem(item)
44            .Bind(TransformItem)
45            .Bind(SaveItem);
46    }
47}

Advanced Error Handling

Contextual Errors

Add context as errors propagate up:

1public class GameSaveService
2{
3    public Result<Unit, string> SaveGame(string slot)
4    {
5        return GatherSaveData()
6            .MapError(e => $"Failed to gather data: {e}")
7            .Bind(data => SerializeData(data)
8                .MapError(e => $"Serialization failed: {e}"))
9            .Bind(json => WriteToFile(slot, json)
10                .MapError(e => $"File write failed: {e}"))
11            .Tap(_ => Debug.Log($"Game saved to slot {slot}"))
12            .MapError(e => $"Save operation failed in slot {slot}: {e}");
13    }
14}

Error Transformation Pipeline

1public class ErrorTransformer
2{
3    // Convert low-level errors to user-friendly messages
4    public static string ToUserFriendly(GameError error)
5    {
6        return error switch
7        {
8            NetworkError net when net.StatusCode == 404 => 
9                "The requested content was not found.",
10            NetworkError net when net.StatusCode >= 500 => 
11                "Server is experiencing issues. Please try again later.",
12            ValidationError val => 
13                $"Invalid {val.FieldName}: {val.Message}",
14            _ => "An unexpected error occurred. Please try again."
15        };
16    }
17    
18    // Usage
19    public void ShowError(Result<Unit, GameError> result)
20    {
21        result
22            .MapError(ToUserFriendly)
23            .Match(
24                onSuccess: _ => Unit.Value,
25                onFailure: userMessage => { ShowErrorDialog(userMessage); return Unit.Value; }
26            );
27    }
28}

Compensating Actions

Undo operations when something fails:

1public class TransactionManager
2{
3    private Stack<Action> compensations = new();
4    
5    public Result<Unit, string> ExecuteTransaction(List<Operation> operations)
6    {
7        compensations.Clear();
8        
9        foreach (var op in operations)
10        {
11            var result = op.Execute()
12                .Tap(_ => compensations.Push(op.Compensate));
13                
14            if (result.IsFailure)
15            {
16                // Rollback all successful operations
17                Rollback();
18                return $"Transaction failed at {op.Name}: {result.Error}";
19            }
20        }
21        
22        return Unit.Value;
23    }
24    
25    private void Rollback()
26    {
27        Debug.Log($"Rolling back {compensations.Count} operations");
28        
29        while (compensations.Count > 0)
30        {
31            try
32            {
33                compensations.Pop().Invoke();
34            }
35            catch (Exception ex)
36            {
37                Debug.LogError($"Compensation failed: {ex.Message}");
38            }
39        }
40    }
41}

Error Handling Best Practices

1. Be Specific with Error Messages

1// Bad - vague error
2if (player.Gold < itemPrice)
3    return "Insufficient resources";
4
5// Good - specific and actionable
6if (player.Gold < itemPrice)
7    return $"Not enough gold. Need {itemPrice}, have {player.Gold}";

2. Use Error Types for Different Handling

1public interface IRecoverableError
2{
3    Result<Unit, IGameError> Recover();
4}
5
6public class CacheExpiredError : GameError, IRecoverableError
7{
8    private readonly Func<Result<Unit, IGameError>> refreshCache;
9    
10    public CacheExpiredError(Func<Result<Unit, IGameError>> refreshCache)
11        : base("Cache has expired")
12    {
13        this.refreshCache = refreshCache;
14    }
15    
16    public Result<Unit, IGameError> Recover()
17    {
18        Debug.Log("Attempting to refresh cache...");
19        return refreshCache();
20    }
21}
22
23// Usage
24result.Match(
25    onSuccess: data => { HandleSuccess(data); return Unit.Value; },
26    onFailure: error =>
27    {
28        if (error is IRecoverableError recoverable)
29        {
30            recoverable.Recover()
31                .Match(
32                    onSuccess: _ => { RetryOriginalOperation(); return Unit.Value; },
33                    onFailure: e => { ShowFatalError(e); return Unit.Value; }
34                );
35        }
36        else
37        {
38            ShowError(error);
39        }
40    }
41);

3. Log Errors Appropriately

1public static class ResultLogging
2{
3    public static Result<T, E> LogError<T, E>(this Result<T, E> result, string context)
4    {
5        if (result.IsFailure)
6        {
7            var error = result.Error;
8            
9            // Log based on error severity
10            if (error is CriticalError)
11                Debug.LogError($"[CRITICAL] {context}: {error}");
12            else if (error is Warning)
13                Debug.LogWarning($"[WARNING] {context}: {error}");
14            else
15                Debug.Log($"[INFO] {context}: {error}");
16                
17            // Send to analytics if needed
18            Analytics.LogError(context, error.ToString());
19        }
20        
21        return result;
22    }
23}

4. Create Error Helpers

1public static class ErrorHelpers
2{
3    // Validate multiple conditions
4    public static Result<T, string> Validate<T>(
5        T value,
6        params (Func<T, bool> predicate, string error)[] validations)
7    {
8        foreach (var (predicate, error) in validations)
9        {
10            if (!predicate(value))
11                return error;
12        }
13        return value;
14    }
15    
16    // Usage
17    public Result<Player, string> ValidatePlayer(Player player)
18    {
19        return ErrorHelpers.Validate(player,
20            (p => !string.IsNullOrEmpty(p.Name), "Name is required"),
21            (p => p.Level >= 1, "Level must be at least 1"),
22            (p => p.Level <= 100, "Level cannot exceed 100"),
23            (p => p.Health > 0, "Player must have health")
24        );
25    }
26}

Complete Example: Robust Save System

Here's a complete example showing all error handling patterns:

1using UnityEngine;
2using System;
3using System.Collections.Generic;
4using NOPE.Runtime.Core;
5using NOPE.Runtime.Core.Result;
6using NOPE.PRO.VisualDebugger;
7
8public class RobustSaveSystem : MonoBehaviour
9{
10    // Error types
11    public enum SaveError
12    {
13        InvalidData,
14        SerializationFailed,
15        DiskFull,
16        PermissionDenied,
17        CorruptedFile,
18        NetworkError
19    }
20    
21    // Save data structure
22    [Serializable]
23    public class SaveData
24    {
25        public string playerId;
26        public int version;
27        public DateTime timestamp;
28        public Dictionary<string, object> gameState;
29    }
30    
31    private const int CURRENT_VERSION = 2;
32    private const int MAX_RETRIES = 3;
33    
34    // Main save method with all error handling
35    public void SaveGame(string slot)
36    {
37        CreateSaveData()
38            .EnableDebug($"SaveGame_{slot}")
39            .Bind(data => ValidateSaveData(data))
40            .Bind(data => SerializeWithBackup(data))
41            .Bind(json => SaveWithRetry(slot, json, MAX_RETRIES))
42            .Tap(_ => OnSaveSuccess(slot))
43            .Match(
44                onSuccess: _ => { ShowSaveSuccessUI(slot); return Unit.Value; },
45                onFailure: error => { HandleSaveError(error, slot); return Unit.Value; }
46            );
47    }
48    
49    // Create save data with error handling
50    private Result<SaveData, SaveError> CreateSaveData()
51    {
52        try
53        {
54            var data = new SaveData
55            {
56                playerId = GetPlayerId(),
57                version = CURRENT_VERSION,
58                timestamp = DateTime.Now,
59                gameState = GatherGameState()
60            };
61            
62            return data;
63        }
64        catch (Exception ex)
65        {
66            Debug.LogError($"Failed to create save data: {ex}");
67            return SaveError.InvalidData;
68        }
69    }
70    
71    // Validate with specific checks
72    private Result<SaveData, SaveError> ValidateSaveData(SaveData data)
73    {
74        if (string.IsNullOrEmpty(data.playerId))
75            return SaveError.InvalidData;
76            
77        if (data.gameState == null || data.gameState.Count == 0)
78            return SaveError.InvalidData;
79            
80        return data;
81    }
82    
83    // Serialize with backup of previous data
84    private Result<string, SaveError> SerializeWithBackup(SaveData data)
85    {
86        return Result.Of(
87            () => {
88                // Backup existing data first
89                BackupExistingData();
90                
91                // Serialize new data
92                return JsonUtility.ToJson(data, true);
93            },
94            ex => {
95                Debug.LogError($"Serialization failed: {ex}");
96                return SaveError.SerializationFailed;
97            }
98        );
99    }
100    
101    // Save with retry logic
102    private Result<Unit, SaveError> SaveWithRetry(string slot, string json, int retriesLeft)
103    {
104        var result = AttemptSave(slot, json);
105        
106        if (result.IsSuccess || retriesLeft <= 0)
107            return result;
108            
109        // Only retry certain errors
110        if (IsRetryableError(result.Error))
111        {
112            Debug.Log($"Save failed, retrying... ({retriesLeft} attempts left)");
113            System.Threading.Thread.Sleep(1000); // Wait before retry
114            return SaveWithRetry(slot, json, retriesLeft - 1);
115        }
116        
117        return result;
118    }
119    
120    // Actual save operation
121    private Result<Unit, SaveError> AttemptSave(string slot, string json)
122    {
123        try
124        {
125            var path = GetSavePath(slot);
126            
127            // Check disk space
128            if (GetAvailableDiskSpace() < json.Length * 2)
129                return SaveError.DiskFull;
130                
131            // Write to file
132            System.IO.File.WriteAllText(path, json);
133            
134            // Verify write
135            var verification = System.IO.File.ReadAllText(path);
136            if (verification != json)
137                return SaveError.CorruptedFile;
138                
139            return Unit.Value;
140        }
141        catch (UnauthorizedAccessException)
142        {
143            return SaveError.PermissionDenied;
144        }
145        catch (Exception ex)
146        {
147            Debug.LogError($"Save failed: {ex}");
148            return SaveError.CorruptedFile;
149        }
150    }
151    
152    // Error handling
153    private void HandleSaveError(SaveError error, string slot)
154    {
155        var recovery = error switch
156        {
157            SaveError.DiskFull => RecoverFromDiskFull(),
158            SaveError.PermissionDenied => RecoverFromPermissionDenied(),
159            SaveError.NetworkError => OfferOfflineSave(slot),
160            _ => Result<Unit, SaveError>.Failure(error)
161        };
162        
163        recovery.Match(
164            onSuccess: _ => {
165                Debug.Log("Recovery successful, retrying save...");
166                SaveGame(slot); // Retry after recovery
167                return Unit.Value;
168            },
169            onFailure: e => {
170                var userMessage = GetUserFriendlyMessage(e);
171                ShowErrorDialog(userMessage);
172                
173                // Restore from backup if critical
174                if (IsCriticalError(e))
175                    RestoreFromBackup();
176
177                return Unit.Value;
178            }
179        );
180    }
181    
182    // Recovery strategies
183    private Result<Unit, SaveError> RecoverFromDiskFull()
184    {
185        Debug.Log("Attempting to clear cache for space...");
186        
187        return Result.Of<Unit, SaveError>(
188            () => {
189                ClearCache();
190                DeleteOldSaves();
191                return Unit.Value;
192            },
193            ex => SaveError.DiskFull
194        );
195    }
196    
197    // Helper methods
198    private bool IsRetryableError(SaveError error)
199    {
200        return error == SaveError.NetworkError || 
201               error == SaveError.CorruptedFile;
202    }
203    
204    private bool IsCriticalError(SaveError error)
205    {
206        return error == SaveError.CorruptedFile || 
207               error == SaveError.InvalidData;
208    }
209    
210    private string GetUserFriendlyMessage(SaveError error)
211    {
212        return error switch
213        {
214            SaveError.DiskFull => "Not enough storage space. Please free up some space and try again.",
215            SaveError.PermissionDenied => "Cannot save to this location. Please check permissions.",
216            SaveError.NetworkError => "Connection lost. Your progress will be saved locally.",
217            SaveError.CorruptedFile => "Save file corrupted. Restored from backup.",
218            _ => "Failed to save game. Please try again."
219        };
220    }
221    
222    // Stub implementations
223    private string GetPlayerId() => "player123";
224    private Dictionary<string, object> GatherGameState() => new();
225    private void BackupExistingData() { }
226    private long GetAvailableDiskSpace() => 1000000;
227    private string GetSavePath(string slot) => $"saves/{slot}.json";
228    private void OnSaveSuccess(string slot) { }
229    private void ShowSaveSuccessUI(string slot) { }
230    private void ShowErrorDialog(string message) => Debug.LogError(message);
231    private Result<Unit, SaveError> RecoverFromPermissionDenied() => SaveError.PermissionDenied;
232    private Result<Unit, SaveError> OfferOfflineSave(string slot) => Unit.Value;
233    private void ClearCache() { }
234    private void DeleteOldSaves() { }
235    private void RestoreFromBackup() { }
236}

Summary

You've learned:

  • ✅ Different error type strategies (string, enum, custom classes)
  • ✅ Error recovery patterns (fallback, retry, degradation)
  • ✅ Advanced error handling techniques
  • ✅ Best practices for production code
  • ✅ How to build robust systems with comprehensive error handling