From c548fc7a2a929acea19bb645013db7a5e9cf4087 Mon Sep 17 00:00:00 2001 From: Mikhail Fisher Date: Wed, 9 Sep 2026 13:52:40 +0200 Subject: [PATCH 1/3] fix: harden asa metrics transport and surface limits --- docs/agent/asa-metrics.md | 53 ++++++++++-- .../references/asa-agent-playbook.md | 15 ++-- src/commands/asa/ad-groups/create.ts | 2 +- src/commands/asa/ad-groups/get.ts | 2 +- src/commands/asa/ad-groups/list.ts | 2 +- src/commands/asa/ad-groups/update.ts | 2 +- src/commands/asa/ads/create.ts | 2 +- src/commands/asa/ads/get.ts | 2 +- src/commands/asa/ads/list.ts | 2 +- src/commands/asa/ads/update.ts | 2 +- src/commands/asa/apps/list.ts | 2 +- src/commands/asa/automations/create.ts | 2 +- src/commands/asa/automations/get.ts | 2 +- src/commands/asa/automations/list.ts | 2 +- src/commands/asa/automations/run.ts | 2 +- src/commands/asa/automations/runs.ts | 2 +- src/commands/asa/automations/update.ts | 2 +- src/commands/asa/campaigns/bulk-create.ts | 2 +- src/commands/asa/campaigns/bulk-list.ts | 2 +- src/commands/asa/campaigns/bulk-status.ts | 2 +- src/commands/asa/campaigns/create.ts | 2 +- src/commands/asa/campaigns/get.ts | 2 +- src/commands/asa/campaigns/list.ts | 2 +- src/commands/asa/campaigns/update.ts | 2 +- src/commands/asa/competitors/summary.ts | 2 +- src/commands/asa/connect.ts | 2 +- src/commands/asa/creatives/list.ts | 2 +- src/commands/asa/keywords/add.ts | 2 +- src/commands/asa/keywords/list.ts | 2 +- src/commands/asa/keywords/update.ts | 2 +- src/commands/asa/metrics/index.ts | 32 +++++-- src/commands/asa/metrics/overview.ts | 2 +- src/commands/asa/negative-keywords/add.ts | 2 +- src/commands/asa/negative-keywords/list.ts | 2 +- src/commands/asa/orgs/list.ts | 2 +- src/commands/asa/product-pages/list.ts | 2 +- src/commands/asa/product-pages/sync.ts | 2 +- src/commands/asa/search-terms/list.ts | 2 +- src/commands/asa/whoami.ts | 9 +- src/lib/api-client.ts | 86 +++++++++++++------ src/lib/asa-client.ts | 9 +- src/lib/asa-schemas.ts | 18 ++++ test/commands/asa-reads.test.ts | 45 ++++++++++ test/commands/asa-writes.test.ts | 16 ++++ test/commands/asa.test.ts | 19 ++++ 45 files changed, 287 insertions(+), 85 deletions(-) diff --git a/docs/agent/asa-metrics.md b/docs/agent/asa-metrics.md index 1299883..3f6af33 100644 --- a/docs/agent/asa-metrics.md +++ b/docs/agent/asa-metrics.md @@ -123,6 +123,41 @@ adapty asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 --metric roas --by-days 7 --by-days 90 --order-by net_roas --order-by-day 90 ``` +### Windows the cohort has not lived through yet + +A cohort metric is what has actually been observed so far, not a projection. A window longer +than the cohort's age therefore repeats the last real figure instead of returning nothing — a +July cohort read in August reports the same number at day 60, day 90 and day 300 as at day 28. + +`meta.max_valid_day` in the response is how many days the youngest cohort in the date range +has lived, counted from `--date-to`. Treat any `--by-days` window above it as not reached: + +- Do not divide a clipped numerator by a full-window denominator. That understates the result, + and by roughly a third in the case that prompted this note. +- Compare markets only at a window all of them have reached. +- The CLI prints a warning when a requested window is past `max_valid_day`; in `--json` the + number is there to check yourself. + +## Money and currency + +Money columns — `spend`, `local_spend`, and every revenue-derived metric — are in the campaign +group currency, not USD. `spend` and `local_spend` carry the same figure despite the naming. +The currency belongs to the group rather than to a row, so read it from `adapty asa orgs list` +before summing or comparing across accounts. + +## Counting entities + +The rows on a page are the page, not the inventory: `--order-by spend --page-size 1000` ranks +across everything the filters allow, so a page can hold a fraction of one app's keywords. +`meta.pagination.count` in the same response is the full count behind the filters — take +inventory from there, and scope the call with `--app` or `--campaign` to make the ranking mean +what you want. + +That count runs higher than the matching catalog list (`campaigns list`, `keywords list`). +Both are right: an entity deleted in Apple keeps the spend it already booked, so metrics still +report it, while the catalog lists show only what exists today. Use the catalog to answer "what +do I have", metrics to answer "what did I spend". + ## The analytics pool Three commands draw on one single-slot pool per company: `metrics`, `metrics overview`, and @@ -130,22 +165,26 @@ Three commands draw on one single-slot pool per company: `metrics`, `metrics ove others can't use (`competitors summary` holds its own single slot). On top of that shared concurrency, each pair also carries its own per-minute budget: -| Commands | Per-minute budget | +Budgets are raised per company; the table is the platform default. `adapty asa whoami` reports +the effective ones under `limits` — pace against those. + +| Commands | Default per-minute budget | |---|---| -| `metrics`, `metrics overview` | 5/min, burst at most 2 per 10s | +| `metrics`, `metrics overview` | 15/min, burst at most 5 per 10s | | `search-terms list`, `competitors summary` | 30/min | Three 429 codes, not one: - `cli_analytics_busy` — another analytics query is still running; wait about 5 seconds. -- `cli_rate_limit_exceeded` — the per-minute window (5/min or 30/min, whichever pair) is - full. +- `cli_rate_limit_exceeded` — the per-minute window for that pair is full. - `cli_cooldown_active` — stop entirely; tell the user when to retry. One refusal is a 422, not a 429: `cli_response_too_large` — a `metrics` page would exceed -5 000 breakdown rows (see [Date window caps](#date-window-caps)). It carries no -`Retry-After` and doesn't count toward the cool-down; retrying is pointless — change the -request instead (coarsen the grouping, narrow the window, or reduce `--page-size`). +the company's `max_breakdown_rows_per_page` (see [Date window caps](#date-window-caps)). It +carries no `Retry-After` and doesn't count toward the cool-down; retrying is pointless — +change the request instead (coarsen the grouping, narrow the window, or reduce +`--page-size`). The error names the `page[size]` that fits for the grouped period; use it +verbatim. Every 429 carries the wait in `Retry-After`. The CLI already absorbs the first 429 of any single command on its own — it waits the exact `Retry-After` (up to 60s; cool-downs diff --git a/skills/adapty-cli/references/asa-agent-playbook.md b/skills/adapty-cli/references/asa-agent-playbook.md index 4b96d2e..57268d5 100644 --- a/skills/adapty-cli/references/asa-agent-playbook.md +++ b/skills/adapty-cli/references/asa-agent-playbook.md @@ -57,9 +57,13 @@ an agent that ignores them gets 429s, then a token cool-down, and then it cannot ## Request budgets (per company, not per token) -| Commands | Budget | +Budgets are raised per company, so the numbers below are the platform defaults, not what this account +gets. `adapty asa whoami` reports the effective ones under `limits` — read them once at the start of a +session and pace against those instead of against this table. + +| Commands | Default budget | |---|---| -| `metrics`, `metrics overview` | 5/min, burst 2 per 10s, one at a time (pool shared with search-terms) | +| `metrics`, `metrics overview` | 15/min, burst 5 per 10s, one at a time (pool shared with search-terms) | | `search-terms list`, `competitors summary` | 30/min, search-terms shares the single-slot analytics pool | | `keywords list` | 30/min, burst 5 per 10s, own 2-concurrent pool, 60s timeout | | catalog lists and gets, automation reads | 120/min | @@ -70,9 +74,10 @@ Every refusal is a `429` with the exact wait in `Retry-After`; `cli_analytics_bu analytics query is still running (wait ~5s), `cli_rate_limit_exceeded` means the window is full, `cli_cooldown_active` means stop entirely and tell the user when to retry. The CLI already waits out and retries the first 429 of a command on its own — a surfaced 429 means the second attempt failed too. -`cli_response_too_large` is the exception: a 422 (a `metrics` page over 20 000 breakdown rows) with no -`Retry-After` and no cool-down strike — waiting fixes nothing, change the request instead; with day -grouping the error names the exact `page[size]` that fits — use it verbatim on the retry. +`cli_response_too_large` is the exception: a 422 (a `metrics` page over the company's +`max_breakdown_rows_per_page`, which `whoami` reports) with no `Retry-After` and no cool-down strike — +waiting fixes nothing, change the request instead; the error names the exact `page[size]` that fits for +the grouped period — use it verbatim on the retry. ## Metric vocabulary diff --git a/src/commands/asa/ad-groups/create.ts b/src/commands/asa/ad-groups/create.ts index 1a76d5d..6c838a9 100644 --- a/src/commands/asa/ad-groups/create.ts +++ b/src/commands/asa/ad-groups/create.ts @@ -77,7 +77,7 @@ export default class AsaAdGroupsCreate extends Command { flags.yes, ) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const {replayed, result} = await asaWrite(client, 'post', '/ad-groups', { body, idempotencyKey: flags['idempotency-key'], diff --git a/src/commands/asa/ad-groups/get.ts b/src/commands/asa/ad-groups/get.ts index b7756d2..b331f24 100644 --- a/src/commands/asa/ad-groups/get.ts +++ b/src/commands/asa/ad-groups/get.ts @@ -18,7 +18,7 @@ export default class AsaAdGroupsGet extends Command { const {args} = await this.parse(AsaAdGroupsGet) if (!isValidUuid(args.ad_group_id)) this.error('Invalid ad group ID format.', {exit: 2}) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const result = await client.get(`/ad-groups/${args.ad_group_id}`) printResponse(result as unknown as Record, this.log.bind(this)) diff --git a/src/commands/asa/ad-groups/list.ts b/src/commands/asa/ad-groups/list.ts index 38954b2..f0b30e3 100644 --- a/src/commands/asa/ad-groups/list.ts +++ b/src/commands/asa/ad-groups/list.ts @@ -18,7 +18,7 @@ export default class AsaAdGroupsList extends Command { async run(): Promise> { const {flags} = await this.parse(AsaAdGroupsList) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const result = await client.get>('/ad-groups', { ...paginationParams(flags), ...scopeParams(flags), diff --git a/src/commands/asa/ad-groups/update.ts b/src/commands/asa/ad-groups/update.ts index 7fa02f7..28315c3 100644 --- a/src/commands/asa/ad-groups/update.ts +++ b/src/commands/asa/ad-groups/update.ts @@ -53,7 +53,7 @@ export default class AsaAdGroupsUpdate extends Command { flags.yes, ) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const {replayed, result} = await asaWrite(client, 'put', `/ad-groups/${args.ad_group_id}`, { body, idempotencyKey: flags['idempotency-key'], diff --git a/src/commands/asa/ads/create.ts b/src/commands/asa/ads/create.ts index 9bdc727..d89447f 100644 --- a/src/commands/asa/ads/create.ts +++ b/src/commands/asa/ads/create.ts @@ -33,7 +33,7 @@ export default class AsaAdsCreate extends Command { } await confirmMutation(this, {body, method: 'POST', path: '/ads/', summary: `Create ad ${flags.name}`}, flags.yes) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const {replayed, result} = await asaWrite(client, 'post', '/ads', { body, idempotencyKey: flags['idempotency-key'], diff --git a/src/commands/asa/ads/get.ts b/src/commands/asa/ads/get.ts index 99f0a0c..abe4556 100644 --- a/src/commands/asa/ads/get.ts +++ b/src/commands/asa/ads/get.ts @@ -18,7 +18,7 @@ export default class AsaAdsGet extends Command { const {args} = await this.parse(AsaAdsGet) if (!isValidUuid(args.ad_id)) this.error('Invalid ad ID format.', {exit: 2}) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const result = await client.get(`/ads/${args.ad_id}`) printResponse(result as unknown as Record, this.log.bind(this)) diff --git a/src/commands/asa/ads/list.ts b/src/commands/asa/ads/list.ts index f806fc4..f82632d 100644 --- a/src/commands/asa/ads/list.ts +++ b/src/commands/asa/ads/list.ts @@ -18,7 +18,7 @@ export default class AsaAdsList extends Command { async run(): Promise> { const {flags} = await this.parse(AsaAdsList) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const result = await client.get>('/ads', { ...paginationParams(flags), ...scopeParams(flags), diff --git a/src/commands/asa/ads/update.ts b/src/commands/asa/ads/update.ts index 56a2b91..2346464 100644 --- a/src/commands/asa/ads/update.ts +++ b/src/commands/asa/ads/update.ts @@ -36,7 +36,7 @@ export default class AsaAdsUpdate extends Command { await confirmMutation(this, {body, method: 'PUT', path: `/ads/${args.ad_id}/`, summary: 'Update ad'}, flags.yes) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const {replayed, result} = await asaWrite(client, 'put', `/ads/${args.ad_id}`, { body, idempotencyKey: flags['idempotency-key'], diff --git a/src/commands/asa/apps/list.ts b/src/commands/asa/apps/list.ts index 8cddfc9..084fbe3 100644 --- a/src/commands/asa/apps/list.ts +++ b/src/commands/asa/apps/list.ts @@ -15,7 +15,7 @@ export default class AsaAppsList extends Command { async run(): Promise> { const {flags} = await this.parse(AsaAppsList) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const result = await client.get>('/apps', paginationParams(flags)) printList(result.data as unknown as Record[], this.log.bind(this), result.meta.pagination) diff --git a/src/commands/asa/automations/create.ts b/src/commands/asa/automations/create.ts index 3b59851..8d245ba 100644 --- a/src/commands/asa/automations/create.ts +++ b/src/commands/asa/automations/create.ts @@ -42,7 +42,7 @@ export default class AsaAutomationsCreate extends Command { const summary = flags['run-now'] ? 'Create automation rule and run it immediately' : 'Create automation rule' await confirmMutation(this, {body, method: 'POST', path: '/automations/', summary}, flags.yes) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const {replayed, result} = await asaWrite(client, 'post', '/automations', { body, idempotencyKey: flags['idempotency-key'], diff --git a/src/commands/asa/automations/get.ts b/src/commands/asa/automations/get.ts index 9b81ad1..303ee98 100644 --- a/src/commands/asa/automations/get.ts +++ b/src/commands/asa/automations/get.ts @@ -18,7 +18,7 @@ export default class AsaAutomationsGet extends Command { const {args} = await this.parse(AsaAutomationsGet) if (!isValidUuid(args.automation_id)) this.error('Invalid automation ID format.', {exit: 2}) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const result = await client.get(`/automations/${args.automation_id}`) printResponse(result as unknown as Record, this.log.bind(this)) diff --git a/src/commands/asa/automations/list.ts b/src/commands/asa/automations/list.ts index 98585f0..0f2b2bc 100644 --- a/src/commands/asa/automations/list.ts +++ b/src/commands/asa/automations/list.ts @@ -15,7 +15,7 @@ export default class AsaAutomationsList extends Command { async run(): Promise> { const {flags} = await this.parse(AsaAutomationsList) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const result = await client.get>('/automations', paginationParams(flags)) printList(result.data as unknown as Record[], this.log.bind(this), result.meta.pagination) diff --git a/src/commands/asa/automations/run.ts b/src/commands/asa/automations/run.ts index 243f1c2..364ab84 100644 --- a/src/commands/asa/automations/run.ts +++ b/src/commands/asa/automations/run.ts @@ -39,7 +39,7 @@ export default class AsaAutomationsRun extends Command { ) } - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const {replayed, result} = await asaWrite( client, 'post', diff --git a/src/commands/asa/automations/runs.ts b/src/commands/asa/automations/runs.ts index 9bcc9eb..cccc16b 100644 --- a/src/commands/asa/automations/runs.ts +++ b/src/commands/asa/automations/runs.ts @@ -21,7 +21,7 @@ export default class AsaAutomationsRuns extends Command { const {args, flags} = await this.parse(AsaAutomationsRuns) if (!isValidUuid(args.automation_id)) this.error('Invalid automation ID format.', {exit: 2}) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const result = await client.get>( `/automations/${args.automation_id}/runs`, paginationParams(flags), diff --git a/src/commands/asa/automations/update.ts b/src/commands/asa/automations/update.ts index 7f7a88e..55f8a8c 100644 --- a/src/commands/asa/automations/update.ts +++ b/src/commands/asa/automations/update.ts @@ -55,7 +55,7 @@ export default class AsaAutomationsUpdate extends Command { this.error('Remove internal_id from the file: the rule ID comes from the command line.', {exit: 2}) } - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) if (actionFlags) await this.rebuildAction(client, args.automation_id, body, flags) await confirmMutation( diff --git a/src/commands/asa/campaigns/bulk-create.ts b/src/commands/asa/campaigns/bulk-create.ts index af33a4a..cde3092 100644 --- a/src/commands/asa/campaigns/bulk-create.ts +++ b/src/commands/asa/campaigns/bulk-create.ts @@ -62,7 +62,7 @@ export default class AsaCampaignsBulkCreate extends Command { async run(): Promise> { const {flags} = await this.parse(AsaCampaignsBulkCreate) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const body = flags['from-file'] ? await this.convertTemplate(client, flags) : await this.readStructure(flags.file!) if (flags.preview) { diff --git a/src/commands/asa/campaigns/bulk-list.ts b/src/commands/asa/campaigns/bulk-list.ts index c58637b..131a444 100644 --- a/src/commands/asa/campaigns/bulk-list.ts +++ b/src/commands/asa/campaigns/bulk-list.ts @@ -40,7 +40,7 @@ export default class AsaCampaignsBulkList extends Command { async run(): Promise { const {flags} = await this.parse(AsaCampaignsBulkList) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const result = await client.get('/bulk-operations', { ...paginationParams(flags), app_id: flags.app, diff --git a/src/commands/asa/campaigns/bulk-status.ts b/src/commands/asa/campaigns/bulk-status.ts index bb158e9..42c5c0f 100644 --- a/src/commands/asa/campaigns/bulk-status.ts +++ b/src/commands/asa/campaigns/bulk-status.ts @@ -23,7 +23,7 @@ export default class AsaCampaignsBulkStatus extends Command { const operationId = args['operation-id'] if (!isValidUuid(operationId)) this.error('Invalid operation ID format.', {exit: 2}) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const state = await client.get(`/bulk-operations/${operationId}`, paginationParams(flags)) printResponse(state as unknown as Record, this.log.bind(this)) diff --git a/src/commands/asa/campaigns/create.ts b/src/commands/asa/campaigns/create.ts index d2544c9..3a41ce2 100644 --- a/src/commands/asa/campaigns/create.ts +++ b/src/commands/asa/campaigns/create.ts @@ -79,7 +79,7 @@ export default class AsaCampaignsCreate extends Command { } await confirmMutation(this, {body, method: 'POST', path: '/campaigns/', summary: `Create campaign ${flags.name}`}, flags.yes) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const {replayed, result} = await asaWrite(client, 'post', '/campaigns', { body, idempotencyKey: flags['idempotency-key'], diff --git a/src/commands/asa/campaigns/get.ts b/src/commands/asa/campaigns/get.ts index 839d461..d810746 100644 --- a/src/commands/asa/campaigns/get.ts +++ b/src/commands/asa/campaigns/get.ts @@ -18,7 +18,7 @@ export default class AsaCampaignsGet extends Command { const {args} = await this.parse(AsaCampaignsGet) if (!isValidUuid(args.campaign_id)) this.error('Invalid campaign ID format.', {exit: 2}) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const result = await client.get(`/campaigns/${args.campaign_id}`) printResponse(result as unknown as Record, this.log.bind(this)) diff --git a/src/commands/asa/campaigns/list.ts b/src/commands/asa/campaigns/list.ts index ae6f215..39a1da0 100644 --- a/src/commands/asa/campaigns/list.ts +++ b/src/commands/asa/campaigns/list.ts @@ -18,7 +18,7 @@ export default class AsaCampaignsList extends Command { async run(): Promise> { const {flags} = await this.parse(AsaCampaignsList) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const result = await client.get>('/campaigns', { ...paginationParams(flags), ...scopeParams(flags), diff --git a/src/commands/asa/campaigns/update.ts b/src/commands/asa/campaigns/update.ts index 505578c..b2f0f27 100644 --- a/src/commands/asa/campaigns/update.ts +++ b/src/commands/asa/campaigns/update.ts @@ -77,7 +77,7 @@ export default class AsaCampaignsUpdate extends Command { flags.yes, ) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const {replayed, result} = await asaWrite(client, 'put', `/campaigns/${args.campaign_id}`, { body, idempotencyKey: flags['idempotency-key'], diff --git a/src/commands/asa/competitors/summary.ts b/src/commands/asa/competitors/summary.ts index a8abfb6..baf07b7 100644 --- a/src/commands/asa/competitors/summary.ts +++ b/src/commands/asa/competitors/summary.ts @@ -33,7 +33,7 @@ export default class AsaCompetitorsSummary extends Command { this.error('App Store IDs are numbers, e.g. --app-ids 1668337467,6503873027.', {exit: 2}) } - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const {result} = await asaWrite(client, 'post', '/competitors/summary', { body: {app_ids: appIds.map(Number)}, }) diff --git a/src/commands/asa/connect.ts b/src/commands/asa/connect.ts index 4a693e1..5b3d568 100644 --- a/src/commands/asa/connect.ts +++ b/src/commands/asa/connect.ts @@ -18,7 +18,7 @@ export default class AsaConnect extends Command { async run(): Promise { const {flags} = await this.parse(AsaConnect) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const {auth_url: authUrl} = await client.get('/apple/oauth') this.log(`If the browser doesn't open, visit: ${authUrl}\n`) diff --git a/src/commands/asa/creatives/list.ts b/src/commands/asa/creatives/list.ts index ca2223a..c9c7364 100644 --- a/src/commands/asa/creatives/list.ts +++ b/src/commands/asa/creatives/list.ts @@ -18,7 +18,7 @@ export default class AsaCreativesList extends Command { async run(): Promise> { const {flags} = await this.parse(AsaCreativesList) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const result = await client.get>('/creatives', { ...paginationParams(flags), ...scopeParams(flags), diff --git a/src/commands/asa/keywords/add.ts b/src/commands/asa/keywords/add.ts index b2f1fca..8d4155a 100644 --- a/src/commands/asa/keywords/add.ts +++ b/src/commands/asa/keywords/add.ts @@ -52,7 +52,7 @@ export default class AsaKeywordsAdd extends Command { flags.yes, ) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const {replayed, result} = await asaWrite(client, 'post', '/keywords', { body, idempotencyKey: flags['idempotency-key'], diff --git a/src/commands/asa/keywords/list.ts b/src/commands/asa/keywords/list.ts index 60cd832..7815e50 100644 --- a/src/commands/asa/keywords/list.ts +++ b/src/commands/asa/keywords/list.ts @@ -18,7 +18,7 @@ export default class AsaKeywordsList extends Command { async run(): Promise> { const {flags} = await this.parse(AsaKeywordsList) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const result = await client.get>('/keywords', { ...paginationParams(flags), ...scopeParams(flags), diff --git a/src/commands/asa/keywords/update.ts b/src/commands/asa/keywords/update.ts index bf01a57..dfeac1e 100644 --- a/src/commands/asa/keywords/update.ts +++ b/src/commands/asa/keywords/update.ts @@ -56,7 +56,7 @@ export default class AsaKeywordsUpdate extends Command { flags.yes, ) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const {replayed, result} = await asaWrite(client, 'put', '/keywords', { body, idempotencyKey: flags['idempotency-key'], diff --git a/src/commands/asa/metrics/index.ts b/src/commands/asa/metrics/index.ts index f6fca9f..068c37f 100644 --- a/src/commands/asa/metrics/index.ts +++ b/src/commands/asa/metrics/index.ts @@ -1,5 +1,7 @@ import {Command, Flags} from '@oclif/core' +import type {AsaMetricsResponse} from '../../../lib/asa-schemas.js' + import {asaWrite, createAsaClient} from '../../../lib/asa-client.js' import { ASA_GROUP_BY_DIMENSIONS, @@ -10,7 +12,7 @@ import { metricsScopeBody, metricsScopeFlags, } from '../../../lib/asa-flags.js' -import {type PaginatedResponse, paginationParams} from '../../../lib/flags.js' +import {paginationParams} from '../../../lib/flags.js' import {printList} from '../../../lib/output.js' export default class AsaMetrics extends Command { @@ -20,10 +22,16 @@ One row per entity, already aggregated server-side and sorted by --order-by, so call with --order-by and --page-size N — never sum pages yourself. Account-level totals are one call to asa metrics overview instead. The date window is capped by the finest --group-by period: 28 days when day is grouped, 90 with no period grouping, 180 by week, 365 by month and coarser — widen the window by -coarsening the grouping, not by splitting into more calls. Each page is also capped at 5000 breakdown -rows (entities × countries × periods); over it the call fails with 422 cli_response_too_large — coarsen -the grouping, narrow the window, or reduce page[size]. Budget: 5 metrics calls per minute, at most -2 per 10 seconds, one at a time. +coarsening the grouping, not by splitting into more calls. Each page is also capped at a breakdown-row +count (entities × countries × periods); over it the call fails with 422 cli_response_too_large naming the +page[size] that fits — coarsen the grouping, narrow the window, or reduce page[size]. + +Money columns are in the campaign group currency, not USD, and spend and local_spend carry the same +figure; the currency itself comes from asa orgs list. Rate budgets and the breakdown-row cap are set per +company: read the current ones with asa whoami rather than assuming a default. meta.pagination.count is the full entity count behind the filters, so take +inventory from there instead of counting the rows on a page. meta.max_valid_day says how many days the +youngest cohort in the window has lived — any --by-days window above it repeats the last real figure +because that time has not passed yet, so treat those as unreached rather than as a plateau. --metric is required and every metric named is computed over the whole entity set, so ask for the columns you actually read. subscribers and paid_subscribers (and arppu / arpas, which derive from them) @@ -66,15 +74,15 @@ to make any call fast, since cost follows the number of entities aggregated, not }), } - async run(): Promise>> { + async run(): Promise { const {flags} = await this.parse(AsaMetrics) if (flags['by-days'] && flags['by-days'].length > MAX_BY_DAYS) { this.error(`At most ${MAX_BY_DAYS} renewal windows per call, got ${flags['by-days'].length}.`, {exit: 2}) } - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) - const {result} = await asaWrite>>(client, 'post', '/metrics', { + const {result} = await asaWrite(client, 'post', '/metrics', { body: { date_from: flags['date-from'], date_to: flags['date-to'], @@ -92,6 +100,14 @@ to make any call fast, since cost follows the number of entities aggregated, not printList(result.data as unknown as Record[], this.log.bind(this), result.meta?.pagination) + const horizon = result.meta?.max_valid_day + if (horizon !== undefined && flags['by-days']?.some((day) => day > horizon)) { + this.log( + `\nCohort windows past day ${horizon} have not been reached by this date range yet; they repeat the last ` + + `figure actually observed rather than projecting it.`, + ) + } + return result } } diff --git a/src/commands/asa/metrics/overview.ts b/src/commands/asa/metrics/overview.ts index eb13694..b3c6e3c 100644 --- a/src/commands/asa/metrics/overview.ts +++ b/src/commands/asa/metrics/overview.ts @@ -39,7 +39,7 @@ the 5-per-minute metrics budget with asa metrics.` this.error(`At most ${MAX_BY_DAYS} renewal windows per call, got ${flags['by-days'].length}.`, {exit: 2}) } - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const {result} = await asaWrite>(client, 'post', '/metrics/overview', { body: { date_from: flags['date-from'], diff --git a/src/commands/asa/negative-keywords/add.ts b/src/commands/asa/negative-keywords/add.ts index 2aa3098..f6e1b86 100644 --- a/src/commands/asa/negative-keywords/add.ts +++ b/src/commands/asa/negative-keywords/add.ts @@ -64,7 +64,7 @@ export default class AsaNegativeKeywordsAdd extends Command { flags.yes, ) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const {replayed, result} = await asaWrite(client, 'post', '/negative-keywords', { body, idempotencyKey: flags['idempotency-key'], diff --git a/src/commands/asa/negative-keywords/list.ts b/src/commands/asa/negative-keywords/list.ts index 3be9d60..263f7b2 100644 --- a/src/commands/asa/negative-keywords/list.ts +++ b/src/commands/asa/negative-keywords/list.ts @@ -22,7 +22,7 @@ export default class AsaNegativeKeywordsList extends Command { async run(): Promise> { const {flags} = await this.parse(AsaNegativeKeywordsList) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const result = await client.get>('/negative-keywords', { ...paginationParams(flags), ...scopeParams(flags), diff --git a/src/commands/asa/orgs/list.ts b/src/commands/asa/orgs/list.ts index 41dc6d5..04548ae 100644 --- a/src/commands/asa/orgs/list.ts +++ b/src/commands/asa/orgs/list.ts @@ -15,7 +15,7 @@ export default class AsaOrgsList extends Command { async run(): Promise> { const {flags} = await this.parse(AsaOrgsList) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const result = await client.get>('/campaign-groups', paginationParams(flags)) printList(result.data as unknown as Record[], this.log.bind(this), result.meta.pagination) diff --git a/src/commands/asa/product-pages/list.ts b/src/commands/asa/product-pages/list.ts index 79dac5f..28efa1b 100644 --- a/src/commands/asa/product-pages/list.ts +++ b/src/commands/asa/product-pages/list.ts @@ -18,7 +18,7 @@ export default class AsaProductPagesList extends Command { async run(): Promise> { const {flags} = await this.parse(AsaProductPagesList) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const result = await client.get>('/product-pages', { ...paginationParams(flags), ...scopeParams(flags), diff --git a/src/commands/asa/product-pages/sync.ts b/src/commands/asa/product-pages/sync.ts index 7b2a3c0..d29efbb 100644 --- a/src/commands/asa/product-pages/sync.ts +++ b/src/commands/asa/product-pages/sync.ts @@ -27,7 +27,7 @@ export default class AsaProductPagesSync extends Command { flags.yes, ) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const {result} = await asaWrite(client, 'post', '/product-pages/sync', { body, idempotencyKey: flags['idempotency-key'], diff --git a/src/commands/asa/search-terms/list.ts b/src/commands/asa/search-terms/list.ts index 6eee8fa..acf0ad4 100644 --- a/src/commands/asa/search-terms/list.ts +++ b/src/commands/asa/search-terms/list.ts @@ -18,7 +18,7 @@ export default class AsaSearchTermsList extends Command { async run(): Promise> { const {flags} = await this.parse(AsaSearchTermsList) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const result = await client.get>('/search-terms', { ...paginationParams(flags), ...periodParams(flags), diff --git a/src/commands/asa/whoami.ts b/src/commands/asa/whoami.ts index ca0ee83..d5f2e84 100644 --- a/src/commands/asa/whoami.ts +++ b/src/commands/asa/whoami.ts @@ -6,13 +6,18 @@ import {createAsaClient} from '../../lib/asa-client.js' import {printResponse} from '../../lib/output.js' export default class AsaWhoami extends Command { - static description = 'Show which company the token unlocks and whether Apple Ads is connected' + static description = `Show which company the token unlocks, whether Apple Ads is connected, and the request budgets it gets + +Limits are raised per company, so read them here rather than assuming the defaults: metrics_limit_per_minute +and metrics_burst_limit govern asa metrics, keywords_read_limit_per_minute the keyword lists, +metrics_inflight_limit how many analytics calls may overlap, and max_breakdown_rows_per_page the size a +grouped page may project to before it is refused.` static enableJsonFlag = true static examples = ['<%= config.bin %> asa whoami'] async run(): Promise { await this.parse(AsaWhoami) - const client = await createAsaClient(this.config) + const client = await createAsaClient(this) const result = await client.get('/me') printResponse(result as unknown as Record, this.log.bind(this)) diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index f7c8af9..5d39cb3 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -1,4 +1,4 @@ -import {ApiError, type ApiErrorFormat, NetworkError, parseApiError} from './errors.js' +import {ApiError, type ApiErrorFormat, type ApiErrorOptions, NetworkError, parseApiError} from './errors.js' const DEFAULT_API_URL = 'https://api-admin.adapty.io/api/v1/developer' const MAX_RETRY_AFTER_SECONDS = 60 @@ -18,14 +18,23 @@ export interface ApiClientOptions { baseUrl?: string defaultBaseUrl?: string errorFormat?: ApiErrorFormat + quiet?: boolean token?: null | string urlEnvVar?: string userAgent?: string } +interface RetryState { + network: boolean + rateLimit: boolean +} + +const NO_RETRIES: RetryState = {network: false, rateLimit: false} + export class ApiClient { private baseUrl: string private errorFormat: ApiErrorFormat + private quiet: boolean private token: null | string private userAgent: string @@ -38,6 +47,7 @@ export class ApiClient { } this.errorFormat = opts.errorFormat ?? 'developer' + this.quiet = opts.quiet ?? false this.token = opts.token ?? null this.userAgent = opts.userAgent ?? 'adapty-cli' } @@ -72,6 +82,20 @@ export class ApiClient { ) } + // eslint-disable-next-line no-undef + private buildHeaders(init: RequestInit, opts: RequestOptions): Record { + const headers: Record = {'User-Agent': this.userAgent} + if (init.body && !(init.body instanceof FormData)) { + headers['Content-Type'] = 'application/json' + } + + if (this.token) { + headers.Authorization = `Bearer ${this.token}` + } + + return {...headers, ...opts.headers} + } + private buildUrl(path: string, params?: QueryParams): string { const url = `${this.baseUrl}${ensureTrailingSlash(path)}` if (!params) return url @@ -95,27 +119,39 @@ export class ApiClient { ) } - // eslint-disable-next-line no-undef - private async request(url: string, init: RequestInit, opts: RequestOptions = {}, retried = false): Promise { - const headers: Record = { - 'User-Agent': this.userAgent, - } - - if (init.body && !(init.body instanceof FormData)) { - headers['Content-Type'] = 'application/json' - } + private async readBody(response: Response, errorOptions: ApiErrorOptions): Promise { + try { + return await response.json() + } catch { + if (!response.ok) { + throw new ApiError(response.status, `http_${response.status}`, {}, errorOptions) + } - if (this.token) { - headers.Authorization = `Bearer ${this.token}` + throw new ApiError( + response.status, + 'malformed_response', + {}, + { + ...errorOptions, + detail: + `The server answered ${response.status} with a body that is not JSON, so the response was cut short ` + + 'rather than refused. Nothing was read; retry the request.', + }, + ) } + } - Object.assign(headers, opts.headers) + // eslint-disable-next-line no-undef + private async request(url: string, init: RequestInit, opts: RequestOptions = {}, retried = NO_RETRIES): Promise { + const headers = this.buildHeaders(init, opts) let response: Response try { response = await fetch(url, {...init, headers}) } catch (error) { - throw new NetworkError(error instanceof Error ? error.message : 'Connection failed') + const failure = new NetworkError(error instanceof Error ? error.message : 'Connection failed') + if (retried.network || init.method !== 'GET') throw failure + return this.request(url, init, opts, {...retried, network: true}) } opts.onResponse?.(response.headers) @@ -127,16 +163,7 @@ export class ApiClient { const retryAfter = Number.parseInt(response.headers.get('Retry-After') ?? '', 10) const errorOptions = this.errorFormat === 'asa' && !Number.isNaN(retryAfter) ? {retryAfterSeconds: retryAfter} : {} - let body: unknown - try { - body = await response.json() - } catch { - if (!response.ok) { - throw new ApiError(response.status, `http_${response.status}`, {}, errorOptions) - } - - return undefined as T - } + const body = await this.readBody(response, errorOptions) if (!response.ok) { const error = parseApiError(response.status, body, errorOptions, this.errorFormat) @@ -144,13 +171,18 @@ export class ApiClient { error.message = 'Token expired or invalid. Run `adapty auth login`.' } - if (!retried && this.isRetryableRateLimit(error)) { + if (!retried.rateLimit && this.isRetryableRateLimit(error)) { const seconds = error.retryAfterSeconds ?? 0 - process.stderr.write(`Rate limited (${error.errorCode}); waiting ${seconds}s per Retry-After, then retrying once.\n`) + if (!this.quiet) { + process.stderr.write( + `Rate limited (${error.errorCode}); waiting ${seconds}s per Retry-After, then retrying once.\n`, + ) + } + await new Promise((resolve) => { setTimeout(resolve, seconds * 1000) }) - return this.request(url, init, opts, true) + return this.request(url, init, opts, {...retried, rateLimit: true}) } throw error diff --git a/src/lib/asa-client.ts b/src/lib/asa-client.ts index 0051930..ccdae61 100644 --- a/src/lib/asa-client.ts +++ b/src/lib/asa-client.ts @@ -10,13 +10,20 @@ import {AuthRequiredError, NetworkError} from './errors.js' export const ASA_API_URL = 'https://api-asa-admin.adapty.io/api/v1/cli' export const ASA_API_URL_ENV_VAR = 'ADAPTY_ASA_API_URL' -export async function createAsaClient(config: Config): Promise { +export interface AsaCommandContext { + config: Config + jsonEnabled(): boolean +} + +export async function createAsaClient(command: AsaCommandContext): Promise { + const {config} = command const token = await resolveToken(config.configDir) if (!token) throw new AuthRequiredError() return new ApiClient({ defaultBaseUrl: ASA_API_URL, errorFormat: 'asa', + quiet: command.jsonEnabled(), token, urlEnvVar: ASA_API_URL_ENV_VAR, userAgent: buildUserAgent(config), diff --git a/src/lib/asa-schemas.ts b/src/lib/asa-schemas.ts index 4b3e5b1..999a8ec 100644 --- a/src/lib/asa-schemas.ts +++ b/src/lib/asa-schemas.ts @@ -39,10 +39,28 @@ export interface AsaMetricsDTO { view_redownloads: number } +export interface AsaMetricsResponse { + data: Record[] + meta: { + max_valid_day?: number + pagination: {count: number; page: number; pages: number} + } +} + +export interface AsaEffectiveLimitsDTO { + keywords_read_limit_per_minute: number + max_breakdown_rows_per_page: number + metrics_burst_limit: number + metrics_inflight_limit: number + metrics_limit_per_minute: number + read_limit_per_minute: number +} + export interface AsaMeDTO { access_source: AsaAccessSource apple_credentials_status: AsaAppleCredentialsStatus company_id: string + limits?: AsaEffectiveLimitsDTO } export interface AsaAppleOAuthDTO { diff --git a/test/commands/asa-reads.test.ts b/test/commands/asa-reads.test.ts index fbc93ae..17c673f 100644 --- a/test/commands/asa-reads.test.ts +++ b/test/commands/asa-reads.test.ts @@ -227,6 +227,51 @@ describe('asa reads', () => { expect(fetchStub.callCount).to.equal(1) }) + it('keeps the retry notice out of the stream when --json is asked for', async () => { + fetchStub = sinon.stub(globalThis, 'fetch') + fetchStub.onFirstCall().resolves( + new Response(JSON.stringify({errors: [{error_code: 'cli_rate_limit_exceeded', message: 'slow down'}]}), { + headers: {'Content-Type': 'application/json', 'Retry-After': '0'}, + status: 429, + }), + ) + fetchStub.onSecondCall().resolves( + new Response(JSON.stringify(EMPTY_LIST_RESPONSE), {headers: {'Content-Type': 'application/json'}, status: 200}), + ) + const {error, stderr, stdout} = await runCommand('asa campaigns list --json') + expect(error).to.equal(undefined) + expect(stderr).to.not.contain('Rate limited') + expect(fetchStub.callCount).to.equal(2) + expect(() => JSON.parse(stdout)).to.not.throw() + }) + + it('names a truncated 200 instead of failing on an undefined body', async () => { + fetchStub = sinon + .stub(globalThis, 'fetch') + .resolves(new Response('', {headers: {'Content-Type': 'application/json'}, status: 200})) + const {error} = await runCommand('asa campaigns list') + expect(error?.message).to.contain('not JSON') + expect(error?.message).to.not.contain('undefined') + }) + + it('retries a dropped read once, and only for reads', async () => { + fetchStub = sinon.stub(globalThis, 'fetch') + fetchStub.onFirstCall().rejects(new TypeError('fetch failed')) + fetchStub.onSecondCall().resolves( + new Response(JSON.stringify(EMPTY_LIST_RESPONSE), {headers: {'Content-Type': 'application/json'}, status: 200}), + ) + const {error} = await runCommand('asa campaigns list') + expect(error).to.equal(undefined) + expect(fetchStub.callCount).to.equal(2) + }) + + it('gives up when a read keeps dropping', async () => { + fetchStub = sinon.stub(globalThis, 'fetch').rejects(new TypeError('fetch failed')) + const {error} = await runCommand('asa campaigns list') + expect(error?.message).to.contain('fetch failed') + expect(fetchStub.callCount).to.equal(2) + }) + it('accepts big pages up to the server cap and refuses above it', async () => { fetchStub = mockFetch([EMPTY_LIST_RESPONSE]) await runCommand('asa campaigns list --page-size 1000') diff --git a/test/commands/asa-writes.test.ts b/test/commands/asa-writes.test.ts index 64c4370..d5fca94 100644 --- a/test/commands/asa-writes.test.ts +++ b/test/commands/asa-writes.test.ts @@ -470,6 +470,22 @@ describe('asa writes', () => { expect(fetchStub.callCount).to.equal(2) }) + it('metrics flags the renewal windows the date range has not reached yet', async () => { + fetchStub = mockFetch([{data: [], meta: {max_valid_day: 39, pagination: {count: 0, page: 1, pages: 1}}}]) + const {stdout} = await runCommand( + 'asa metrics --entity campaign --date-from 2026-08-01 --date-to 2026-08-31 --metric roas --by-days 30 --by-days 90', + ) + expect(stdout).to.contain('past day 39') + }) + + it('metrics stays quiet when every requested window has been reached', async () => { + fetchStub = mockFetch([{data: [], meta: {max_valid_day: 200, pagination: {count: 0, page: 1, pages: 1}}}]) + const {stdout} = await runCommand( + 'asa metrics --entity campaign --date-from 2026-01-01 --date-to 2026-01-31 --metric roas --by-days 30 --by-days 90', + ) + expect(stdout).to.not.contain('past day') + }) + it('metrics overview caps the renewal windows client-side', async () => { fetchStub = mockFetch([{}]) const byDays = Array.from({length: 17}, (_, index) => `--by-days ${index}`).join(' ') diff --git a/test/commands/asa.test.ts b/test/commands/asa.test.ts index 50a96fb..e296ae9 100644 --- a/test/commands/asa.test.ts +++ b/test/commands/asa.test.ts @@ -46,6 +46,25 @@ describe('asa', () => { expect(stdout).to.contain('402') }) + it('whoami prints the budgets this company actually gets', async () => { + fetchStub = mockFetch([ + { + ...ME_RESPONSE, + limits: { + keywords_read_limit_per_minute: 90, + max_breakdown_rows_per_page: 10_000, + metrics_burst_limit: 5, + metrics_inflight_limit: 1, + metrics_limit_per_minute: 20, + read_limit_per_minute: 120, + }, + }, + ]) + const {stdout} = await runCommand('asa whoami') + expect(stdout).to.contain('Metrics Limit Per Minute: 20') + expect(stdout).to.contain('Max Breakdown Rows Per Page: 10000') + }) + it('apps list calls GET /apps with pagination', async () => { fetchStub = mockFetch([EMPTY_LIST_RESPONSE]) await runCommand('asa apps list --page 2 --page-size 50') From bd4ea88f3df60714cb86f70356b12c9c89a84d90 Mon Sep 17 00:00:00 2001 From: Mikhail Fisher Date: Wed, 9 Sep 2026 14:59:07 +0200 Subject: [PATCH 2/3] fix: retry a 503 with Retry-After and bump version --- docs/agent/asa-metrics.md | 6 ++++ package.json | 2 +- .../references/asa-agent-playbook.md | 7 +++-- src/lib/api-client.ts | 17 ++++++----- test/commands/asa-reads.test.ts | 29 +++++++++++++++++++ 5 files changed, 51 insertions(+), 10 deletions(-) diff --git a/docs/agent/asa-metrics.md b/docs/agent/asa-metrics.md index 3f6af33..8a0c358 100644 --- a/docs/agent/asa-metrics.md +++ b/docs/agent/asa-metrics.md @@ -179,6 +179,12 @@ Three 429 codes, not one: - `cli_rate_limit_exceeded` — the per-minute window for that pair is full. - `cli_cooldown_active` — stop entirely; tell the user when to retry. +A fourth refusal is a 503, not a 429: `cli_upstream_unavailable` means the Adapty API that +identifies the company is temporarily unreachable. Nothing ran, the token is fine, and no +cool-down strike is recorded. It carries a `Retry-After` and the CLI waits it out once, the +same as a 429 — so if it reaches you, the outage outlasted the retry. Say the dependency is +down rather than blaming the command. + One refusal is a 422, not a 429: `cli_response_too_large` — a `metrics` page would exceed the company's `max_breakdown_rows_per_page` (see [Date window caps](#date-window-caps)). It carries no `Retry-After` and doesn't count toward the cool-down; retrying is pointless — diff --git a/package.json b/package.json index 3ed2d70..37ff8fa 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "adapty", "description": "Adapty command line interface", - "version": "0.8.3", + "version": "0.8.4", "author": "Adapty team ", "bin": { "adapty": "./bin/run.js" diff --git a/skills/adapty-cli/references/asa-agent-playbook.md b/skills/adapty-cli/references/asa-agent-playbook.md index 57268d5..6e4fcc6 100644 --- a/skills/adapty-cli/references/asa-agent-playbook.md +++ b/skills/adapty-cli/references/asa-agent-playbook.md @@ -72,8 +72,11 @@ session and pace against those instead of against this table. Every refusal is a `429` with the exact wait in `Retry-After`; `cli_analytics_busy` means another analytics query is still running (wait ~5s), `cli_rate_limit_exceeded` means the window is full, -`cli_cooldown_active` means stop entirely and tell the user when to retry. The CLI already waits out -and retries the first 429 of a command on its own — a surfaced 429 means the second attempt failed too. +`cli_cooldown_active` means stop entirely and tell the user when to retry. A `503 +cli_upstream_unavailable` is not the caller's doing at all — the Adapty API that identifies the company +is down, nothing ran, and no cool-down strike is recorded; report the dependency, not the command. The +CLI already waits out and retries the first 429 or 503 of a command on its own — a surfaced one means +the second attempt failed too. `cli_response_too_large` is the exception: a 422 (a `metrics` page over the company's `max_breakdown_rows_per_page`, which `whoami` reports) with no `Retry-After` and no cool-down strike — waiting fixes nothing, change the request instead; the error names the exact `page[size]` that fits for diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 5d39cb3..843cbf1 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -2,6 +2,8 @@ import {ApiError, type ApiErrorFormat, type ApiErrorOptions, NetworkError, parse const DEFAULT_API_URL = 'https://api-admin.adapty.io/api/v1/developer' const MAX_RETRY_AFTER_SECONDS = 60 +// 429 is the caller going too fast; 503 is a dependency being down. Both name a wait and clear up on their own. +const RETRYABLE_STATUSES = new Set([429, 503]) function ensureTrailingSlash(path: string): string { return path.endsWith('/') ? path : `${path}/` @@ -26,10 +28,10 @@ export interface ApiClientOptions { interface RetryState { network: boolean - rateLimit: boolean + refused: boolean } -const NO_RETRIES: RetryState = {network: false, rateLimit: false} +const NO_RETRIES: RetryState = {network: false, refused: false} export class ApiClient { private baseUrl: string @@ -109,10 +111,10 @@ export class ApiClient { return search.size === 0 ? url : `${url}?${search.toString()}` } - private isRetryableRateLimit(error: ApiError): boolean { + private isRetryableRefusal(error: ApiError): boolean { return ( this.errorFormat === 'asa' && - error.statusCode === 429 && + RETRYABLE_STATUSES.has(error.statusCode) && error.errorCode !== 'cli_cooldown_active' && error.retryAfterSeconds !== undefined && error.retryAfterSeconds <= MAX_RETRY_AFTER_SECONDS @@ -171,18 +173,19 @@ export class ApiClient { error.message = 'Token expired or invalid. Run `adapty auth login`.' } - if (!retried.rateLimit && this.isRetryableRateLimit(error)) { + if (!retried.refused && this.isRetryableRefusal(error)) { const seconds = error.retryAfterSeconds ?? 0 if (!this.quiet) { + const reason = error.statusCode === 429 ? 'Rate limited' : 'Temporarily unavailable' process.stderr.write( - `Rate limited (${error.errorCode}); waiting ${seconds}s per Retry-After, then retrying once.\n`, + `${reason} (${error.errorCode}); waiting ${seconds}s per Retry-After, then retrying once.\n`, ) } await new Promise((resolve) => { setTimeout(resolve, seconds * 1000) }) - return this.request(url, init, opts, {...retried, rateLimit: true}) + return this.request(url, init, opts, {...retried, refused: true}) } throw error diff --git a/test/commands/asa-reads.test.ts b/test/commands/asa-reads.test.ts index 17c673f..bdaea88 100644 --- a/test/commands/asa-reads.test.ts +++ b/test/commands/asa-reads.test.ts @@ -245,6 +245,35 @@ describe('asa reads', () => { expect(() => JSON.parse(stdout)).to.not.throw() }) + it('waits out a 503 with a Retry-After, so an upstream outage is not the caller problem', async () => { + fetchStub = sinon.stub(globalThis, 'fetch') + fetchStub.onFirstCall().resolves( + new Response(JSON.stringify({errors: [{error_code: 'cli_upstream_unavailable', message: 'upstream down'}]}), { + headers: {'Content-Type': 'application/json', 'Retry-After': '0'}, + status: 503, + }), + ) + fetchStub.onSecondCall().resolves( + new Response(JSON.stringify(EMPTY_LIST_RESPONSE), {headers: {'Content-Type': 'application/json'}, status: 200}), + ) + const {error, stderr} = await runCommand('asa campaigns list') + expect(error).to.equal(undefined) + expect(stderr).to.contain('Temporarily unavailable') + expect(fetchStub.callCount).to.equal(2) + }) + + it('surfaces a 503 that carries no Retry-After instead of guessing a wait', async () => { + fetchStub = sinon.stub(globalThis, 'fetch').resolves( + new Response(JSON.stringify({errors: [{error_code: 'cli_upstream_unavailable', message: 'upstream down'}]}), { + headers: {'Content-Type': 'application/json'}, + status: 503, + }), + ) + const {error} = await runCommand('asa campaigns list') + expect(error?.message).to.contain('upstream down') + expect(fetchStub.callCount).to.equal(1) + }) + it('names a truncated 200 instead of failing on an undefined body', async () => { fetchStub = sinon .stub(globalThis, 'fetch') From 90b3072b01c0cea62764988e5021dcc2b94aa644 Mon Sep 17 00:00:00 2001 From: Mikhail Fisher Date: Wed, 9 Sep 2026 16:22:11 +0200 Subject: [PATCH 3/3] chore: bump version to 0.8.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 37ff8fa..a8108b6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "adapty", "description": "Adapty command line interface", - "version": "0.8.4", + "version": "0.8.5", "author": "Adapty team ", "bin": { "adapty": "./bin/run.js"