Hue.Error exception (Hue v0.2.0)

Copy Markdown View Source

A normalised failure from a Hue bridge.

Three different wire formats end up here, and a client that assumes one of them breaks on the others:

  • CLIP v2 puts the signal in the HTTP status and the detail in {"errors":[{"description": …}]}. There are no numeric error codes — reasons are derived from status, never from the prose.
  • CLIP v2 auth failure returns HTTP 403 with a text/html body. Decoding that as JSON crashes on the single most likely first-run failure.
  • v1 pairing returns HTTP 200 carrying [{"error":{"type":101, …}}] — application errors at 200, with numeric types.

Match on :reason. It is stable across all three.

Reasons owned by layer 2

Most reasons above come from the bridge or the transport. Two are raised locally by Hue.Bridge, layer 2's live model, and never by anything in this module:

  • :not_started — no Hue.Bridge is running under the name you addressed. You asked a live model that does not exist; add its child_spec/1 to your supervision tree.
  • :not_synced — the bridge is running but has not completed its first full fetch, so the cache cannot answer yet. Transient; Hue.Bridge.status/1 reports :connecting or :syncing while it lasts.

What this struct does and does not mean

An Error means something outside the process refused the call — the bridge or the transport — or that a locally-known capability makes the call impossible. It is never used for an argument that could not have been valid: brightness: "loud" is a caller bug and raises.

The reason worth handling explicitly is :unauthorized. It means no valid application key was sent. Get one with Hue.Pairing.pair/2 after pressing the round link button on the bridge.

Summary

Functions

Builds an error from the v1 pairing envelope, which arrives at HTTP 200.

Builds an error from a CLIP v2 response.

Builds an error from whatever Req handed back in an {:error, _}.

Builds an error for a failure below the application layer.

Types

reason()

@type reason() ::
  :unauthorized
  | :link_button_not_pressed
  | :not_found
  | :rate_limited
  | :bridge_busy
  | :unsupported_bridge
  | :certificate_changed
  | :bridge_identity_mismatch
  | :unexpected_verification_event
  | :not_dimmable
  | :not_color_capable
  | :invalid_gamut
  | :no_grouped_light
  | :not_started
  | :not_synced
  | :timeout
  | :econnrefused
  | :closed
  | :nxdomain
  | :unexpected_response
  | :unknown

t()

@type t() :: %Hue.Error{
  __exception__: true,
  description: String.t() | nil,
  reason: reason(),
  rid: String.t() | nil,
  status: pos_integer() | nil,
  type: integer() | nil
}

Functions

from_pairing(error)

@spec from_pairing(list() | map()) :: t()

Builds an error from the v1 pairing envelope, which arrives at HTTP 200.

Requires the body to be a list whose first element is %{"error" => %{"type" => integer}} — not merely a list with an "error" key present. An "error" value with no "type", a non-map "error" value, an empty list, or a body that is not a list at all all raise FunctionClauseError. Checking for the presence of an "error" key is necessary but not sufficient before calling this; callers must confirm the full shape, not just route on "error" versus "success".

from_response(status, body, content_type, opts \\ [])

@spec from_response(pos_integer(), binary(), String.t() | nil, keyword()) :: t()

Builds an error from a CLIP v2 response.

content_type matters: the bridge answers an unauthenticated request with an HTML page, so the body is only parsed when it claims to be JSON.

from_transport(exception)

@spec from_transport(Exception.t() | %{reason: term()}) :: t()

Builds an error from whatever Req handed back in an {:error, _}.

Every request path in this library goes through here, because the reason Req carries is not always an atom, and the one case where it is not is the most security-relevant failure the library can report.

Why the pin needs unwrapping

When Hue.Transport.verify_pinned/4 refuses a certificate it returns {:fail, :certificate_changed}. :ssl turns that into a fatal alert, and Mint surfaces it as %Mint.TransportError{reason: {:tls_alert, {:handshake_failure, text}}} — a tuple. Handed to transport/2 that raises FunctionClauseError; guarded away with is_atom it degrades to :unknown. Either way the one failure Hue.Transport documents as "your bridge was replaced or you are being intercepted" never reaches a caller in a form they can match on.

OTP appends the refused term to the alert text on a line of its own, so the original reason is recoverable, and the two terms this library's verify_fun can fail with are mapped back to themselves.

Why the match is narrow

Reporting a benign failure as an interception is the same class of mistake as reporting a differently-formatted fingerprint as one. So the trailing line must equal one of those terms rather than merely contain it, and every other alert stays :unknown with its text intact. A server-sent protocol_version or insufficient_security, and a handshake_failure carrying any other term — hostname_check_failed and cert_expired both arrive through this same wrapping — are negotiation or certificate problems, not evidence that anything was swapped.

Why it tolerates two spellings of that line

ssl_alert.erl carries two adjacent formatters for the same alert: own_alert_format_depth/1 renders " ~s\n ~P", putting the refused term bare on the last line, while own_alert_format/1 ten lines above renders " ~s\n - ~p", which inspects the atom and so prints - :certificate_changed. Which one produced the text handed to this function is not ours to choose, and a parser coupled to one of them degrades the other to :unknown. So a leading - and a leading : are stripped, and trailing blank lines are skipped, before an equality comparison that is otherwise unchanged.

transport(reason, opts \\ [])

@spec transport(
  atom(),
  keyword()
) :: t()

Builds an error for a failure below the application layer.