go /

Building view-trees: Error Handling [Part 3]

.md | permalink | Published on December 05, 2023

Previously: intro, and the basics.


We have a bunch of assumptions about what things can fail and which ones cannot.

Right now, any error will bubble all of the way out of the render pipeline, and template rendering will fail. If a slot render fails, the same thing will happen. Any template parsing can/will bubble up as well, any data fetching, anything that happens during the Renderable call.

There are a few ways we can build and execute Render. The views either populate data top down, or they fetch data lazilly, they can do validation, they can not, each of these can fail.

Handling errors, err != nil

Something that can eventually be rendered, something that is AsRenderable should also be able to handle its own error failure, or handle a failure lower down in the tree.

Handling errors will introduce another refactor to our renderer, but will keep our UX/API small and opt-in.

Marker interfaces (duck typing yo)

type ErrorRenderable interface {
    ErrorRenderable(err error) (AsRenderable, error)
}

And our logic to handle the error is pretty simple:

  • Check to see if we actually have an error handler
    • If we don't we bubble up the error.
  • Check to see if the erro handler wants to handle the error itself.
    • Returning an error means we want to buble it up.
    • Returning nil for the AsRenderable means we don't care about this error at all. OK to move on.
    • Do we have something to render?
      • Try to do so!

Note: It is definitely the case here that if our error handler fails to render and it is ErrorRenderable as well we'll keep trying.

func handleRenderError(err error, with any) (template.HTML, error) {
    var empty template.HTML

    if with == nil {
        return empty, err
    }

    errRenderable, ok := with.(ErrorRenderable)
    if !ok {
        return empty, err
    }

    r, err := errRenderable.ErrorRenderable(err)
    if err != nil {
        return empty, err
    }

    if r == nil {
        return empty, nil
    }

    return Render(r)
}
with handleRenderError and ErrorRenderable (source: 400a872b)
diff --git a/error_renderable.go b/error_renderable.go
new file mode 100644
index 0000000..63159e8
--- /dev/null
+++ b/error_renderable.go
@@ -0,0 +1,39 @@
+package veun
+
+import "html/template"
+
+type ErrorRenderable interface {
+	// ErrorRenderable can return bubble the error
+	// back up, which will continue to fail the render
+	// the same as it did before.
+	//
+	// It can also return nil for Renderable,
+	// which will ignore the error entirely.
+	//
+	// Otherwise we will attempt to render next one.
+	ErrorRenderable(err error) (AsRenderable, error)
+}
+
+func handleRenderError(err error, with any) (template.HTML, error) {
+	var empty template.HTML
+
+	if with == nil {
+		return empty, err
+	}
+
+	errRenderable, ok := with.(ErrorRenderable)
+	if !ok {
+		return empty, err
+	}
+
+	r, err := errRenderable.ErrorRenderable(err)
+	if err != nil {
+		return empty, err
+	}
+
+	if r == nil {
+		return empty, nil
+	}
+
+	return Render(r)
+}
diff --git a/render_container_error_test.go b/render_container_error_test.go
new file mode 100644
index 0000000..8052a8d
--- /dev/null
+++ b/render_container_error_test.go
@@ -0,0 +1,96 @@
+package veun_test
+
+import (
+	"errors"
+	"fmt"
+	"html/template"
+	"testing"
+
+	"github.com/alecthomas/assert/v2"
+
+	. "github.com/stanistan/veun"
+)
+
+type FailingView struct {
+	Err error
+}
+
+func (v FailingView) Renderable() (Renderable, error) {
+	return nil, fmt.Errorf("FailingView.Renderable(): %w", v.Err)
+}
+
+type FallibleView struct {
+	CapturesErr error
+	Child       AsRenderable
+}
+
+func (v FallibleView) Renderable() (Renderable, error) {
+	return v.Child.Renderable()
+}
+
+func (v FallibleView) ErrorRenderable(err error) (AsRenderable, error) {
+	if v.CapturesErr == nil {
+		return nil, err
+	}
+
+	if errors.Is(err, v.CapturesErr) {
+		return ChildView1{}, nil
+	}
+
+	return nil, nil
+}
+
+func TestRenderContainerWithFailingView(t *testing.T) {
+	_, err := Render(ContainerView2{
+		Heading: ChildView1{},
+		Body: FailingView{
+			Err: fmt.Errorf("construction: %w", errSomethingFailed),
+		},
+	})
+	assert.IsError(t, err, errSomethingFailed)
+}
+
+func TestRenderContainerWithCapturedError(t *testing.T) {
+	t.Run("errors_bubble_out", func(t *testing.T) {
+		_, err := Render(ContainerView2{
+			Heading: ChildView1{},
+			Body: FallibleView{
+				Child: FailingView{Err: errSomethingFailed},
+			},
+		})
+		assert.IsError(t, err, errSomethingFailed)
+	})
+
+	t.Run("errors_can_push_replacement_views", func(t *testing.T) {
+		html, err := Render(ContainerView2{
+			Heading: ChildView1{},
+			Body: FallibleView{
+				Child:       FailingView{Err: errSomethingFailed},
+				CapturesErr: errSomethingFailed,
+			},
+		})
+		assert.NoError(t, err)
+		assert.Equal(t, template.HTML(`<div>
+	<div class="heading">HEADING</div>
+	<div class="body">HEADING</div>
+</div>`), html)
+	})
+
+	t.Run("errors_can_return_nil_views", func(t *testing.T) {
+		html, err := Render(ContainerView2{
+			Heading: ChildView1{},
+			Body: FallibleView{
+				Child:       FailingView{Err: errors.New("hi")},
+				CapturesErr: errSomethingFailed,
+			},
+		})
+		assert.NoError(t, err)
+		assert.Equal(t, template.HTML(`<div>
+	<div class="heading">HEADING</div>
+	<div class="body"></div>
+</div>`), html)
+	})
+
+}
+
+var errSomethingFailed = errors.New("an error")
diff --git a/renderer.go b/renderer.go
index cb2c5f0..27d3abc 100644
--- a/renderer.go
+++ b/renderer.go
@@ -16,12 +16,17 @@ type AsRenderable interface {
 }
 
 func Render(r AsRenderable) (template.HTML, error) {
-	rr, err := r.Renderable()
+	renderable, err := r.Renderable()
 	if err != nil {
-		return template.HTML(""), err
+		return handleRenderError(err, r)
 	}
 
-	return render(rr)
+	out, err := render(renderable)
+	if err != nil {
+		return handleRenderError(err, r)
+	}
+
+	return out, nil
 }
 
 func render(r Renderable) (template.HTML, error) {

Fallible Views!

With this in hand, and a quick change to the Render function to call this instead of returning an error, we get some neat behavior, and an example of composition based delegation.

type FallibleView struct {
    Contents     AsRenderable
    ErrorHandler func(err error) (AsRenderable, error)
}

func (v FallibleView) Renderable() (Renderable, error) {
    return v.Contents.Renderable()
}

func (v FallibleView) ErrorRenderable(err error) (AsRenderable, error) {
    return v.ErrorHandler(err)
}

This starts to show the kind of thing we can do with composition and this libary.

func logWarningErrorHandler(err error) (AsRenderable, error) {
    log.Printf("something failed, but it's ok: %s", err)
    return nil, nil // we don't care for this example
}

html, err := Render(FallibleView{
    Contents:     someViewThatMightFailToRender,
    ErrorHandler: logWarningErrorHandler,
})

Or more realistically a situation where you're not sure what you are rendering in some slot.

func (v Container) Renderable() (Renderable, error) {
    return View{
        // ... snip ...
        Slots: Slots{
            "extra_content": FallibleView{
                Contents:     v.ContentFactory(),
                ErrorHandler: logWarningErrorHandler,
            },
        },
    }, nil
}

You can see this in action in the tests in the patch above where we use errors.Is(err, somethingKnown) to do error bubbling or handling.

Error handling is going to be much more important when we get to doing real things like data access.


Next: