go-unifi

Error Handling

React to UniFi controller failures with the ErrNotFound, Official-API, and old-style sentinels, the rich ServerError, and client-side ValidationError.

go-unifi returns ordinary Go errors. What makes them useful is that the interesting failures are matchable: sentinel values you compare with errors.Is, and structured types you unwrap with errors.As. This page covers every error worth branching on.

The sentinels

These package-level values let you react to a specific condition with errors.Is:

SentinelMeaning
unifi.ErrNotFoundThe resource doesn't exist — a real HTTP 404, or a single-resource Get that returned empty data. List methods return an empty slice and nil error, not ErrNotFound.
unifi.ErrOldStyleUnsupportedConstruction-time: the target is an old-style (classic) controller. API-key auth needs UniFi Network 9.0.114+.
unifi.ErrOfficialAPIUnavailableThe c.Official() surface can't be used here — a classic controller, a failed GET /v1/info probe (a rejected API key surfaces here), or a controller below 10.1.78.
unifi.ErrOfficialAPIDisabledThe Official API was explicitly turned off via ClientConfig.DisableOfficialAPI.

Not found

The most common branch. ErrNotFound covers both a genuine 404 and the "empty result" case uniformly, so one check handles both:

func getOne(ctx context.Context, c unifi.Client) {
	net, err := c.GetNetwork(ctx, "default", "does-not-exist")
	if errors.Is(err, unifi.ErrNotFound) {
		log.Println("no such network")
		return
	}
	if err != nil {
		log.Fatal(err)
	}
	_ = net
}

ServerError: the full HTTP story

Every non-2xx response from the controller is decoded into a *unifi.ServerError. Reach it with errors.As:

func inspect(ctx context.Context, c unifi.Client) {
	_, err := c.CreateNetwork(ctx, "default", &unifi.Network{})
	if err == nil {
		return
	}

	var se *unifi.ServerError
	if errors.As(err, &se) {
		fmt.Printf("status %d on %s %s\n", se.StatusCode, se.RequestMethod, se.RequestURL)
		fmt.Printf("controller code=%q message=%q\n", se.ErrorCode, se.Message)
		for _, d := range se.Details {
			if d.ValidationError.Field != "" {
				fmt.Printf("  field %q must match %q\n", d.ValidationError.Field, d.ValidationError.Pattern)
			}
		}
		if se.StatusCode == http.StatusUnauthorized {
			log.Fatal("API key rejected")
		}
		return
	}
	// Not a ServerError: a transport, timeout, or context error.
	log.Printf("non-server error: %v", err)
}

ServerError carries the request context (StatusCode, RequestMethod, RequestURL), the controller's own ErrorCode and Message, and a Details slice of per-field ServerErrorDetails (each with a message and a ValidationError field/pattern). Its Error() method formats all of that into a single readable line, so logging the error directly is already informative.

A *ServerError with status 404 also satisfies errors.Is(err, unifi.ErrNotFound) — it implements an Is method that maps a 404 to the sentinel. So you can keep using the simple errors.Is(err, unifi.ErrNotFound) check even when the failure arrives as a structured ServerError. Empty-body responses (common for 401/403) and non-JSON gateway pages still produce a fully-populated ServerError, never a bare decode error.

ValidationError: failing before the request leaves

By default the client validates request structs locally (in soft mode it logs; in hard mode it rejects). A rejected struct surfaces as a *unifi.ValidationError, whose Messages map names each offending field:

func validate(ctx context.Context, c unifi.Client) {
	_, err := c.CreateNetwork(ctx, "default", &unifi.Network{})
	var ve *unifi.ValidationError
	if errors.As(err, &ve) {
		for field, msg := range ve.Messages {
			fmt.Printf("invalid %s: %s\n", field, msg)
		}
	}
}

ValidationError also Unwraps to the underlying go-playground/validator error, so errors.As against the validator's own types works too. See Request validation for the modes.

Official API errors

The Official surface fails in two stages. Before a request is sent, the capability gate returns one of the ErrOfficialAPI* sentinels — match them with errors.Is:

func official(ctx context.Context, c unifi.Client) {
	info, err := c.Official().Info().Get(ctx)
	switch {
	case errors.Is(err, unifi.ErrOfficialAPIDisabled):
		log.Println("Official API turned off via ClientConfig.DisableOfficialAPI")
		return
	case errors.Is(err, unifi.ErrOfficialAPIUnavailable):
		log.Println("controller too old (<10.1.78), classic, or info probe failed")
		return
	case err != nil:
		// Past the gate, a request failure is still a *unifi.ServerError.
		var se *unifi.ServerError
		if errors.As(err, &se) {
			log.Printf("official request failed: %d %s", se.StatusCode, se.Message)
		}
		return
	}
	_ = info
}

Once past the gate, an Official request that fails on the wire returns the same *unifi.ServerError as the Internal API — the Official package borrows the client's transport and error decoding, so you handle both surfaces with one errors.As.

Construction-time errors

NewClient does an eager system-info round-trip (unless SkipSystemInfo: true), so a classic controller or a bad key fails at construction. The classic-controller case is its own sentinel:

func build() {
	_, err := unifi.NewClient(&unifi.ClientConfig{
		URL:    "https://unifi.example.com",
		APIKey: "your-api-key",
	})
	if errors.Is(err, unifi.ErrOldStyleUnsupported) {
		log.Fatal("classic controller — upgrade to UniFi Network 9.0.114+ for API-key auth")
	}
	_ = err
}

Next steps

On this page