From a2c71c305de4989513f89ee6461889879bf37714 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Thu, 5 Mar 2026 01:30:15 -0500 Subject: [PATCH 1/5] feat: add retry with backoff for rate limits and FetchAllOrgDevices helper Apple's ABM API has unpublished rate limits and returns HTTP 429 when exceeded. Add exponential backoff retry (up to 5 retries, starting at 1s) to doJSONRequest so all API calls automatically handle rate limiting. Also add FetchAllOrgDevices which fetches all org devices following pagination, returning the full device list and total count. Co-Authored-By: Claude Opus 4.6 --- abm.go | 52 +++++++++++++++++++++++++++++++ client.go | 92 ++++++++++++++++++++++++++++++++++++------------------- 2 files changed, 113 insertions(+), 31 deletions(-) diff --git a/abm.go b/abm.go index 161ce6d..8b9662c 100644 --- a/abm.go +++ b/abm.go @@ -47,6 +47,58 @@ func (c *Client) FetchOrgDevicePartNumbers(ctx context.Context) ([]string, error return partNumbers, nil } +// FetchAllOrgDevices returns all org devices for the organization, +// automatically following pagination until all pages are consumed. +// It also returns the total device count from the first page's metadata. +func (c *Client) FetchAllOrgDevices(ctx context.Context) ([]OrgDevice, int, error) { + if err := ctx.Err(); err != nil { + return nil, 0, err + } + + baseURL, err := c.buildURL(orgDevicesPath, nil) + if err != nil { + return nil, 0, err + } + + var all []OrgDevice + var total int + firstPage := true + + for pageResult, err := range PageIterator(ctx, c.httpClient, decodeOrgDevicesPage, baseURL) { + if err != nil { + return all, total, err + } + if firstPage { + total = pageResult.total + firstPage = false + } + all = append(all, pageResult.devices...) + } + + return all, total, nil +} + +type orgDevicesPageResult struct { + devices []OrgDevice + total int +} + +func decodeOrgDevicesPage(payload []byte) (orgDevicesPageResult, string, error) { + var response OrgDevicesResponse + if err := json.Unmarshal(payload, &response); err != nil { + return orgDevicesPageResult{}, "", fmt.Errorf("decode org devices response: %w", err) + } + + result := orgDevicesPageResult{ + devices: response.Data, + } + if response.Meta != nil { + result.total = response.Meta.Paging.Total + } + + return result, response.Links.Next, nil +} + func decodeOrgDevices(payload []byte) ([]string, string, error) { var response OrgDevicesResponse if err := json.Unmarshal(payload, &response); err != nil { diff --git a/client.go b/client.go index 74d5f66..1fdedf7 100644 --- a/client.go +++ b/client.go @@ -26,11 +26,20 @@ import ( "slices" "strconv" "strings" + "time" "github.com/go-json-experiment/json" "golang.org/x/oauth2" ) +const ( + // defaultMaxRetries is the default maximum number of retries for rate-limited requests. + defaultMaxRetries = 5 + + // defaultInitialBackoff is the initial backoff duration for retries. + defaultInitialBackoff = time.Second +) + const ( // DefaultAPIBaseURL is the default Apple Business Manager API base URL. DefaultAPIBaseURL = "https://api-business.apple.com/" @@ -480,42 +489,63 @@ func (c *Client) doJSONRequest(ctx context.Context, method, path string, query u } } - requestReader := io.Reader(http.NoBody) - if len(body) > 0 { - requestReader = bytes.NewReader(body) - } + backoff := defaultInitialBackoff - req, err := http.NewRequestWithContext(ctx, method, requestURL, requestReader) - if err != nil { - return fmt.Errorf("build request: %w", err) - } - req.Header.Set("Accept", "application/json") - if len(body) > 0 { - req.Header.Set("Content-Type", "application/json") - } + for attempt := 0; ; attempt++ { + if err := ctx.Err(); err != nil { + return err + } - resp, err := c.httpClient.Do(req) - if err != nil { - return fmt.Errorf("send request: %w", err) - } - defer resp.Body.Close() + requestReader := io.Reader(http.NoBody) + if len(body) > 0 { + requestReader = bytes.NewReader(body) + } - payload, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("read response body: %w", err) - } + req, err := http.NewRequestWithContext(ctx, method, requestURL, requestReader) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("Accept", "application/json") + if len(body) > 0 { + req.Header.Set("Content-Type", "application/json") + } - if !statusAllowed(resp.StatusCode, expectedStatusCodes) { - return decodeAPIError(resp, payload) - } + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("send request: %w", err) + } - if responseBody == nil || len(payload) == 0 { - return nil - } + payload, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return fmt.Errorf("read response body: %w", err) + } - if err := json.Unmarshal(payload, responseBody); err != nil { - return fmt.Errorf("decode response body: %w", err) - } + // Retry on 429 Too Many Requests with exponential backoff. + // Apple's ABM API has unpublished rate limits and returns 429 + // when they are exceeded. + if resp.StatusCode == http.StatusTooManyRequests && attempt < defaultMaxRetries { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(backoff): + } + backoff *= 2 + continue + } - return nil + if !statusAllowed(resp.StatusCode, expectedStatusCodes) { + return decodeAPIError(resp, payload) + } + + if responseBody == nil || len(payload) == 0 { + return nil + } + + if err := json.Unmarshal(payload, responseBody); err != nil { + return fmt.Errorf("decode response body: %w", err) + } + + return nil + } } From a309147b47cb4689f8c0ed3252580963bbc06cef Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Thu, 5 Mar 2026 14:34:40 -0500 Subject: [PATCH 2/5] client: respect Retry-After header on 429 responses Apple's ABM API returns a Retry-After header (in seconds) with 429 responses. Parse and use it instead of always falling back to the exponential backoff, which was too aggressive for the device listing endpoint (Apple sends Retry-After: 60) and too slow for the AppleCare endpoint (Apple sends Retry-After: 1). Co-Authored-By: Claude Opus 4.6 --- client.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/client.go b/client.go index 1fdedf7..4d5de63 100644 --- a/client.go +++ b/client.go @@ -521,14 +521,20 @@ func (c *Client) doJSONRequest(ctx context.Context, method, path string, query u return fmt.Errorf("read response body: %w", err) } - // Retry on 429 Too Many Requests with exponential backoff. + // Retry on 429 Too Many Requests. // Apple's ABM API has unpublished rate limits and returns 429 - // when they are exceeded. + // when they are exceeded, with a Retry-After header in seconds. if resp.StatusCode == http.StatusTooManyRequests && attempt < defaultMaxRetries { + wait := backoff + if ra := resp.Header.Get("Retry-After"); ra != "" { + if secs, err := strconv.Atoi(ra); err == nil { + wait = time.Duration(secs) * time.Second + } + } select { case <-ctx.Done(): return ctx.Err() - case <-time.After(backoff): + case <-time.After(wait): } backoff *= 2 continue From 62230fa6037ff2395122550d2210e762b6acc954 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Thu, 5 Mar 2026 14:55:07 -0500 Subject: [PATCH 3/5] client: add proactive rate limiting to avoid 429s Apple's ABM API has an observed limit of ~20 requests/minute. Add a token bucket rate limiter (1 req/3s, burst 1) at the transport level so both doJSONRequest and PageIterator are covered. This avoids triggering 429s in the first place, which is better throughput than blasting requests and then waiting 60s on the penalty. The rate limiter sits below the oauth2 transport so auth token refreshes are not rate-limited. Co-Authored-By: Claude Opus 4.6 --- client.go | 28 +++++++++++++++++++++++++++- go.mod | 2 ++ go.sum | 2 ++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/client.go b/client.go index 4d5de63..c7f9479 100644 --- a/client.go +++ b/client.go @@ -30,6 +30,7 @@ import ( "github.com/go-json-experiment/json" "golang.org/x/oauth2" + "golang.org/x/time/rate" ) const ( @@ -38,8 +39,30 @@ const ( // defaultInitialBackoff is the initial backoff duration for retries. defaultInitialBackoff = time.Second + + // defaultRateLimit is the proactive rate limit in requests per second. + // Apple's ABM API has an observed limit of ~20 requests/minute. + // 1 request every 3 seconds = 20/min, staying just at the limit. + defaultRateLimit = rate.Limit(1.0 / 3.0) + + // defaultRateBurst is the maximum burst size for the rate limiter. + defaultRateBurst = 1 ) +// rateLimitTransport wraps an http.RoundTripper with proactive rate limiting +// to avoid hitting Apple's 429 rate limits. +type rateLimitTransport struct { + base http.RoundTripper + limiter *rate.Limiter +} + +func (t *rateLimitTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if err := t.limiter.Wait(req.Context()); err != nil { + return nil, err + } + return t.base.RoundTrip(req) +} + const ( // DefaultAPIBaseURL is the default Apple Business Manager API base URL. DefaultAPIBaseURL = "https://api-business.apple.com/" @@ -148,7 +171,10 @@ func NewClientWithBaseURL(httpClient *http.Client, tokenSource oauth2.TokenSourc authorizedClient := *httpClient authorizedClient.Transport = &oauth2.Transport{ - Base: baseTransport, + Base: &rateLimitTransport{ + base: baseTransport, + limiter: rate.NewLimiter(defaultRateLimit, defaultRateBurst), + }, Source: tokenSource, } diff --git a/go.mod b/go.mod index 7ce8282..178561e 100644 --- a/go.mod +++ b/go.mod @@ -9,3 +9,5 @@ require ( github.com/google/uuid v1.6.0 golang.org/x/oauth2 v0.35.0 ) + +require golang.org/x/time v0.14.0 // indirect diff --git a/go.sum b/go.sum index 324ef44..0388650 100644 --- a/go.sum +++ b/go.sum @@ -8,3 +8,5 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= From 1e16e0a447e4eed207555800ccba2d519314f274 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Thu, 5 Mar 2026 15:50:49 -0500 Subject: [PATCH 4/5] Add FlexStringSlice for polymorphic string/array fields The ABM API returns MAC address fields (wifiMacAddress, bluetoothMacAddress, ethernetMacAddress) as either a single string or an array of strings depending on the device. Using plain []string causes unmarshal failures when the API returns a string. FlexStringSlice handles both formats. Co-Authored-By: Claude Opus 4.6 --- types.go | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/types.go b/types.go index 2cb2f74..51c70fa 100644 --- a/types.go +++ b/types.go @@ -17,9 +17,36 @@ package abm import ( + "encoding/json" "time" ) +// FlexStringSlice handles JSON fields that may be a single string or an array of strings. +// The ABM API is inconsistent about some fields (e.g. wifiMacAddress) — returning +// a string for single values and an array for multiple values. +type FlexStringSlice []string + +// UnmarshalJSON implements json.Unmarshaler, accepting both a string and an array of strings. +func (f *FlexStringSlice) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *f = nil + return nil + } + // Try array first + var arr []string + if err := json.Unmarshal(data, &arr); err == nil { + *f = arr + return nil + } + // Try single string + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + *f = []string{s} + return nil +} + // OrgDevicesResponse contains a list of organization device resources. type OrgDevicesResponse struct { Data []OrgDevice `json:"data"` @@ -81,9 +108,9 @@ type OrgDeviceAttributes struct { EID string `json:"eid,omitzero"` IMEI []string `json:"imei,omitempty"` MEID []string `json:"meid,omitempty"` - WifiMacAddress []string `json:"wifiMacAddress,omitempty"` - BluetoothMacAddress []string `json:"bluetoothMacAddress,omitempty"` - EthernetMacAddress []string `json:"ethernetMacAddress,omitempty"` + WifiMacAddress FlexStringSlice `json:"wifiMacAddress,omitempty"` + BluetoothMacAddress FlexStringSlice `json:"bluetoothMacAddress,omitempty"` + EthernetMacAddress FlexStringSlice `json:"ethernetMacAddress,omitempty"` OrderDateTime time.Time `json:"orderDateTime,omitzero"` OrderNumber string `json:"orderNumber,omitzero"` PartNumber string `json:"partNumber,omitzero"` From 89db9bed3a3eefb660c9ebea2690cd8b4e65e553 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Thu, 5 Mar 2026 16:04:34 -0500 Subject: [PATCH 5/5] Keep ethernetMacAddress as []string, only wifi/bluetooth need FlexStringSlice ethernetMacAddress is always returned as an array from the ABM API. Only wifiMacAddress and bluetoothMacAddress are returned as bare strings. Co-Authored-By: Claude Opus 4.6 --- types.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/types.go b/types.go index 51c70fa..49c38a5 100644 --- a/types.go +++ b/types.go @@ -110,7 +110,7 @@ type OrgDeviceAttributes struct { MEID []string `json:"meid,omitempty"` WifiMacAddress FlexStringSlice `json:"wifiMacAddress,omitempty"` BluetoothMacAddress FlexStringSlice `json:"bluetoothMacAddress,omitempty"` - EthernetMacAddress FlexStringSlice `json:"ethernetMacAddress,omitempty"` + EthernetMacAddress []string `json:"ethernetMacAddress,omitempty"` OrderDateTime time.Time `json:"orderDateTime,omitzero"` OrderNumber string `json:"orderNumber,omitzero"` PartNumber string `json:"partNumber,omitzero"`