// Package cloud talks to the Behavision server at mcp.loyaly.ai. // // Everything a store PC sends to head office goes over MQTT; this is the // request/response half — logging in, reading reports, saving the customer // form. A store PC never holds database credentials, so every one of these is // a call the server authorises against the session token. package cloud import ( "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/url" "strings" "sync" "time" ) // ErrUnauthorized means the session is gone. The UI shows the login sheet // again rather than an error dialog - an expired token is an ordinary event, // not a fault. var ErrUnauthorized = errors.New("session expired") type Client struct { Base string http *http.Client mu sync.RWMutex token string refresh string user User // Held across a whole refresh so concurrent screens cannot each spend the // single-use refresh token. refreshMu sync.Mutex onRefresh func(Session) // Camera snapshots already fetched, keyed by camera id. The Cameras screen // polls every 8 seconds and a snapshot is ~90 KB, so re-fetching one that // has not changed would put megabytes an hour on the wire to redraw the // same picture - the same trap the web app's useAuthedImage avoids by // keying on the url rather than the object around it. shotMu sync.Mutex shots map[string]cachedShot } type cachedShot struct { at string // the server's snapshot_at; a new one is a new picture uri string } type User struct { ID string `json:"id"` Email string `json:"email"` FullName string `json:"full_name"` Role string `json:"role"` ClientID string `json:"client_id"` Client string `json:"client_name"` } type Session struct { Token string `json:"access_token"` RefreshToken string `json:"refresh_token"` User User `json:"user"` } func New(base string) *Client { return &Client{ Base: strings.TrimRight(base, "/"), http: &http.Client{Timeout: 30 * time.Second}, } } func (c *Client) SetSession(s Session) { c.mu.Lock() defer c.mu.Unlock() c.token, c.refresh, c.user = s.Token, s.RefreshToken, s.User } func (c *Client) Clear() { c.mu.Lock() defer c.mu.Unlock() c.token, c.refresh, c.user = "", "", User{} } func (c *Client) User() User { c.mu.RLock() defer c.mu.RUnlock() return c.user } func (c *Client) LoggedIn() bool { c.mu.RLock() defer c.mu.RUnlock() return c.token != "" } // do sends a request, refreshing the session once if the access token has // expired. // // The body is marshalled up front and kept, because a retry has to send it // again and an io.Reader is spent after the first attempt - a bug that only // shows up twelve hours after a shop PC was last touched, which is the worst // possible time to find it. func (c *Client) do(ctx context.Context, method, path string, body, out any) error { var raw []byte if body != nil { var err error if raw, err = json.Marshal(body); err != nil { return err } } err := c.send(ctx, method, path, raw, out) if !errors.Is(err, errTokenExpired) { return err } if rerr := c.Refresh(ctx); rerr != nil { // The refresh token is gone too, so this really is a sign-in, not a // transient failure. Forget the session - in memory AND on disk, through // the same callback that persists rotations - so the app goes back to // Login instead of showing "session expired" on every screen until // somebody finds Sign out. Seen on a PC that had been claimed against a // demo head office and then re-claimed against the real one: the old // login sat there, dead, for the whole session. c.Clear() if c.onRefresh != nil { c.onRefresh(Session{}) } return ErrUnauthorized } return c.send(ctx, method, path, raw, out) } // APIError carries the server's machine-readable code alongside the prose. // // Some codes are not failures at all: a customer with no photo is the default // configuration of this product, not a fault, and a caller cannot tell that // from the message text. Error() still returns the server's own words, so // anything that only prints the error is unaffected. type APIError struct { Status int Code string Message string } func (e *APIError) Error() string { return e.Message } // codeOf reports the server's error code, or "" for anything else. func codeOf(err error) string { var ae *APIError if errors.As(err, &ae) { return ae.Code } return "" } // errTokenExpired is internal: callers see either success or ErrUnauthorized. // An expiring access token is an ordinary event that the client handles on its // own, not something every screen should have to know about. var errTokenExpired = errors.New("access token expired") func (c *Client) send(ctx context.Context, method, path string, raw []byte, out any) error { var rdr io.Reader if raw != nil { rdr = bytes.NewReader(raw) } req, err := http.NewRequestWithContext(ctx, method, c.Base+path, rdr) if err != nil { return err } if raw != nil { req.Header.Set("Content-Type", "application/json") } c.mu.RLock() tok := c.token c.mu.RUnlock() if tok != "" { req.Header.Set("Authorization", "Bearer "+tok) } resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("cannot reach %s: %w", c.Base, err) } defer resp.Body.Close() // The server returns {error, message, detail}; showing `message` puts the // server's own words in front of the user instead of a status code. var e struct { Message string `json:"message"` Error string `json:"error"` } if resp.StatusCode >= 400 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192)) _ = json.Unmarshal(body, &e) } switch { case resp.StatusCode == http.StatusUnauthorized && e.Error == "token_expired": return errTokenExpired case resp.StatusCode == http.StatusUnauthorized && tok != "": // A 401 on a call we sent a session with: the session is the problem. return ErrUnauthorized case resp.StatusCode >= 400: // Every other 4xx/5xx - including a 401 on a call that carried NO // session, such as redeeming an installation code - is about the // request, and the server wrote its message for exactly this moment. // Mapping those to "session expired" told an installer their session // had lapsed on a screen where they had never signed in, and hid // "That installation code is not valid" behind it. msg := e.Message if msg == "" { msg = fmt.Sprintf("%s %s: %s", method, path, resp.Status) } return &APIError{Status: resp.StatusCode, Code: e.Error, Message: msg} } if out == nil { return nil } return json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(out) } // Refresh swaps the refresh token for a new pair. // // Serialised behind refreshMu so a screen that fires four polls at once does // not spend the refresh token four times - the server rotates it on use, so // three of those four would race and lose, logging the shop out at random. func (c *Client) Refresh(ctx context.Context) error { c.refreshMu.Lock() defer c.refreshMu.Unlock() c.mu.RLock() before, refresh := c.token, c.refresh c.mu.RUnlock() if refresh == "" { return ErrUnauthorized } var s Session if err := c.send(ctx, http.MethodPost, "/api/auth/refresh", mustJSON(map[string]string{"refresh_token": refresh}), &s); err != nil { return err } c.mu.Lock() // Another goroutine may have refreshed while this one waited on the lock; // its tokens are the live ones and must not be overwritten by ours. if c.token == before { c.token, c.refresh = s.Token, s.RefreshToken if s.User.Email != "" { c.user = s.User } } c.mu.Unlock() if c.onRefresh != nil { // So the caller can persist the rotated tokens. Without this a PC that // refreshes and then reboots comes back holding a refresh token the // server already invalidated. c.onRefresh(s) } return nil } // OnRefresh registers a callback fired whenever the session rotates. func (c *Client) OnRefresh(fn func(Session)) { c.onRefresh = fn } func mustJSON(v any) []byte { b, err := json.Marshal(v) if err != nil { panic(err) // a map of strings cannot fail to marshal } return b } func (c *Client) Login(ctx context.Context, email, password string) (Session, error) { var s Session err := c.do(ctx, http.MethodPost, "/api/auth/login", map[string]string{"email": email, "password": password}, &s) if err != nil { return Session{}, err } c.SetSession(s) return s, nil } func (c *Client) Me(ctx context.Context) (User, error) { var u User err := c.do(ctx, http.MethodGet, "/api/auth/me", nil, &u) if err == nil { c.mu.Lock() c.user = u c.mu.Unlock() } return u, err } // Logout revokes the session server-side as well as forgetting it here. // Clearing only the local copy leaves a live token on a machine somebody is // about to hand back. func (c *Client) Logout(ctx context.Context) error { err := c.do(ctx, http.MethodPost, "/api/auth/logout", nil, nil) c.Clear() return err } // Session returns the current tokens so the caller can persist them. func (c *Client) Session() Session { c.mu.RLock() defer c.mu.RUnlock() return Session{Token: c.token, RefreshToken: c.refresh, User: c.user} } // Bootstrap is what a freshly installed PC asks for after the operator logs // in: which models to fetch, and the broker credentials for this site. The // installer ships none of this, so a leaked build hands out nothing. type Bootstrap struct { SiteID string `json:"site_id"` SiteName string `json:"site_name"` SiteSlug string `json:"site_slug"` // ClientSlug and SiteSlug are what the agent's topic prefix is built from, // and the server derives ClientSlug from the broker username so the two // cannot disagree with the broker's ACL. ClientSlug string `json:"client_slug"` MQTTURL string `json:"mqtt_url"` MQTTUser string `json:"mqtt_username"` MQTTPass string `json:"mqtt_password"` // AgentToken is this PC's own credential for the HTTPS API - asking for an // image upload URL, pulling its camera list. Not the broker password: they // authenticate different things, so rotating one must not break the other. AgentToken string `json:"agent_token"` CACert string `json:"ca_cert"` Models []Model `json:"models"` } type Model struct { Name string `json:"name"` URL string `json:"url"` SHA256 string `json:"sha256"` Bytes int64 `json:"bytes"` } func (c *Client) Bootstrap(ctx context.Context, siteToken string) (Bootstrap, error) { var b Bootstrap return b, c.do(ctx, http.MethodPost, "/api/agent/enrol", map[string]string{"site_token": siteToken}, &b) } type FootfallPoint struct { Bucket string `json:"bucket"` Visitors int `json:"visitors"` New int `json:"new"` Returning int `json:"returning"` } type FootfallReport struct { From string `json:"from"` To string `json:"to"` Bucket string `json:"bucket"` TZ string `json:"timezone"` Points []FootfallPoint `json:"points"` // Total is unique people over the whole window; Visits counts every // appearance. Summing Points gives neither - a customer who came on Monday // and Thursday is one Total and two bucket-visitors - so both ship rather // than letting a screen add up the chart and call it a headcount. Total int `json:"total"` Visits int `json:"visits"` // Share of faces the cameras saw that fell below the enrolment gate. A // footfall figure from a badly placed camera is wrong in a way nobody can // see, so the number ships with its own confidence. FractionBelowGate float64 `json:"fraction_below_gate"` WorstSite string `json:"worst_site,omitempty"` } // SiteHealth distinguishes "no customers" from "this shop's PC has been // unplugged for a week" - two identical rows of zeroes with completely // different responses. type SiteHealth struct { SiteID string `json:"site_id"` Slug string `json:"slug"` Name string `json:"name"` Timezone string `json:"timezone"` Online bool `json:"online"` LastHeartbeatAt string `json:"last_heartbeat_at"` LastEventAt string `json:"last_event_at"` RecognitionModel string `json:"recognition_model"` AgentVersion string `json:"agent_version"` CamerasUp int `json:"cameras_up"` CamerasTotal int `json:"cameras_total"` FractionBelowGate float64 `json:"fraction_below_gate"` Queued int `json:"queued"` Dropped int64 `json:"dropped"` } func (c *Client) Sites(ctx context.Context) ([]SiteHealth, error) { var out []SiteHealth return out, c.do(ctx, http.MethodGet, "/api/sites", nil, &out) } // Visit is one appearance in a customer's timeline. type Visit struct { ID string `json:"id"` OccurredAt string `json:"occurred_at"` Site string `json:"site"` CameraID string `json:"camera_id"` IsNew bool `json:"is_new_visitor"` Similarity float64 `json:"similarity"` Quality float64 `json:"quality"` Attributes map[string]any `json:"attributes"` } // Photo is a customer's face image, or a plain statement that there isn't one. // // Absence is modelled as data rather than as an error because it is the // ordinary case: images are off by default, so most deployments answer // "no photo" for every customer forever. Returning an error there would put a // red failure box on screen for a system working exactly as configured, and a // UI that cries wolf is a UI whose real errors get ignored. type Photo struct { URL string `json:"url"` ExpiresIn int `json:"expires_in"` Available bool `json:"available"` Reason string `json:"reason"` // Auth is set by the server when the URL is one of its own endpoints and // needs this session's bearer, rather than a presigned object-store link // that carries its own signature. It never reaches the front end - see // VisitorImage, which resolves it here. Auth bool `json:"auth"` } // VisitorImage fetches a short-lived signed link to this customer's photo. // // The link expires (the server decides how soon, and says so), so it is // fetched when a screen opens rather than cached alongside the customer. func (c *Client) VisitorImage(ctx context.Context, id string) (Photo, error) { var out Photo err := c.do(ctx, http.MethodGet, "/api/visitors/"+url.PathEscape(id)+"/image", nil, &out) if err != nil { switch codeOf(err) { case "no_image": return Photo{Reason: "No photo of this customer has been captured."}, nil case "images_disabled": return Photo{Reason: "This system is not storing customer photos."}, nil } return Photo{}, err } out.Available = out.URL != "" // A deployment with no object storage serves the photo from the API itself, // which means a RELATIVE url that needs this session's bearer. Neither // works in the window: a webview resolves a relative src against // wails://, not against the cloud, and it cannot send an Authorization // header at all - so handing it straight through renders a broken picture // on exactly the deployments that have just started storing photos. // // Fetched here and passed as a data: URI. The alternative is a local proxy // inside this process holding the session, which is a second authenticated // surface on the shop PC to get wrong. One photo per sheet, ~90 KB, and the // server already records the read where the link was handed out. if out.Available && out.Auth { data, err := c.fetchImage(ctx, out.URL) if err != nil { // The record itself is worth far more than the picture, so this is // an absence with a reason rather than a failure that blanks the // customer - the same rule the whole image path follows. return Photo{Reason: "That photo could not be loaded."}, nil } out.URL = data out.Auth = false } return out, nil } // fetchImage reads an image this server holds itself and returns a data: URI. // // Deliberately not routed through send(): that decodes JSON into `out`, and // these are bytes. It shares the token and the expiry retry, because a sheet // opened twelve hours after the last one must not show a broken photo. func (c *Client) fetchImage(ctx context.Context, path string) (string, error) { body, err := c.imageBytes(ctx, path) if errors.Is(err, errTokenExpired) { if rerr := c.Refresh(ctx); rerr != nil { return "", rerr } body, err = c.imageBytes(ctx, path) } if err != nil { return "", err } return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(body), nil } // maxPhotoBytes bounds what will be pulled into memory and then base64'd into // the window. Face crops are ~20 KB and a camera still ~100 KB; anything near // this is a different file or a fault, and a shop PC should not spend its // memory finding that out. const maxPhotoBytes = 4 << 20 func (c *Client) imageBytes(ctx context.Context, path string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Base+path, nil) if err != nil { return nil, err } c.mu.RLock() tok := c.token c.mu.RUnlock() if tok != "" { req.Header.Set("Authorization", "Bearer "+tok) } resp, err := c.http.Do(req) if err != nil { return nil, fmt.Errorf("cannot reach %s: %w", c.Base, err) } defer resp.Body.Close() if resp.StatusCode == http.StatusUnauthorized { var e struct { Error string `json:"error"` } body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192)) _ = json.Unmarshal(body, &e) if e.Error == "token_expired" { return nil, errTokenExpired } return nil, ErrUnauthorized } if resp.StatusCode >= 400 { return nil, fmt.Errorf("photo: %s", resp.Status) } return io.ReadAll(io.LimitReader(resp.Body, maxPhotoBytes)) } // ForgetVisitor erases a customer: face template, photo and profile. // // Irreversible by design — a soft-deleted face template is a retained // photograph by another name, because template inversion reconstructs a // recognisable face from it. The server refuses the whole request rather than // report a partial erasure, so an error here means nothing was deleted. func (c *Client) ForgetVisitor(ctx context.Context, id string) error { return c.do(ctx, http.MethodDelete, "/api/visitors/"+url.PathEscape(id), nil, nil) } // AssistantTurn is one message in the help conversation. The browser holds // the history and resends it; nothing is stored server-side. type AssistantTurn struct { Role string `json:"role"` Text string `json:"text"` } // AssistantAnswer is the reply, and the names of what it looked at - shown to // the user, because an assistant that silently ran a camera check would be // alarming and naming what it consulted makes a wrong answer traceable. type AssistantAnswer struct { Text string `json:"text"` Used []string `json:"used,omitempty"` } // Ask puts a question to the head-office assistant as this signed-in user. func (c *Client) Ask(ctx context.Context, history []AssistantTurn) (AssistantAnswer, error) { var out AssistantAnswer return out, c.do(ctx, http.MethodPost, "/api/assistant", map[string]any{"history": history}, &out) } func (c *Client) VisitorHistory(ctx context.Context, id string, limit int) ([]Visit, error) { var out []Visit return out, c.do(ctx, http.MethodGet, fmt.Sprintf("/api/visitors/%s/history?limit=%d", url.PathEscape(id), limit), nil, &out) } func (c *Client) Footfall(ctx context.Context, from, to, bucket string) (FootfallReport, error) { var r FootfallReport return r, c.do(ctx, http.MethodGet, fmt.Sprintf("/api/reports/footfall?from=%s&to=%s&bucket=%s", from, to, bucket), nil, &r) } type SalesReport struct { Visitors int `json:"visitors"` Purchasers int `json:"purchasers"` Conversion float64 `json:"conversion"` Revenue float64 `json:"revenue"` AvgBasket float64 `json:"average_basket"` Currency string `json:"currency"` } func (c *Client) Sales(ctx context.Context, from, to string) (SalesReport, error) { var r SalesReport return r, c.do(ctx, http.MethodGet, fmt.Sprintf("/api/reports/conversion?from=%s&to=%s", from, to), nil, &r) } type Customer struct { ID string `json:"id"` // Ref is the customer number - "V-42" - and is what staff say to each // other. It is accepted anywhere this customer's id is. Ref string `json:"ref"` Label string `json:"label"` FullName string `json:"full_name"` Phone string `json:"phone"` Email string `json:"email"` VisitCount int `json:"visit_count"` FirstSeenAt string `json:"first_seen_at"` LastSeenAt string `json:"last_seen_at"` HasProfile bool `json:"has_profile"` HasConsent bool `json:"has_consent"` } func (c *Client) Customers(ctx context.Context, query string, limit int) ([]Customer, error) { var out []Customer return out, c.do(ctx, http.MethodGet, fmt.Sprintf("/api/visitors?q=%s&limit=%d", url.QueryEscape(query), limit), nil, &out) } // Profile is the in-store form. PUT rather than POST: a staff member // resubmitting on a bad connection must not create a second record for the // same person. type Profile struct { VisitorID string `json:"visitor_id"` FullName string `json:"full_name"` Phone string `json:"phone"` Email string `json:"email"` Gender string `json:"gender"` DateOfBirth string `json:"date_of_birth"` Notes string `json:"notes"` Consent bool `json:"consent"` } func (c *Client) SaveProfile(ctx context.Context, p Profile) error { return c.do(ctx, http.MethodPut, "/api/visitors/"+url.PathEscape(p.VisitorID)+"/profile", p, nil) } func (c *Client) RecordPurchase(ctx context.Context, visitorID string, amount float64, items []string, notes string) error { return c.do(ctx, http.MethodPost, "/api/purchases", map[string]any{ "visitor_id": visitorID, "amount": amount, "items": items, "source": "manual", "notes": notes, }, nil) } // ---------------------------------------------------------------- viewing -- // // A PC with no engine of its own is not broken, it is a VIEWER: somebody // signed in on a laptop away from the shop. Everything below reads head // office so those screens have something true to show instead of "engine not // reachable", which is an accurate sentence and a useless one when the reader // was never expecting an engine on that machine. // Arrival is one visit as the estate's feed reports it, across every shop - // not just this PC's. `GET /api/visits`. type Arrival struct { VisitID string `json:"visit_id"` VisitRef string `json:"visit_ref"` OccurredAt string `json:"occurred_at"` Site string `json:"site"` SiteSlug string `json:"site_slug"` CameraID string `json:"camera_id"` VisitorID string `json:"visitor_id"` Ref string `json:"ref"` Label string `json:"label"` IsNew bool `json:"is_new_visitor"` Similarity float64 `json:"similarity"` Attributes map[string]any `json:"attributes"` Image Photo `json:"image"` } // RemoteCamera is a camera as HEAD OFFICE knows it. Deliberately not the same // type the local engine returns: this one can never be edited from here (the // shop PC on that LAN is the only thing that can reach it) and it carries a // snapshot rather than a stream. type RemoteCamera struct { ID string `json:"id"` CameraID string `json:"camera_id"` Label string `json:"label"` Site string `json:"site"` SiteSlug string `json:"site_slug"` Enabled bool `json:"enabled"` Connected *bool `json:"connected"` LastSeenAt string `json:"last_seen_at"` // State is the server's single answer - connected / not_connecting / // waiting / stale - and the screen renders that rather than deciding // again from Connected. Two places deciding one fact is how a shop came // out labelled Working, in green, above "2 of 3 cameras not connecting". State string `json:"state"` StateNote string `json:"state_note"` Snapshot Photo `json:"snapshot"` SnapshotAt string `json:"snapshot_at"` } // Arrivals reads the estate's recent visits, newest last. func (c *Client) Arrivals(ctx context.Context, limit int) ([]Arrival, error) { var out struct { Arrivals []Arrival `json:"arrivals"` } if err := c.send(ctx, http.MethodGet, fmt.Sprintf("/api/visits?limit=%d", limit), nil, &out); err != nil { return nil, err } return out.Arrivals, nil } // RemoteCameras lists every camera head office knows about for this company. func (c *Client) RemoteCameras(ctx context.Context) ([]RemoteCamera, error) { var out []RemoteCamera if err := c.send(ctx, http.MethodGet, "/api/cameras", nil, &out); err != nil { return nil, err } for i := range out { out[i].Snapshot = c.resolveShot(ctx, out[i].ID, out[i].SnapshotAt, out[i].Snapshot) } return out, nil } // resolveShot turns a camera snapshot into something the window can render. // // Same problem VisitorImage has and the same answer: a deployment with no // object storage serves the picture from the API itself, so the url is // relative and needs this session's bearer. A webview can supply // neither - it resolves a relative src against wails:// and cannot set a // header - so the bytes are fetched here and passed as a data: URI. // // A failure is an absence with a reason, never an error. Whether the camera is // CONNECTED is the answer this screen exists to give; the photograph is // decoration, and blanking the card because a picture would not load would // hide the part that matters. func (c *Client) resolveShot(ctx context.Context, camID, at string, p Photo) Photo { if !p.Available || !p.Auth || p.URL == "" { return p } c.shotMu.Lock() hit, ok := c.shots[camID] c.shotMu.Unlock() if ok && hit.at == at && at != "" { p.URL, p.Auth = hit.uri, false return p } uri, err := c.fetchImage(ctx, p.URL) if err != nil { return Photo{Reason: "That camera's picture could not be loaded."} } c.shotMu.Lock() if c.shots == nil { c.shots = map[string]cachedShot{} } c.shots[camID] = cachedShot{at: at, uri: uri} c.shotMu.Unlock() p.URL, p.Auth = uri, false return p } // CameraLive opens head office's live relay for one camera and returns the // live SSE response for the caller to read and close. // // A response rather than frames, because the consumer is the app's own // loopback relay: it re-emits these frames as MJPEG so an can show them, // and buffering the stream through a channel here would only add a place for // frames to queue. A stale frame is worthless - the only one worth having is // the newest - which is the whole reason LiveHub drops rather than queues. // // There is no client timeout on this request. A live view is endless by // design and any deadline would cut the picture off mid-shift; the context is // what ends it, when the viewer navigates away. func (c *Client) CameraLive(ctx context.Context, cameraID string) (*http.Response, error) { resp, err := c.liveOnce(ctx, cameraID) if errors.Is(err, errTokenExpired) { if rerr := c.Refresh(ctx); rerr != nil { return nil, rerr } resp, err = c.liveOnce(ctx, cameraID) } return resp, err } func (c *Client) liveOnce(ctx context.Context, cameraID string) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Base+"/api/cameras/"+url.PathEscape(cameraID)+"/live", nil) if err != nil { return nil, err } req.Header.Set("Accept", "text/event-stream") c.mu.RLock() tok := c.token c.mu.RUnlock() if tok == "" { return nil, ErrUnauthorized } req.Header.Set("Authorization", "Bearer "+tok) // c.http has a 30 s timeout, which covers the whole response and would // therefore sever a working live view every thirty seconds - the same // trap that made the server set WriteTimeout to zero for its own SSE // endpoint. A dedicated client, with the dial bounded instead. hc := &http.Client{Transport: &http.Transport{ DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext, TLSHandshakeTimeout: 10 * time.Second, }} resp, err := hc.Do(req) if err != nil { return nil, fmt.Errorf("cannot reach %s: %w", c.Base, err) } if resp.StatusCode == http.StatusUnauthorized { var e struct { Error string `json:"error"` } body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192)) resp.Body.Close() _ = json.Unmarshal(body, &e) if e.Error == "token_expired" { return nil, errTokenExpired } return nil, ErrUnauthorized } if resp.StatusCode >= 400 { resp.Body.Close() return nil, fmt.Errorf("live view: %s", resp.Status) } return resp, nil }