diff --git a/docs/agent/asa-metrics.md b/docs/agent/asa-metrics.md index 1299883..8a0c358 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,32 @@ 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. +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 -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/package.json b/package.json index f93620c..50e3264 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" diff --git a/skills/adapty-cli/references/asa-agent-playbook.md b/skills/adapty-cli/references/asa-agent-playbook.md index 4b96d2e..6e4fcc6 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 | @@ -68,11 +72,15 @@ an agent that ignores them gets 429s, then a token cool-down, and then it cannot 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_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_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 +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 32621c4..06d24a7 100644 --- a/src/commands/asa/ad-groups/create.ts +++ b/src/commands/asa/ad-groups/create.ts @@ -84,7 +84,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, diff --git a/src/commands/asa/ad-groups/get.ts b/src/commands/asa/ad-groups/get.ts index 2977cd7..8d1a005 100644 --- a/src/commands/asa/ad-groups/get.ts +++ b/src/commands/asa/ad-groups/get.ts @@ -22,7 +22,7 @@ export default class AsaAdGroupsGet extends Command { 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, this.log.bind(this)); diff --git a/src/commands/asa/ad-groups/list.ts b/src/commands/asa/ad-groups/list.ts index a78aebf..5d81072 100644 --- a/src/commands/asa/ad-groups/list.ts +++ b/src/commands/asa/ad-groups/list.ts @@ -20,7 +20,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), diff --git a/src/commands/asa/ad-groups/update.ts b/src/commands/asa/ad-groups/update.ts index 12df788..7da66fb 100644 --- a/src/commands/asa/ad-groups/update.ts +++ b/src/commands/asa/ad-groups/update.ts @@ -80,7 +80,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, diff --git a/src/commands/asa/ads/create.ts b/src/commands/asa/ads/create.ts index dfbb139..934032b 100644 --- a/src/commands/asa/ads/create.ts +++ b/src/commands/asa/ads/create.ts @@ -37,7 +37,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, diff --git a/src/commands/asa/ads/get.ts b/src/commands/asa/ads/get.ts index cef678f..1ca4864 100644 --- a/src/commands/asa/ads/get.ts +++ b/src/commands/asa/ads/get.ts @@ -22,7 +22,7 @@ export default class AsaAdsGet extends Command { 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, this.log.bind(this)); diff --git a/src/commands/asa/ads/list.ts b/src/commands/asa/ads/list.ts index 2f7cc37..0b4fb15 100644 --- a/src/commands/asa/ads/list.ts +++ b/src/commands/asa/ads/list.ts @@ -20,7 +20,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), diff --git a/src/commands/asa/ads/update.ts b/src/commands/asa/ads/update.ts index bab5d1c..a4f63de 100644 --- a/src/commands/asa/ads/update.ts +++ b/src/commands/asa/ads/update.ts @@ -46,7 +46,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, diff --git a/src/commands/asa/apps/list.ts b/src/commands/asa/apps/list.ts index 9a08f33..7c0c9af 100644 --- a/src/commands/asa/apps/list.ts +++ b/src/commands/asa/apps/list.ts @@ -16,7 +16,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, this.log.bind(this), result.meta.pagination); diff --git a/src/commands/asa/automations/create.ts b/src/commands/asa/automations/create.ts index 35e6192..a787d45 100644 --- a/src/commands/asa/automations/create.ts +++ b/src/commands/asa/automations/create.ts @@ -50,7 +50,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, diff --git a/src/commands/asa/automations/get.ts b/src/commands/asa/automations/get.ts index 05cec60..28866ec 100644 --- a/src/commands/asa/automations/get.ts +++ b/src/commands/asa/automations/get.ts @@ -22,7 +22,7 @@ export default class AsaAutomationsGet extends Command { 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, this.log.bind(this)); diff --git a/src/commands/asa/automations/list.ts b/src/commands/asa/automations/list.ts index a6b3490..a22ac03 100644 --- a/src/commands/asa/automations/list.ts +++ b/src/commands/asa/automations/list.ts @@ -16,7 +16,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, this.log.bind(this), result.meta.pagination); diff --git a/src/commands/asa/automations/run.ts b/src/commands/asa/automations/run.ts index fcb9063..e341616 100644 --- a/src/commands/asa/automations/run.ts +++ b/src/commands/asa/automations/run.ts @@ -44,7 +44,7 @@ export default class AsaAutomationsRun extends Command { ); } - const client = await createAsaClient(this.config); + const client = await createAsaClient(this); const { replayed, result } = await asaWrite( client, diff --git a/src/commands/asa/automations/runs.ts b/src/commands/asa/automations/runs.ts index 4373a7d..7f31d3e 100644 --- a/src/commands/asa/automations/runs.ts +++ b/src/commands/asa/automations/runs.ts @@ -25,7 +25,7 @@ export default class AsaAutomationsRuns extends Command { 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`, diff --git a/src/commands/asa/automations/update.ts b/src/commands/asa/automations/update.ts index a8f4f78..95e744c 100644 --- a/src/commands/asa/automations/update.ts +++ b/src/commands/asa/automations/update.ts @@ -73,7 +73,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); diff --git a/src/commands/asa/campaigns/bulk-create.ts b/src/commands/asa/campaigns/bulk-create.ts index c463437..83d9a0e 100644 --- a/src/commands/asa/campaigns/bulk-create.ts +++ b/src/commands/asa/campaigns/bulk-create.ts @@ -65,7 +65,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); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- FIXME if you see this const body = flags['from-file'] ? await this.convertTemplate(client, flags) : await this.readStructure(flags.file!); diff --git a/src/commands/asa/campaigns/bulk-list.ts b/src/commands/asa/campaigns/bulk-list.ts index 3024974..35be5dc 100644 --- a/src/commands/asa/campaigns/bulk-list.ts +++ b/src/commands/asa/campaigns/bulk-list.ts @@ -45,7 +45,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), diff --git a/src/commands/asa/campaigns/bulk-status.ts b/src/commands/asa/campaigns/bulk-status.ts index 587282e..a4f6f00 100644 --- a/src/commands/asa/campaigns/bulk-status.ts +++ b/src/commands/asa/campaigns/bulk-status.ts @@ -27,7 +27,7 @@ export default class AsaCampaignsBulkStatus extends Command { 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, this.log.bind(this)); diff --git a/src/commands/asa/campaigns/create.ts b/src/commands/asa/campaigns/create.ts index 7e9c26c..d3b69c7 100644 --- a/src/commands/asa/campaigns/create.ts +++ b/src/commands/asa/campaigns/create.ts @@ -86,7 +86,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, diff --git a/src/commands/asa/campaigns/get.ts b/src/commands/asa/campaigns/get.ts index 7b4e2a3..9d494db 100644 --- a/src/commands/asa/campaigns/get.ts +++ b/src/commands/asa/campaigns/get.ts @@ -22,7 +22,7 @@ export default class AsaCampaignsGet extends Command { 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, this.log.bind(this)); diff --git a/src/commands/asa/campaigns/list.ts b/src/commands/asa/campaigns/list.ts index 64be480..609ce3b 100644 --- a/src/commands/asa/campaigns/list.ts +++ b/src/commands/asa/campaigns/list.ts @@ -20,7 +20,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), diff --git a/src/commands/asa/campaigns/update.ts b/src/commands/asa/campaigns/update.ts index bb825e3..17dabb9 100644 --- a/src/commands/asa/campaigns/update.ts +++ b/src/commands/asa/campaigns/update.ts @@ -108,7 +108,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, diff --git a/src/commands/asa/competitors/summary.ts b/src/commands/asa/competitors/summary.ts index 891332c..37e5bca 100644 --- a/src/commands/asa/competitors/summary.ts +++ b/src/commands/asa/competitors/summary.ts @@ -36,7 +36,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 6246843..c2aca33 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 5fa2e11..d302e4a 100644 --- a/src/commands/asa/creatives/list.ts +++ b/src/commands/asa/creatives/list.ts @@ -20,7 +20,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), diff --git a/src/commands/asa/keywords/add.ts b/src/commands/asa/keywords/add.ts index 4e15e2b..a6be4b3 100644 --- a/src/commands/asa/keywords/add.ts +++ b/src/commands/asa/keywords/add.ts @@ -62,7 +62,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, diff --git a/src/commands/asa/keywords/list.ts b/src/commands/asa/keywords/list.ts index 6c5bf06..6e00b71 100644 --- a/src/commands/asa/keywords/list.ts +++ b/src/commands/asa/keywords/list.ts @@ -20,7 +20,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), diff --git a/src/commands/asa/keywords/update.ts b/src/commands/asa/keywords/update.ts index ad17e10..cdf4a33 100644 --- a/src/commands/asa/keywords/update.ts +++ b/src/commands/asa/keywords/update.ts @@ -75,7 +75,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, diff --git a/src/commands/asa/metrics/index.ts b/src/commands/asa/metrics/index.ts index d86dffe..82a46b6 100644 --- a/src/commands/asa/metrics/index.ts +++ b/src/commands/asa/metrics/index.ts @@ -13,7 +13,7 @@ import { import { paginationParams } from '../../../lib/flags.js'; import { printList } from '../../../lib/output.js'; -import type { PaginatedResponse } from '../../../lib/flags.js'; +import type { AsaMetricsResponse } from '../../../lib/asa-schemas.js'; export default class AsaMetrics extends Command { static override description = `Query metrics for any level of the account over a date range @@ -22,10 +22,17 @@ 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) @@ -70,16 +77,16 @@ 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'], @@ -98,6 +105,16 @@ to make any call fast, since cost follows the number of entities aggregated, not // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- FIXME if you see this printList(result.data, this.log.bind(this), result.meta?.pagination); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- FIXME if you see this + 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 0fa17f8..49c68d7 100644 --- a/src/commands/asa/metrics/overview.ts +++ b/src/commands/asa/metrics/overview.ts @@ -42,7 +42,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: { diff --git a/src/commands/asa/negative-keywords/add.ts b/src/commands/asa/negative-keywords/add.ts index cc1cda1..de9ee38 100644 --- a/src/commands/asa/negative-keywords/add.ts +++ b/src/commands/asa/negative-keywords/add.ts @@ -74,7 +74,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, diff --git a/src/commands/asa/negative-keywords/list.ts b/src/commands/asa/negative-keywords/list.ts index 4884d69..9be7ff6 100644 --- a/src/commands/asa/negative-keywords/list.ts +++ b/src/commands/asa/negative-keywords/list.ts @@ -24,7 +24,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), diff --git a/src/commands/asa/orgs/list.ts b/src/commands/asa/orgs/list.ts index 953d744..35eac62 100644 --- a/src/commands/asa/orgs/list.ts +++ b/src/commands/asa/orgs/list.ts @@ -16,7 +16,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, 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 ee0abe3..7c61ac2 100644 --- a/src/commands/asa/product-pages/list.ts +++ b/src/commands/asa/product-pages/list.ts @@ -20,7 +20,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), diff --git a/src/commands/asa/product-pages/sync.ts b/src/commands/asa/product-pages/sync.ts index 8babc36..7b9e7af 100644 --- a/src/commands/asa/product-pages/sync.ts +++ b/src/commands/asa/product-pages/sync.ts @@ -28,7 +28,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, diff --git a/src/commands/asa/search-terms/list.ts b/src/commands/asa/search-terms/list.ts index e4d9a45..27e958b 100644 --- a/src/commands/asa/search-terms/list.ts +++ b/src/commands/asa/search-terms/list.ts @@ -20,7 +20,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), diff --git a/src/commands/asa/whoami.ts b/src/commands/asa/whoami.ts index f35efc6..bcaedfe 100644 --- a/src/commands/asa/whoami.ts +++ b/src/commands/asa/whoami.ts @@ -6,13 +6,19 @@ import { printResponse } from '../../lib/output.js'; import type { AsaMeDTO } from '../../lib/asa-schemas.js'; export default class AsaWhoami extends Command { - static override description = 'Show which company the token unlocks and whether Apple Ads is connected'; + static override 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 override enableJsonFlag = true; static override 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, this.log.bind(this)); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 867faa9..aab817d 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -1,9 +1,11 @@ import { ApiError, NetworkError, parseApiError } from './errors.js'; -import type { ApiErrorFormat } from './errors.js'; +import type { ApiErrorFormat, ApiErrorOptions } from './errors.js'; 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}/`; @@ -20,14 +22,23 @@ export type ApiClientOptions = { baseUrl?: string; defaultBaseUrl?: string; errorFormat?: ApiErrorFormat; + quiet?: boolean; token?: null | string; urlEnvVar?: string; userAgent?: string; }; +type RetryState = { + network: boolean; + refused: boolean; +}; + +const NO_RETRIES: RetryState = { network: false, refused: false }; + export class ApiClient { private baseUrl: string; private errorFormat: ApiErrorFormat; + private quiet: boolean; private token: null | string; private userAgent: string; @@ -41,6 +52,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'; } @@ -75,6 +87,20 @@ export class ApiClient { ); } + 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)}`; @@ -97,37 +123,58 @@ 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 ); } - 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); + 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); @@ -139,17 +186,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); @@ -158,15 +195,19 @@ export class ApiClient { error.message = 'Token expired or invalid. Run `adapty auth login`.'; } - if (!retried && this.isRetryableRateLimit(error)) { + if (!retried.refused && this.isRetryableRefusal(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) { + const reason = error.statusCode === 429 ? 'Rate limited' : 'Temporarily unavailable'; + process.stderr.write(`${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, true); + return this.request(url, init, opts, { ...retried, refused: true }); } throw error; diff --git a/src/lib/asa-client.ts b/src/lib/asa-client.ts index 9256e30..e16644b 100644 --- a/src/lib/asa-client.ts +++ b/src/lib/asa-client.ts @@ -11,7 +11,13 @@ import type { Config } from '@oclif/core'; 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 type AsaCommandContext = { + config: Config; + jsonEnabled: () => boolean; +}; + +export async function createAsaClient(command: AsaCommandContext): Promise { + const { config } = command; const token = await resolveToken(config.configDir); if (!token) { @@ -21,6 +27,7 @@ export async function createAsaClient(config: Config): Promise { 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 0297f56..1f1495a 100644 --- a/src/lib/asa-schemas.ts +++ b/src/lib/asa-schemas.ts @@ -38,10 +38,28 @@ export type AsaMetricsDTO = { view_redownloads: number; }; +export type AsaMetricsResponse = { + data: Record[]; + meta: { + max_valid_day?: number; + pagination: { count: number; page: number; pages: number }; + }; +}; + +export type 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 type AsaMeDTO = { access_source: AsaAccessSource; apple_credentials_status: AsaAppleCredentialsStatus; company_id: string; + limits?: AsaEffectiveLimitsDTO; }; export type AsaAppleOAuthDTO = { diff --git a/test/commands/asa-reads.test.ts b/test/commands/asa-reads.test.ts index 8b748a3..fe6f418 100644 --- a/test/commands/asa-reads.test.ts +++ b/test/commands/asa-reads.test.ts @@ -241,6 +241,94 @@ 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('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') + .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 bde87a5..1e8431c 100644 --- a/test/commands/asa-writes.test.ts +++ b/test/commands/asa-writes.test.ts @@ -570,6 +570,28 @@ 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 b3eecba..e2db483 100644 --- a/test/commands/asa.test.ts +++ b/test/commands/asa.test.ts @@ -47,6 +47,26 @@ 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');