feat: better error handling #17

Merged
decentral1se merged 5 commits from feat/errors into main 2026-07-31 19:40:04 +00:00
19 changed files with 343 additions and 139 deletions
+20
View File
@@ -0,0 +1,20 @@
---
kind: pipeline
name: git.coopcloud.tech/decentral1se/iroh-go
steps:
- name: go build
image: golang:1.26
commands:
- go build -v ./...
- name: examples
image: golang:1.26
commands:
- for ex in $(find examples -mindepth 1 -maxdepth 1 -type d -not -path "*-desktop"); do make -C $ex; done
depends_on:
- go build
trigger:
action:
exclude:
- synchronized
+1
View File
@@ -3,6 +3,7 @@ examples/connect-desktop/build
examples/connect-desktop/frontend/dist
examples/connect-desktop/frontend/wailsjs
examples/connect/connect
examples/errors/errors
examples/pairchat/pairchat
examples/timeserve/timeserve
iroh-go/target/
+2
View File
@@ -1,5 +1,7 @@
# `iroh-go`
[![Build Status](https://build.coopcloud.tech/api/badges/decentral1se/iroh-go/status.svg?ref=refs/heads/main)](https://build.coopcloud.tech/decentral1se/iroh-go)
> **WARNING** Highly Experimental ™️
Go FFI bindings for [`iroh`](https://iroh.computer) via
+111
View File
@@ -0,0 +1,111 @@
package iroh_ffi
import (
"errors"
"fmt"
)
// `As` casts an error to IrohError and back, extracting relevant error message
// information. It is a single argument variant of the typical errors.As API
// because we always expect an IrohError back from the iroh FFI bindings. The
// bindings do not return an IrohError for reasons and will not for the
// forseeable future due to API stability guarantees.
func As(err error) error {
if err, ok := errors.AsType[IrohError](err); ok {
return fmt.Errorf("%s: %s", err.Message(), err.DebugMessage())
}
return err
}
// `Is` supports matching against the stable iroh error taxonomy using the
// error sentinel construction below. The caller must pass in a valid error
// sentinel specified by this library, e.g. iroh.BindError.
func (e *IrohError) Is(target error) bool {
if err, ok := errors.AsType[sentinel](target); ok {
return uint(e.Kind()) == err.kind
}
return false
}
type sentinel struct {
kind uint
message string
}
func (s sentinel) Error() string {
return s.message
}
func (s sentinel) String() string {
return s.message
}
var InvalidInputError error = sentinel{
kind: uint(IrohErrorKindInvalidInput),
message: "iroh: invalid input supplied by the caller",
}
var BindError error = sentinel{
kind: uint(IrohErrorKindBind),
message: "iroh: failure while binding an endpoint",
}
var ConnectError error = sentinel{
kind: uint(IrohErrorKindConnect),
message: "iroh: failure while initiating or completing an outgoing connection",
}
var ConnectionError error = sentinel{
kind: uint(IrohErrorKindConnection),
message: "iroh: an established connection failed or closed unexpectedly",
}
var AlpnError error = sentinel{
kind: uint(IrohErrorKindAlpn),
message: "iroh: ALPN negotiation or lookup failed",
}
var KeyParsingError error = sentinel{
kind: uint(IrohErrorKindKeyParsing),
message: "iroh: endpoint id / public key parsing failed",
}
var TicketParsingError error = sentinel{
kind: uint(IrohErrorKindKeyParsing),
message: "iroh: ticket parsing failed",
}
var RelayError error = sentinel{
kind: uint(IrohErrorKindRelay),
message: "iroh: relay configuration or relay operation failed",
}
var StreamError error = sentinel{
kind: uint(IrohErrorKindStream),
message: "iroh: stream read/write/control operation failed",
}
var DatagramError error = sentinel{
kind: uint(IrohErrorKindDatagram),
message: "iroh: datagram send/receive operation failed",
}
var FCallbackError error = sentinel{
kind: uint(IrohErrorKindCallback),
message: "iroh: foreign callback failed",
}
var ClosedError error = sentinel{
kind: uint(IrohErrorKindClosed),
message: "iroh: operation was attempted on a closed stream/connection/resource",
}
var TimeoutError error = sentinel{
kind: uint(IrohErrorKindTimeout),
message: "iroh: operation timed out",
}
var InternalError error = sentinel{
kind: uint(IrohErrorKindInternal),
message: "iroh: unclassified internal error",
}
+15 -16
View File
@@ -1,6 +1,10 @@
# connect-desktop
A version of [`../connect`](../connect) as a desktop application. On running
> **WARNING** Due to Wails requiring some additional system dependencies, we
> are not testing this example regularly on CI/CD. Therefore, this example
> might not build when you try it. Contributions are very welcome!
A version of [`../connect`](../connect) as a desktop application. When running
the desktop application, an Iroh endpoint id will be displayed. This id can be
plugged into a terminal running the [`../connect`](../connect) example as a
sender. What the sender types in the terminal will appear in the
@@ -8,28 +12,23 @@ connect-desktop application window.
## Dependencies
* Wails v2.10.2 (old version, haven't tried on a newer yet - hopefully works there too!)
* Vite v3.0.7
* Go v1.18
* npm v10.2+
* node v20.11+
This example has been tested on the following versions.
**Getting setup**
* Wails v2.10.2
* Node v20.11+
* Install node and golang
Please consult the [Wails
docs](https://wails.io/docs/gettingstarted/installation) for everything you
need to get up and running.
Install Wails:
### Wails
```
go install github.com/wailsapp/wails/v2/cmd/wails@v2.10.2 // for tested version
// OR: go install github.com/wailsapp/wails/v2/cmd/wails@latest // for latest
// then install frontend dependency
cd frontend
npm i
go install github.com/wailsapp/wails/v2/cmd/wails@v2.10.2
cd frontend && npm i
```
## Running in development mode
## Development mode
```
wails dev
+20 -21
View File
@@ -2,11 +2,11 @@ package main
import (
"context"
"errors"
"fmt"
"strings"
iroh "git.coopcloud.tech/decentral1se/iroh-go"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
var (
@@ -15,16 +15,9 @@ var (
endpointPeer *string
)
func check(err error) {
if err != nil {
irohErr := err.(*iroh.IrohError)
panic(irohErr.Message())
}
}
// App struct
type App struct {
ctx context.Context
ctx context.Context
endpoint *iroh.Endpoint
}
@@ -45,7 +38,9 @@ func (a *App) startup(ctx context.Context) {
}
endpoint, err := iroh.EndpointBind(opts)
check(err)
if err := iroh.As(err); err != nil {
panic(err)
}
endpoint.Online()
a.endpoint = endpoint
@@ -63,28 +58,32 @@ func listen(ctx context.Context, e *iroh.Endpoint) {
}
}
func handleIncoming (ctx context.Context, incoming *iroh.Incoming) {
func handleIncoming(ctx context.Context, incoming *iroh.Incoming) {
accepting, err := incoming.Accept()
check(err)
if err := iroh.As(err); err != nil {
panic(err)
}
conn, err := accepting.Connect()
check(err)
if err := iroh.As(err); err != nil {
panic(err)
}
stream, err := conn.AcceptBi()
check(err)
if err := iroh.As(err); err != nil {
panic(err)
}
recv := stream.Recv()
for {
frame, err := recv.Read(frameSize)
// handle timeout
if err != nil {
irohErr := err.(*iroh.IrohError)
errMsg := irohErr.Message()
if strings.HasPrefix(errMsg, "ConnectionLost") {
if errors.Is(err, iroh.TimeoutError) {
fmt.Println("lost connection")
break
} else {
check(err)
}
if err := iroh.As(err); err != nil {
panic(err)
}
}
runtime.EventsEmit(ctx, "data", map[string]interface{}{
+3 -5
View File
@@ -16,7 +16,7 @@ func main() {
app := NewApp()
// Create application with options
err := wails.Run(&options.App{
if err := wails.Run(&options.App{
Title: "go-iroh-desktop-example",
Width: 1024,
Height: 768,
@@ -28,9 +28,7 @@ func main() {
Bind: []interface{}{
app,
},
})
if err != nil {
println("Error:", err.Error())
}); err != nil {
panic(err)
}
}
+31 -23
View File
@@ -2,11 +2,11 @@ package main
import (
"bufio"
"errors"
"flag"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
iroh "git.coopcloud.tech/decentral1se/iroh-go"
@@ -18,13 +18,6 @@ var (
endpointPeer *string
)
func check(err error) {
if err != nil {
irohErr := err.(*iroh.IrohError)
panic(irohErr.Message())
}
}
func awaitInterrupt() {
done := make(chan os.Signal, 1)
signal.Notify(done, syscall.SIGINT, syscall.SIGTERM)
@@ -39,22 +32,30 @@ func parseFlags() {
func send(e *iroh.Endpoint, id string) {
remote, err := iroh.EndpointIdFromString(id)
check(err)
if err := iroh.As(err); err != nil {
panic(err)
}
addr := iroh.NewEndpointAddr(remote, nil, nil)
conn, err := e.Connect(addr, alpn)
check(err)
if err := iroh.As(err); err != nil {
panic(err)
}
stream, err := conn.OpenBi()
check(err)
if err := iroh.As(err); err != nil {
panic(err)
}
fmt.Println("connected")
fmt.Println("connection established")
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
if line := scanner.Text(); line != "" {
err = stream.Send().WriteAll([]byte(line))
check(err)
if err := iroh.As(err); err != nil {
panic(err)
}
}
}
@@ -72,26 +73,30 @@ func listen(e *iroh.Endpoint) {
func handleIncoming(incoming *iroh.Incoming) {
accepting, err := incoming.Accept()
check(err)
if err := iroh.As(err); err != nil {
panic(err)
}
conn, err := accepting.Connect()
check(err)
if err := iroh.As(err); err != nil {
panic(err)
}
stream, err := conn.AcceptBi()
check(err)
if err := iroh.As(err); err != nil {
panic(err)
}
recv := stream.Recv()
for {
frame, err := recv.Read(frameSize)
// handle timeout
if err != nil {
irohErr := err.(*iroh.IrohError)
errMsg := irohErr.Message()
if strings.HasPrefix(errMsg, "ConnectionLost") {
if errors.Is(err, iroh.TimeoutError) {
fmt.Println("lost connection")
break
} else {
check(err)
}
if err := iroh.As(err); err != nil {
panic(err)
}
}
fmt.Println(string(frame))
@@ -108,7 +113,10 @@ func main() {
}
endpoint, err := iroh.EndpointBind(opts)
check(err)
if err := iroh.As(err); err != nil {
panic(err)
}
endpoint.Online()
if *endpointPeer == "" {
+2
View File
@@ -0,0 +1,2 @@
default:
@go build -v ./...
+9
View File
@@ -0,0 +1,9 @@
# errors
This example shows a brief example of how to deal with error handling in a
convenient way. Unfortunately, the error handling coming down the pipes in the
FFI bindings is not ergonomic by default ends up creating extremely verbose
error handling. We've created an error handling wrapper API which we've tried
to make as intuitive as possible. See
[`#263`](https://github.com/n0-computer/iroh-ffi/issues/263) for the juicy
details.
+33
View File
@@ -0,0 +1,33 @@
package main
import (
"errors"
iroh "git.coopcloud.tech/decentral1se/iroh-go"
)
func main() {
preset := iroh.PresetN0DisableRelay()
opts := iroh.EndpointOptions{
Preset: &preset,
Alpns: &[][]byte{[]byte("iroh-go-errors/0")},
}
endpoint, err := iroh.EndpointBind(opts)
// NOTE(d1): The main route: convert errors to IrohError with `iroh.As`. This
// brings the `IrohError.Message`/`DebugMessage` back in a regular error
// value. This ensures we maintain `err != nil` compatibility
if err := iroh.As(err); err != nil {
panic(err)
}
// NOTE(d1): If you need more fine grained error catching: no `iroh.As`
// machinery is required. Sentinel checks with the usual `errors.Is` API are
// supported. It works as expected with `err` values
if err != nil && errors.Is(err, iroh.BindError) {
panic(err)
}
endpoint.Online()
}
+7
View File
@@ -0,0 +1,7 @@
module git.coopcloud.tech/decentral1se/iroh-go/examples/errors
go 1.26.1
require git.coopcloud.tech/decentral1se/iroh-go v0.0.0-20260717110820-68ad06fe2a25
replace git.coopcloud.tech/decentral1se/iroh-go => ../../
View File
+2
View File
@@ -0,0 +1,2 @@
default:
@go build -v ./...
+2
View File
@@ -0,0 +1,2 @@
default:
@go build -v ./...
+32 -27
View File
@@ -2,11 +2,11 @@ package main
import (
"bufio"
"errors"
"flag"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
iroh "git.coopcloud.tech/decentral1se/iroh-go"
@@ -18,13 +18,6 @@ var (
endpointPeer *string
)
func check(err error) {
if err != nil {
irohErr := err.(*iroh.IrohError)
panic(irohErr.Message())
}
}
func awaitInterrupt() {
done := make(chan os.Signal, 1)
signal.Notify(done, syscall.SIGINT, syscall.SIGTERM)
@@ -39,15 +32,21 @@ func parseFlags() {
func send(e *iroh.Endpoint, id string) {
remote, err := iroh.EndpointIdFromString(id)
check(err)
if err := iroh.As(err); err != nil {
panic(err)
}
addr := iroh.NewEndpointAddr(remote, nil, nil)
conn, err := e.Connect(addr, alpn)
check(err)
if err := iroh.As(err); err != nil {
panic(err)
}
stream, err := conn.OpenBi()
check(err)
fmt.Println("connected")
if err := iroh.As(err); err != nil {
panic(err)
}
fmt.Println("connection established")
recv := stream.Recv()
done := make(chan struct{})
go handleInput(stream, done)
@@ -63,13 +62,19 @@ func listen(e *iroh.Endpoint) {
func handleIncoming(incoming *iroh.Incoming) {
accepting, err := incoming.Accept()
check(err)
if err := iroh.As(err); err != nil {
panic(err)
}
conn, err := accepting.Connect()
check(err)
if err := iroh.As(err); err != nil {
panic(err)
}
stream, err := conn.AcceptBi()
check(err)
if err := iroh.As(err); err != nil {
panic(err)
}
recv := stream.Recv()
done := make(chan struct{})
@@ -82,7 +87,9 @@ func handleInput(stream *iroh.BiStream, done chan struct{}) {
for scanner.Scan() {
if line := scanner.Text(); line != "" {
err := stream.Send().WriteAll([]byte(line))
check(err)
if err := iroh.As(err); err != nil {
panic(err)
}
}
select {
@@ -102,16 +109,12 @@ func handleInput(stream *iroh.BiStream, done chan struct{}) {
func handleReading(recv *iroh.RecvStream, done chan struct{}) {
for {
frame, err := recv.Read(frameSize)
// handle timeout
if err != nil {
irohErr := err.(*iroh.IrohError)
errMsg := irohErr.Message()
if strings.HasPrefix(errMsg, "ConnectionLost") {
fmt.Println("lost connection")
break
} else {
check(err)
}
if errors.Is(err, iroh.TimeoutError) {
fmt.Println("lost connection")
break
}
if err := iroh.As(err); err != nil {
panic(err)
}
fmt.Println(string(frame))
}
@@ -128,7 +131,9 @@ func main() {
}
endpoint, err := iroh.EndpointBind(opts)
check(err)
if err := iroh.As(err); err != nil {
panic(err)
}
endpoint.Online()
if *endpointPeer == "" {
+7 -6
View File
@@ -1,9 +1,9 @@
# timeserve
This example demonstrates that an endpoint can support multiple live connections at once. The
endpoint aka listener writes the time, once every 2 seconds, to every connected peer.
Try connecting to a running listener with 3 (or more!) receiver peers at once.
This example demonstrates that an endpoint can support multiple live
connections at once. The endpoint aka listener writes the time, once every 2
seconds, to every connected peer. Try connecting to a running listener with 3
(or more!) receiver peers at once.
## Build a binary
@@ -39,5 +39,6 @@ go build -v -a -ldflags '-extldflags "-static"' ./timeserve.go
./timeserve -endpoint <endpoint-id>
```
You'll see a connection notice if a receiver peer manages to connect to the listener.
Receivers (Terminal 2 and Terminal 3) will display the time sent by the listener (Terminal 1).
You'll see a connection notice if a receiver peer manages to connect to the
listener. Receivers (Terminal 2 and Terminal 3) will display the time sent by
the listener (Terminal 1).
+2
View File
@@ -0,0 +1,2 @@
default:
@go build -v ./...
+44 -41
View File
@@ -1,11 +1,11 @@
package main
import (
"errors"
"flag"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
@@ -18,13 +18,6 @@ var (
endpointPeer *string
)
func check(err error) {
if err != nil {
irohErr := err.(*iroh.IrohError)
panic(irohErr.Message())
}
}
func awaitInterrupt() {
done := make(chan os.Signal, 1)
signal.Notify(done, syscall.SIGINT, syscall.SIGTERM)
@@ -39,51 +32,47 @@ func parseFlags() {
func receiver(e *iroh.Endpoint, id string) {
remote, err := iroh.EndpointIdFromString(id)
check(err)
if err := iroh.As(err); err != nil {
panic(err)
}
addr := iroh.NewEndpointAddr(remote, nil, nil)
conn, err := e.Connect(addr, alpn)
check(err)
if err := iroh.As(err); err != nil {
panic(err)
}
// from docs:
// "The peer that calls open_bi must write to its SendStream before the peer Connection is able to accept the stream
// using accept_bi()"
// from docs: "The peer that calls open_bi must write to its SendStream
// before the peer Connection is able to accept the stream using accept_bi()"
// https://docs.rs/iroh/latest/iroh/endpoint/struct.Connection.html#method.accept_bi
stream, err := conn.OpenBi()
check(err)
// NOTE: for the other peer to realize the bidi stream is open, you have to send some data! the data sent cannot be
// the empty string ""; empty string == no data
if err := iroh.As(err); err != nil {
panic(err)
}
// NOTE: for the other peer to realize the bidi stream is open, you have to
// send some data! the data sent cannot be the empty string ""; empty string
// == no data
err = stream.Send().WriteAll([]byte{1})
if err := iroh.As(err); err != nil {
panic(err)
}
check(err)
handleReading(stream.Recv())
}
func handleReading(recv *iroh.RecvStream) {
for {
frame, err := recv.Read(frameSize)
if checkForTimeout(err) {
if errors.Is(err, iroh.TimeoutError) {
fmt.Println("lost connection")
break
}
fmt.Println(string(frame))
check(err)
}
}
func checkForTimeout(err error) bool {
// handle timeout
if err != nil {
irohErr := err.(*iroh.IrohError)
errMsg := irohErr.Message()
if strings.HasPrefix(errMsg, "ConnectionLost") {
fmt.Println("lost connection")
return true
} else {
// panics and exits bc unhandled err
check(err)
if err := iroh.As(err); err != nil {
panic(err)
}
fmt.Println(string(frame))
}
return false
}
func listen(e *iroh.Endpoint) {
@@ -96,20 +85,32 @@ func listen(e *iroh.Endpoint) {
func handleIncoming(incoming *iroh.Incoming) {
accepting, err := incoming.Accept()
check(err)
if err := iroh.As(err); err != nil {
panic(err)
}
conn, err := accepting.Connect()
check(err)
if err := iroh.As(err); err != nil {
panic(err)
}
stream, err := conn.AcceptBi()
check(err)
if err := iroh.As(err); err != nil {
panic(err)
}
for {
err := stream.Send().WriteAll([]byte(time.Now().Format(time.DateTime)))
if checkForTimeout(err) {
if errors.Is(err, iroh.TimeoutError) {
fmt.Println("lost connection")
break
}
// prints every two seconds (not for any technical reason, 1/sec just feels hectic!)
if err := iroh.As(err); err != nil {
panic(err)
}
// prints every two seconds (not for any technical reason, 1/sec just feels
// hectic!)
<-time.After(2 * time.Second)
}
}
@@ -124,7 +125,9 @@ func main() {
}
endpoint, err := iroh.EndpointBind(opts)
check(err)
if err := iroh.As(err); err != nil {
panic(err)
}
endpoint.Online()
if *endpointPeer == "" {