Error Handling

A page function has three ways a failure reaches the user:

func Page(p *tgframe.Params) error {
    // 1. return it
    if err := doSomething(); err != nil {
        return err
    }

    // 2. a component records it and carries on
    tgcomp.Table(p.Main, head, table) // rows that don't match the head

    // 3. panic
    tgcomp.Column(p.Main, 0) // no such thing as zero columns

    return nil
}

All three end up in the same red message box under the page, and none of them takes the server down. What differs is how much of the page survives.

Which one to use

return err is for a failure of the job the page is doing: a file that does not parse, a request that timed out, a form value the app rejects. The page function decides when the run is over and hands the reason back. Nothing after the return renders.

Container.Fail is for a failure the run's data decides — the same call would be right on other data. A table whose rows do not match its head, an image that will not encode, a date in the state that no longer parses. The run records the error and keeps going: the component leaves a visible error placeholder where it would have been, everything after it still renders, and App.Run returns the failure once the page function is done.

// Table create a table by heading(head) and values(table).
func Table(c *tgframe.Container, head []string, table [][]string, conf ...*TableConf) {
    // ...
    if len(table[0]) != len(head) {
        c.Fail(tgutil.NewError("len of head should equal to len of table[0]"))
        return
    }
    // ...
}

Fail is public, so a third-party component reports a bad value the same way the built-in ones do. A nil error is nothing to report and does nothing.

panic(err) is for a misuse of the API: a call that no data could make right. Column(c, 0) asks for zero columns, OneConf was handed two confs, Echo cannot see its caller. These are programming errors the caller cannot recover from, so the components panic instead of returning an error and keep their signature small:

// Column create N columns.
func Column(c *tgframe.Container, n uint, conf ...*ColumnConf) []*tgframe.Container {
    if n == 0 {
        panic("number of columns should > 0")
    }
    ...
}

The same rule applies to App.AddPage and App.AddPageByConfig: they panic on a bad page config, because an app that cannot register its own pages has nothing to run.

Application code is free to panic too — it just gets reported as a run error rather than crashing the process.

Where each component sits

The split is between what the call says and what the data says. A wrong argument type or an out-of-range constant is the call; a length that only this run's rows have is the data.

Reports through FailWhy
Table, head and row lengthsthe rows are data
DataFrame, head, row, column conf and page size checkssame
Chart and friends, series against labels and points against kindsame
JSON, a value that will not marshal or a string that is not JSONsame
Image, a PNG or JPEG that will not encodesame
DatePicker, TimePicker, DateTimePicker, a stored value that will not parsethe state is data, and the browser or an old session may have written it
FileUpload, a stored pick that will not unmarshalsame
Still panicsWhy
Column(c, 0), EqColumn with an unsupported countno data makes zero columns valid
OneConf handed two confsthe call passed two, not the data
Echo that cannot find its callerthe code is not shaped the way Echo needs
Slider, SelectSlider, ColorPicker bad bounds and defaultsthe conf is the call
Status with more than one closing labelthe call again
Image with an unsupported format constant or an unsupported argument typethe call again
ChartKind, ColumnType, ColumnAlign String() on an unknown constantthe call again
App.AddPage on a bad page configan app that cannot register a page has nothing to run

session.go panics with ErrUpdateInterrupt for neither reason: it is control flow, the only way to unwind a run the next event has already made stale, from wherever in the page function it happens to be. See Sentinel errors below.

What happens to a returned error

App.Run calls the page function and wraps whatever comes back:

err := pageFunc(&Params{...})
if err != nil {
    return tgutil.Errorf("%w", err)
}

A page function that returns nothing still fails the run when a component did: App.Run returns what Fail recorded. The first failure of a run is the one kept — a later one does not overwrite it — but every one of them leaves its own placeholder, so the page shows all of them even though the caller reads one. An error the page function returns itself wins over both: the page said the run was over, and that reason is the one that comes back.

tgutil.Errorf and tgutil.NewError prefix the message with the name of the function that created the error, so the log line says where it came from. They wrap with %w, so errors.Is still works on the original error.

The Session turns the error into a result pack:

err := s.app.RunWithHandlingPanic(s.pageName, s.state, sendNotifyPack)
if err != nil {
    s.sendResult(&ResultPack{Error: err.Error()})
    slog.Error("run err", "error", err)
    return
}

s.sendResult(&ResultPack{Success: true})

The client renders ResultPack.Error in AppError, a is-danger message below the page body. Components the run already created stay on screen: the error is appended to a half-drawn page, not a replacement for it. The next run clears it.

What happens to a panic

RunWithHandlingPanic recovers it and turns it into an error wrapping ErrPanic:

defer func() {
    r := recover()
    if r != nil {
        log.Println("Panic", r)
        err = tgutil.Errorf("%w: %v", ErrPanic, r)
    }
}()

From there it follows the path above, so the user sees the same red box, its message being panic: followed by the recovered value. A panic in one run does not affect the session, the state, or the other users of a web executor.

Note that only panics inside the page function are covered. A panic in a goroutine the page function started has no recover on its stack and kills the process, as it does in any Go program.

Sentinel errors

ErrorMeaning
tgframe.ErrPageNotFoundApp.Run or NewSession got a name no page is registered under.
tgframe.ErrPanicThe page function panicked. Wraps the recovered value, keeping its chain when it is an error.
tgframe.ErrUpdateInterruptThe run was cut short by a new event. Not an application error.
tgframe.ErrDuplicatedIDTwo components of one run claimed the same id. Recorded through the same slot as Fail, so the first of the two comes back.

ErrUpdateInterrupt is how an interrupted run unwinds. When an event arrives while a page function is still running, the session cancels that run's context, and the next component the page func creates panics with ErrUpdateInterrupt instead of sending its notify pack:

sendNotifyPack := func(pack NotifyPack) {
    if runCtx.Err() != nil {
        panic(ErrUpdateInterrupt)
    }
    ...
}

A page function that watches Params.Context does not have to wait for that: it returns as soon as the context is done.

Either way the run is cut, and a cut run is silent — no result pack, no run err line. The session tells the two apart by the context, not by the error: a context.Canceled an application produced from a context of its own still reaches the client as an error.

An error a page function panicked with keeps its chain under ErrPanic, so errors.Is(err, ErrUpdateInterrupt) holds for an interrupt.

Outside the page function

The rest of the framework does not panic on the request path; it returns errors and lets the executor decide.

  • Session.HandleRawEvent reports a malformed event to the client and returns the error, so the executor can log it. A closed session ignores events instead of erroring.
  • Session never fails a run because the client is gone. A send that fails is logged and dropped — there is nowhere left to report it to.
  • The web executor answers with an HTTP status on the upload and page handlers, and sends a ResultPack over the socket when a session cannot be created.
  • The desktop (Wails) backend returns the error to the frontend from its bound methods, ErrNoSession among them.

Package-level initialization is the one place the library panics on something that is not a caller mistake: toolguiweb.GetRootAssets panics if the embedded frontend assets cannot be read, since a build without them cannot serve anything.