Skip to content

Commit acf076b

Browse files
Merge pull request #200 from brngates98/fix/remote-api-rate-limit-and-nil-panic
Remote API: 429 retry, NVR filter, safe error body handling
2 parents a81641f + 233815f commit acf076b

1 file changed

Lines changed: 117 additions & 4 deletions

File tree

remote.go

Lines changed: 117 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package unifi
33
import (
44
"crypto/tls"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"io"
89
"net/http"
@@ -109,8 +110,28 @@ func NewRemoteAPIClient(apiKey string, errorLog, debugLog, log Logger) *RemoteAP
109110
}
110111
}
111112

112-
// makeRequest makes an HTTP request to the remote API.
113+
// maxRetries429 is the number of retries after the first 429 (so up to 3 attempts total).
114+
// We use the full Retry-After from the API (no cap) so rate-limited requests can succeed.
115+
const maxRetries429 = 2
116+
117+
// makeRequest makes an HTTP request to the remote API. On 429 (rate limit) it retries
118+
// up to maxRetries429 times after sleeping for the full Retry-After duration returned by the API.
113119
func (c *RemoteAPIClient) makeRequest(method, path string, queryParams map[string]string) ([]byte, error) {
120+
body, err := c.makeRequestOnce(method, path, queryParams)
121+
for attempt := 0; attempt < maxRetries429 && err != nil; attempt++ {
122+
var rateErr *RateLimitError
123+
if !errors.As(err, &rateErr) || rateErr.RetryAfter <= 0 {
124+
break
125+
}
126+
c.DebugLog("Rate limited (429), retry %d/%d after %v", attempt+1, maxRetries429, rateErr.RetryAfter)
127+
time.Sleep(rateErr.RetryAfter)
128+
body, err = c.makeRequestOnce(method, path, queryParams)
129+
}
130+
return body, err
131+
}
132+
133+
// makeRequestOnce performs a single HTTP request to the remote API (no retry).
134+
func (c *RemoteAPIClient) makeRequestOnce(method, path string, queryParams map[string]string) ([]byte, error) {
114135
fullURL := c.baseURL + path
115136

116137
if len(queryParams) > 0 {
@@ -144,11 +165,24 @@ func (c *RemoteAPIClient) makeRequest(method, path string, queryParams map[strin
144165
}
145166
defer resp.Body.Close()
146167

147-
body, err := io.ReadAll(resp.Body)
168+
// Limit read size for error responses to avoid OOM from huge HTML/error bodies
169+
const maxErrorBody = 64 * 1024
170+
var body []byte
171+
if resp.StatusCode >= 400 {
172+
body, err = io.ReadAll(io.LimitReader(resp.Body, maxErrorBody))
173+
} else {
174+
body, err = io.ReadAll(resp.Body)
175+
}
148176
if err != nil {
149177
return nil, fmt.Errorf("reading response: %w", err)
150178
}
151179

180+
if resp.StatusCode == http.StatusTooManyRequests {
181+
after := parseRetryAfter(resp.Header.Get("Retry-After"))
182+
183+
return nil, &RateLimitError{RetryAfter: after}
184+
}
185+
152186
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
153187
// Try to parse the error response for better error messages
154188
var apiErr APIErrorResponse
@@ -167,8 +201,12 @@ func (c *RemoteAPIClient) makeRequest(method, path string, queryParams map[strin
167201
return nil, fmt.Errorf("%s", errMsg)
168202
}
169203

170-
// Fallback to generic error if we can't parse the response
171-
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
204+
// Fallback: truncate body in error message to avoid huge allocations
205+
bodyStr := string(body)
206+
if len(bodyStr) > 512 {
207+
bodyStr = bodyStr[:512] + "... (truncated)"
208+
}
209+
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, bodyStr)
172210
}
173211

174212
return body, nil
@@ -272,6 +310,49 @@ func (c *RemoteAPIClient) DiscoverConsoles() ([]Console, error) {
272310
return allConsoles, nil
273311
}
274312

313+
// substrings that indicate a console does not support the Network API (sites/stat endpoints).
314+
var nonNetworkConsoleSubstrings = []string{"nvr", "protect", "cloudkey+ for displays"}
315+
316+
// FilterNetworkConsoles returns only consoles that are likely to support the UniFi Network API,
317+
// by excluding those whose name (case-insensitive) contains common NVR/Protect/display identifiers.
318+
// Use this to avoid 403s and rate limits when discovering sites for multi-console accounts.
319+
func FilterNetworkConsoles(consoles []Console) []Console {
320+
filtered := make([]Console, 0, len(consoles))
321+
name := ""
322+
for _, c := range consoles {
323+
name = c.ConsoleName
324+
if name == "" {
325+
name = c.ReportedState.Name
326+
}
327+
if name == "" {
328+
name = c.ReportedState.Hostname
329+
}
330+
lower := strings.ToLower(name)
331+
skip := false
332+
for _, sub := range nonNetworkConsoleSubstrings {
333+
if strings.Contains(lower, sub) {
334+
skip = true
335+
break
336+
}
337+
}
338+
if !skip {
339+
filtered = append(filtered, c)
340+
}
341+
}
342+
return filtered
343+
}
344+
345+
// DiscoverNetworkConsoles discovers consoles via the remote API and returns only those likely
346+
// to support the Network API (excludes NVR/Protect/display-only by name). Use this when you
347+
// only need Network-capable controllers to avoid 403s and unnecessary rate-limit pressure.
348+
func (c *RemoteAPIClient) DiscoverNetworkConsoles() ([]Console, error) {
349+
all, err := c.DiscoverConsoles()
350+
if err != nil {
351+
return nil, err
352+
}
353+
return FilterNetworkConsoles(all), nil
354+
}
355+
275356
// DiscoverSites discovers all sites for a given console ID.
276357
func (c *RemoteAPIClient) DiscoverSites(consoleID string) ([]RemoteSite, error) {
277358
path := fmt.Sprintf("/v1/connector/consoles/%s/proxy/network/integration/v1/sites", consoleID)
@@ -293,3 +374,35 @@ func (c *RemoteAPIClient) DiscoverSites(consoleID string) ([]RemoteSite, error)
293374

294375
return response.Data, nil
295376
}
377+
378+
// DiscoverSitesForConsolesResult holds the result of discovering sites for one console.
379+
type DiscoverSitesForConsolesResult struct {
380+
ConsoleID string
381+
Console Console
382+
Sites []RemoteSite
383+
Err error
384+
}
385+
386+
// DiscoverSitesForConsoles discovers sites for multiple consoles with a delay between
387+
// each request to avoid 429 rate limits from the remote API. Use a delay of at least
388+
// 1–2 seconds when many consoles are present. Failed consoles (e.g. 403 for NVR) are
389+
// reported in the result slice; only successful discoveries have Err == nil.
390+
func (c *RemoteAPIClient) DiscoverSitesForConsoles(consoles []Console, delayBetween time.Duration) []DiscoverSitesForConsolesResult {
391+
results := make([]DiscoverSitesForConsolesResult, 0, len(consoles))
392+
393+
for i, console := range consoles {
394+
if i > 0 && delayBetween > 0 {
395+
time.Sleep(delayBetween)
396+
}
397+
398+
sites, err := c.DiscoverSites(console.ID)
399+
results = append(results, DiscoverSitesForConsolesResult{
400+
ConsoleID: console.ID,
401+
Console: console,
402+
Sites: sites,
403+
Err: err,
404+
})
405+
}
406+
407+
return results
408+
}

0 commit comments

Comments
 (0)