Error Handling

View Source

Strategies for handling errors in Mau templates and applications.

Overview

This guide covers different approaches to error handling, from strict mode validation to graceful degradation and custom error handlers.

Error Types

Mau can produce different types of errors:

# Compilation errors
{:error, error} = Mau.compile("invalid {{ syntax")

# Runtime errors
{:error, error} = Mau.render("{{ undefined_var }}", %{})

# Filter errors
{:error, error} = Mau.render("{{ 'text' | abs }}", %{})

# Type errors
{:error, error} = Mau.render("{{ undefined_function() }}", %{})

Strict Mode

Control error reporting with strict mode:

# ❌ Lenient mode (default): Errors return gracefully
{:ok, output} = Mau.compile("invalid {{ syntax")
# Returns: {:error, error_message}

# ✅ Strict mode: More explicit error reporting
{:ok, ast, warnings} = Mau.compile(template, strict_mode: true)
# Returns warnings list in third element

Undefined Variables

Handle undefined variable access:

# Default behavior: undefined variables become empty strings
context = %{"name" => "Alice"}
{:ok, output} = Mau.render("Hello {{ name }}, meet {{ unknown }}", context)
# Output: "Hello Alice, meet "

# Strict checking: provide defaults
context = %{
  "name" => "Alice",
  "unknown" => nil  # Explicitly set to nil
}
{:ok, output} = Mau.render("Hello {{ name }}, meet {{ unknown | default('Friend') }}", context)
# Output: "Hello Alice, meet Friend"

Filter Error Handling

Handle filter errors gracefully:

# Type mismatch in filter
{:ok, output} = Mau.render("{{ 'text' | abs }}", %{})
# Error: "abs can only be applied to numbers"

# Handle with validation
context = %{"value" => "not_a_number"}
{:ok, output} = Mau.render(
  "{% if value | is_number %}
     {{ value | abs }}
   {% else %}
     Invalid number
   {% endif %}",
  context
)

Application-Level Error Handling

Basic Error Pattern

defmodule MyApp.TemplateRenderer do
  def render(template_name, context) do
    case get_template(template_name) do
      {:ok, template_ast} ->
        render_template(template_ast, context)

      {:error, reason} ->
        {:error, "Template not found: #{reason}"}
    end
  end

  defp render_template(template_ast, context) do
    case Mau.render(template_ast, context) do
      {:ok, output} ->
        {:ok, output}

      {:error, error} ->
        {:error, "Rendering failed: #{error}"}
    end
  end

  defp get_template(name) do
    case Application.get_env(:my_app, :compiled_templates)[name] do
      ast when is_tuple(ast) -> {:ok, ast}
      _ -> {:error, "#{name} not found"}
    end
  end
end

With Logging

defmodule MyApp.TemplateRenderer do
  require Logger

  def render_safe(template, context) do
    case Mau.render(template, context) do
      {:ok, output} ->
        {:ok, output}

      {:error, error} ->
        Logger.error("Template rendering failed: #{error}")
        {:ok, render_fallback(template)}
    end
  end

  def render_fallback(template) do
    "An error occurred while rendering the template"
  end
end

Error Recovery Strategies

Graceful Degradation

Provide fallback content when rendering fails:

defmodule MyApp.SafeRenderer do
  def render_or_fallback(template, context, fallback \\ "") do
    case Mau.render(template, context) do
      {:ok, output} -> output
      {:error, _} -> fallback
    end
  end

  def render_with_default(template, context, default_value) do
    case Mau.render(template, context, preserve_types: true) do
      {:ok, value} -> value
      {:error, _} -> default_value
    end
  end
end

# Usage
output = MyApp.SafeRenderer.render_or_fallback(
  template,
  context,
  "Unable to render content"
)

price = MyApp.SafeRenderer.render_with_default(
  "{{ product.price }}",
  context,
  0.00
)

Partial Failure Handling

Continue processing when some templates fail:

defmodule MyApp.BatchRenderer do
  def render_all(templates, context) do
    Enum.map(templates, fn {name, template} ->
      case Mau.render(template, context) do
        {:ok, output} ->
          {:ok, name, output}

        {:error, error} ->
          {:error, name, error}
      end
    end)
  end

  def render_all_safe(templates, context) do
    # Only return successful renders
    templates
    |> render_all(context)
    |> Enum.filter(&match?({:ok, _, _}, &1))
    |> Enum.map(fn {:ok, name, output} -> {name, output} end)
  end
end

# Usage
results = MyApp.BatchRenderer.render_all(templates, context)

Enum.each(results, fn
  {:ok, name, output} -> IO.puts("#{name}: #{output}")
  {:error, name, error} -> IO.puts("#{name}: ERROR - #{error}")
end)

Validation Before Rendering

Context Validation

defmodule MyApp.ContextValidator do
  def validate_context(context, required_keys) do
    missing = Enum.filter(required_keys, &(!Map.has_key?(context, &1)))

    if Enum.empty?(missing) do
      :ok
    else
      {:error, "Missing context keys: #{Enum.join(missing, ", ")}"}
    end
  end

  def safe_render(template, context, required_keys) do
    case validate_context(context, required_keys) do
      :ok ->
        Mau.render(template, context)

      {:error, reason} ->
        {:error, reason}
    end
  end
end

# Usage
required = ["user_name", "order_id", "total"]
case MyApp.ContextValidator.safe_render(template, context, required) do
  {:ok, output} -> send_email(output)
  {:error, error} -> log_error(error)
end

Template Validation

defmodule MyApp.TemplateValidator do
  def validate_template(template_string) do
    case Mau.compile(template_string) do
      {:ok, _ast} ->
        :ok

      {:error, reason} ->
        {:error, "Template compilation failed: #{reason}"}
    end
  end

  def validate_and_store(name, template_string) do
    case validate_template(template_string) do
      :ok ->
        {:ok, ast} = Mau.compile(template_string)
        store_template(name, ast)
        {:ok, name}

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp store_template(name, ast) do
    templates = Application.get_env(:my_app, :compiled_templates, %{})
    Application.put_env(:my_app, :compiled_templates, Map.put(templates, name, ast))
  end
end

Error Logging and Monitoring

Structured Logging

defmodule MyApp.TemplateLogger do
  require Logger

  def log_render_error(template_name, context_keys, error) do
    Logger.error("Template rendering error",
      template_name: template_name,
      context_keys: context_keys,
      error: error,
      timestamp: DateTime.utc_now()
    )
  end

  def log_filter_error(filter_name, input_type, error) do
    Logger.error("Filter execution error",
      filter: filter_name,
      input_type: input_type,
      error: error
    )
  end
end

# Usage
case Mau.render(template, context) do
  {:ok, output} -> output
  {:error, error} ->
    MyApp.TemplateLogger.log_render_error(
      template_name,
      Map.keys(context),
      error
    )
    render_fallback()
end

Error Metrics

defmodule MyApp.ErrorMetrics do
  def track_render_error(error_type, metadata \\ %{}) do
    :telemetry.execute(
      [:mau, :render, :error],
      %{count: 1},
      Map.merge(metadata, %{error_type: error_type})
    )
  end

  def track_filter_error(filter_name) do
    :telemetry.execute(
      [:mau, :filter, :error],
      %{count: 1},
      %{filter: filter_name}
    )
  end
end

# Attach handlers in application startup
:telemetry.attach_many(
  "mau_errors",
  [
    [:mau, :render, :error],
    [:mau, :filter, :error]
  ],
  &MyApp.ErrorMetrics.handle_event/4,
  []
)

Custom Error Handlers

Error Handler Pipeline

defmodule MyApp.ErrorHandler do
  @type error_result :: {:ok, any} | {:error, String.t()}

  def render_with_handlers(template, context, handlers \\ []) do
    case Mau.render(template, context) do
      {:ok, output} -> {:ok, output}
      {:error, error} -> handle_error(error, handlers)
    end
  end

  defp handle_error(error, handlers) do
    Enum.reduce_while(handlers, {:error, error}, fn handler, acc ->
      case handler.(acc) do
        {:ok, recovered} -> {:halt, {:ok, recovered}}
        :skip -> {:cont, acc}
        {:error, new_error} -> {:cont, {:error, new_error}}
      end
    end)
  end
end

# Usage with multiple recovery strategies
handlers = [
  fn {:error, error} ->
    if String.contains?(error, "undefined") do
      {:ok, "Default content"}
    else
      :skip
    end
  end,
  fn {:error, _error} ->
    {:ok, "Fallback content"}
  end
]

MyApp.ErrorHandler.render_with_handlers(template, context, handlers)

Custom Error Messages

defmodule MyApp.UserFriendlyErrors do
  def render_safe(template, context) do
    case Mau.render(template, context) do
      {:ok, output} ->
        {:ok, output}

      {:error, error} ->
        user_message = translate_error(error)
        {:error, user_message}
    end
  end

  defp translate_error(error) do
    cond do
      String.contains?(error, "undefined") ->
        "Some data was missing. Please try again."

      String.contains?(error, "filter") ->
        "A formatting error occurred. Please contact support."

      String.contains?(error, "syntax") ->
        "The template is misconfigured. Please contact support."

      true ->
        "An unexpected error occurred. Please try again."
    end
  end
end

Testing Error Scenarios

Unit Tests for Error Handling

defmodule MyApp.ErrorHandlingTest do
  use ExUnit.Case

  test "handles undefined variables gracefully" do
    template = "Hello {{ name }}"
    context = %{}
    assert {:ok, "Hello "} = Mau.render(template, context)
  end

  test "handles filter type errors" do
    template = "{{ 'text' | abs }}"
    context = %{}
    assert {:error, _} = Mau.render(template, context)
  end

  test "validates context before rendering" do
    context = %{"name" => "Alice"}
    required = ["name", "email"]

    assert {:error, "Missing context keys: email"} =
             MyApp.ContextValidator.safe_render(template, context, required)
  end

  test "recovers from template rendering errors" do
    template = "{{ undefined }}"
    context = %{}

    assert {:ok, "Fallback"} =
             MyApp.SafeRenderer.render_or_fallback(template, context, "Fallback")
  end
end

Property-Based Testing

defmodule MyApp.ErrorHandlingProperties do
  use ExUnit.Case
  use PropCheck

  property "rendering with any context never crashes" do
    forall template <- string(:printable) do
      context = %{"key" => "value"}

      case Mau.render(template, context) do
        {:ok, _output} -> true
        {:error, _} -> true
      end
    end
  end

  property "filters always return valid results or errors" do
    forall {filter, args} <- filter_and_args() do
      case Mau.FilterRegistry.apply(filter, "test", args) do
        {:ok, _result} -> true
        {:error, _} -> true
      end
    end
  end
end

Production Best Practices

Error Rate Monitoring

defmodule MyApp.ErrorMonitoring do
  def start_monitoring do
    :telemetry.attach(
      "mau_error_counter",
      [:mau, :render, :error],
      &count_error/4,
      %{errors: 0}
    )
  end

  def count_error(_event_name, measurements, metadata, config) do
    total = (config.errors || 0) + measurements.count
    if rem(total, 100) == 0 do
      Logger.warn("Reached #{total} template rendering errors")
    end
  end
end

Alerting on Error Spikes

defmodule MyApp.ErrorAlerting do
  def check_error_rate do
    recent_errors = get_recent_errors(last_n_minutes: 5)
    error_rate = length(recent_errors) / total_renders()

    if error_rate > 0.05 do  # More than 5% errors
      send_alert("High template error rate: #{error_rate * 100}%")
    end
  end

  defp send_alert(message) do
    Logger.error("ALERT: #{message}")
    # Send to monitoring/alerting service
  end
end

See Also