@@ -3,6 +3,7 @@ package unifi
33import (
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.
113119func (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.
276357func (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