From fbf66660e04a583b5c63d4409ad515e11c1c9491 Mon Sep 17 00:00:00 2001 From: Aleksey Kulikov Date: Tue, 20 Oct 2020 15:47:57 +0200 Subject: [PATCH 1/9] start to work on a stable 1.1.0 release --- README.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3ce4913..b42b240 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # crux-api -> A [Chrome UX Report API](https://developers.google.com/web/tools/chrome-user-experience-report/api/reference) wrapper that handles errors and provides types. +> A [Chrome UX Report API](https://developers.google.com/web/tools/chrome-user-experience-report/api/reference) wrapper that handles errors, provides types, and supports batching. **Motivation**: [CrUX API](https://web.dev/chrome-ux-report-api/) is a fantastic tool to get RUM data without installing any script. While using the API in [Treo](https://treo.sh/), we discovered a few cases that require extra code: error responses, not found entries, API limits, URLs normalization, TypeScript notations. The `crux-api` library makes it easy to work with CrUX API by handling errors and providing TypeScript support. diff --git a/package.json b/package.json index b29ddbc..38f2f2b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "crux-api", - "version": "1.1.0-beta1", + "version": "1.1.0", "description": "A Chrome UX Report API wrapper that handles errors and provides types.", "repository": "https://github.com/treosh/crux-api", "bugs": "https://github.com/treosh/crux-api/issues", From 52407149ceb3e997ef4db5701733eac863b3867a Mon Sep 17 00:00:00 2001 From: Aleksey Kulikov Date: Tue, 20 Oct 2020 15:48:34 +0200 Subject: [PATCH 2/9] add batch-limits script to exhaust batch API limits --- .gitignore | 1 + script/batch-limits.js | 102 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 script/batch-limits.js diff --git a/.gitignore b/.gitignore index de4d1f0..8aef2b7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ dist +result node_modules diff --git a/script/batch-limits.js b/script/batch-limits.js new file mode 100644 index 0000000..e29e006 --- /dev/null +++ b/script/batch-limits.js @@ -0,0 +1,102 @@ +// A script to test batch API limits using Github's URLs & origins. +// Script generates 312 + 720 requests, which quicly exaust CrUX API limits for the `key`. +// +// Check the data using the node CLI: +// +// > o = require('./result/origins3.json') +// > o.length +// 312 +// > new Set(o.filter(v => v.error).map(v => v.error.code)) +// Set { 429, 404 } +// +// usage: CRUX_KEY='...' node -r esm script/batch-limits.js 1 + +import nodeFetch from 'node-fetch' +import { promises as fs } from 'fs' +import { join } from 'path' +import { createBatch } from '../batch/src' + +const key = process.env.CRUX_KEY || 'no-key' +const suffix = process.argv[2] || '' // the suffix for `result/${origins|urls}${suffix}.json` file +const origins = [ + 'https://github.com', + 'https://resources.github.com', + 'https://developer.github.com', + 'https://atom.io', + 'https://www.electronjs.org', + 'https://desktop.github.com', + 'https://partner.github.com', + 'https://docs.github.com', + 'https://www.githubstatus.com', + 'https://support.github.com', + 'https://github.myshopify.com', + 'https://socialimpact.github.com', + 'https://github.blog', +] +const urls = [ + 'https://github.com/', + 'https://github.com/team', + 'https://github.com/enterprise', + 'https://github.com/marketplace', + 'https://github.com/pricing', + 'https://github.com/explore', + 'https://github.com/login?return_to=%2Fexplore', + 'https://github.com/join?ref_cta=Sign+up&ref_loc=header+logged+out&ref_page=%2Fexplore&source=header', + 'https://github.com/features', + 'https://github.com/features/actions', + 'https://github.com/features/code-review/', + 'https://github.com/features/project-management/', + 'https://github.com/security', + 'https://github.com/customer-stories?type=enterprise', + 'https://github.com/about', + 'https://github.com/about/careers', + 'https://github.com/about/press', + 'https://resources.github.com/', + 'https://developer.github.com/', + 'https://atom.io/', + 'https://www.electronjs.org/', + 'https://desktop.github.com/', + 'https://partner.github.com/', + 'https://docs.github.com/', + 'https://www.githubstatus.com/', + 'https://support.github.com/', + 'https://github.myshopify.com/', + 'https://socialimpact.github.com/', + 'https://github.blog/', + 'https://github.blog/changelog/', +] +const formFactors = /** @type {import('../src').FormFactor[]} */ ([undefined, 'PHONE', 'DESKTOP', 'TABLET']) +const connections = /** @type {import('../src').Connection[]} */ ([undefined, '4G', '3G', '2G', 'slow-2G', 'offline']) + +async function main() { + const batch = createBatch({ key, fetch: nodeFetch }) + console.time('fetch origins') + const res1 = await batch( + origins.reduce((memo, origin) => { + for (const formFactor of formFactors) { + for (const effectiveConnectionType of connections) { + memo.push({ origin, formFactor, effectiveConnectionType }) + } + } + return memo + }, /** @type {import('../batch/src').BatchOptions} */ ([])) + ) + await fs.writeFile(join(__dirname, `../result/origins${suffix}.json`), JSON.stringify(res1, null, ' ')) + console.timeEnd('fetch origins') + + console.time('fetch urls') + const res2 = await batch( + urls.reduce((memo, url) => { + for (const formFactor of formFactors) { + for (const effectiveConnectionType of connections) { + memo.push({ url, formFactor, effectiveConnectionType }) + } + } + return memo + }, /** @type {import('../batch/src').BatchOptions} */ ([])) + ) + await fs.writeFile(join(__dirname, `../result/urls${suffix}.json`), JSON.stringify(res2, null, ' ')) + console.timeEnd('fetch urls') +} + +main().catch(console.error) From 638395c6d0ad7f8e69f1a5ba061aea14f3df7c1b Mon Sep 17 00:00:00 2001 From: Aleksey Kulikov Date: Thu, 22 Oct 2020 12:26:09 +0200 Subject: [PATCH 3/9] batch: add support for 404 responses --- batch/src/index.js | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/batch/src/index.js b/batch/src/index.js index 186b1d7..a48105a 100644 --- a/batch/src/index.js +++ b/batch/src/index.js @@ -1,6 +1,6 @@ /** @typedef {{ key: string, fetch?: function }} CreateBatchOptions */ /** @typedef {import('../../src').QueryRecordOptions[]} BatchOptions */ -/** @typedef {(import('../../src').SuccessResponse | import('../../src').ErrorResponse)[]} BatchResponse */ +/** @typedef {(import('../../src').SuccessResponse | null)[]} BatchResponse */ const boundary = 'BATCH_BOUNDARY' @@ -208,7 +208,7 @@ ${JSON.stringify(queryRecordOptions, null, ' ')} */ function parseBatchResponse(text) { - const res = /** @type {BatchResponse} */ ([]) + const results = /** @type {BatchResponse} */ ([]) let index = /** @type {number | null} */ (null) let contentBody = '' for (const line of text.split('\n')) { @@ -220,14 +220,23 @@ function parseBatchResponse(text) { contentBody += line } if (index && contentBody && line.startsWith('}')) { - res[ - index - 1 - ] = /** @type {import('../../src').SuccessResponse | import('../../src').ErrorResponse} */ (JSON.parse( - contentBody - )) + const json = JSON.parse(contentBody) + let res = /** @type {import('../../src').SuccessResponse | null} */ (json) + if (json && json.error) { + const { error } = /** @type {import('../../src').ErrorResponse} */ (json) + if (error.code === 404) { + res = null + } else if (error.code === 429) { + // TODO: collect failed results & restart + res = null + } else { + throw new Error(JSON.stringify(error)) + } + } + results[index - 1] = res index = null contentBody = '' } } - return res + return results } From 5af51245271365dbff3673ff6ade640980a800e1 Mon Sep 17 00:00:00 2001 From: Aleksey Kulikov Date: Thu, 22 Oct 2020 14:27:22 +0200 Subject: [PATCH 4/9] rebuild batch to handle 429 errors --- batch/src/index.js | 99 ++++++++++++++++++++++++++---------------- package.json | 2 +- script/batch-limits.js | 4 +- src/index.js | 24 ++++------ src/utils.js | 11 +++++ 5 files changed, 85 insertions(+), 55 deletions(-) create mode 100644 src/utils.js diff --git a/batch/src/index.js b/batch/src/index.js index a48105a..ee85b00 100644 --- a/batch/src/index.js +++ b/batch/src/index.js @@ -1,36 +1,71 @@ -/** @typedef {{ key: string, fetch?: function }} CreateBatchOptions */ -/** @typedef {import('../../src').QueryRecordOptions[]} BatchOptions */ -/** @typedef {(import('../../src').SuccessResponse | null)[]} BatchResponse */ +import { randomDelay } from '../../src/utils' +/** @typedef {{ options: import('../../src').QueryRecordOptions, result: import('../../src').SuccessResponse | null | undefined }[]} BatchValues */ const boundary = 'BATCH_BOUNDARY' /** * Create batch interface for CrUX API. * https://developers.google.com/web/tools/chrome-user-experience-report/api/guides/batch * - * @param {CreateBatchOptions} createOptions + * @param {import('../../src').CreateOptions} createOptions */ export function createBatch(createOptions) { const key = createOptions.key const fetch = createOptions.fetch || window.fetch + const maxRetries = createOptions.maxRetries || 10 + const maxRetryTimeout = createOptions.maxRetryTimeout || 60 * 1000 // 60s return batch /** - * @param {BatchOptions} batchOptions - * @return {Promise} + * @param {import('../../src').BatchOptions} batchOptions + * */ - async function batch(batchOptions) { - const body = generateBatchBody(batchOptions, key) - const res = await fetch('https://chromeuxreport.googleapis.com/batch/', { - method: 'POST', - headers: { 'Content-Type': `multipart/mixed; boundary=${boundary}` }, - body, - }) - const text = await res.text() - if (res.status !== 200) throw new Error(`Invalid batch response: ${text}`) - return parseBatchResponse(text) + function batch(batchOptions) { + const batchValues = /** @type {BatchValues} */ (batchOptions.map((options) => ({ options, result: undefined }))) + return batchRequest(1) + + /** + * @param {number} retryCounter + * @return {Promise} + */ + + async function batchRequest(retryCounter) { + const body = generateBatchBody(batchValues, key) + const res = await fetch('https://chromeuxreport.googleapis.com/batch/', { + method: 'POST', + headers: { 'Content-Type': `multipart/mixed; boundary=${boundary}` }, + body, + }) + const text = await res.text() + if (res.status !== 200) throw new Error(`Invalid batch response: ${text}`) + const results = parseBatchResponse(text) + results.forEach(({ index, json }) => { + if (!json) { + throw new Error('Empty result') + } else if (json.error) { + const { error } = /** @type {import('../../src').ErrorResponse} */ (json) + if (error.code === 404) { + batchValues[index].result = null + } else if (error.code !== 429) { + throw new Error(JSON.stringify(error)) + } + } else { + batchValues[index].result = json + } + }) + const hasRateLimitedRequests = batchValues.some(({ result }) => result === undefined) + if (hasRateLimitedRequests) { + if (retryCounter <= maxRetries) { + await randomDelay(maxRetryTimeout) + return batchRequest(retryCounter + 1) + } else { + throw new Error('Max retries reached') + } + } + return batchValues.map(({ result }) => /** @type {import('../../src').SuccessResponse | null} */ (result)) + } } } @@ -53,13 +88,15 @@ export function createBatch(createOptions) { * * --BATCH_BOUNDARY-- * - * @param {BatchOptions} batchOptions + * @param {BatchValues} batchValues * @param {string} key */ -function generateBatchBody(batchOptions, key) { - const strOpts = batchOptions.map((queryRecordOptions, index) => { - return `--${boundary} +function generateBatchBody(batchValues, key) { + let str = '' + batchValues.forEach(({ options, result }, index) => { + if (result !== undefined) return + str += `--${boundary} Content-Type: application/http Content-ID: ${index + 1} @@ -67,10 +104,11 @@ POST /v1/records:queryRecord?key=${key} Content-Type: application/json Accept: application/json -${JSON.stringify(queryRecordOptions, null, ' ')} +${JSON.stringify(options, null, ' ')} + ` }) - return strOpts.join('\n') + `\n--${boundary}--` + return `${str}\n--${boundary}--` } /** @@ -208,7 +246,7 @@ ${JSON.stringify(queryRecordOptions, null, ' ')} */ function parseBatchResponse(text) { - const results = /** @type {BatchResponse} */ ([]) + const results = /** @type {{ index: number, json: any }[]} */ ([]) let index = /** @type {number | null} */ (null) let contentBody = '' for (const line of text.split('\n')) { @@ -220,20 +258,7 @@ function parseBatchResponse(text) { contentBody += line } if (index && contentBody && line.startsWith('}')) { - const json = JSON.parse(contentBody) - let res = /** @type {import('../../src').SuccessResponse | null} */ (json) - if (json && json.error) { - const { error } = /** @type {import('../../src').ErrorResponse} */ (json) - if (error.code === 404) { - res = null - } else if (error.code === 429) { - // TODO: collect failed results & restart - res = null - } else { - throw new Error(JSON.stringify(error)) - } - } - results[index - 1] = res + results.push({ index: index - 1, json: JSON.parse(contentBody) }) index = null contentBody = '' } diff --git a/package.json b/package.json index 38f2f2b..f7621a1 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "path": "./src/index.js" }, { - "limit": "500B", + "limit": "800B", "path": "./batch/src/index.js" } ], diff --git a/script/batch-limits.js b/script/batch-limits.js index e29e006..0df9c45 100644 --- a/script/batch-limits.js +++ b/script/batch-limits.js @@ -79,7 +79,7 @@ async function main() { } } return memo - }, /** @type {import('../batch/src').BatchOptions} */ ([])) + }, /** @type {import('../src').BatchOptions} */ ([])) ) await fs.writeFile(join(__dirname, `../result/origins${suffix}.json`), JSON.stringify(res1, null, ' ')) console.timeEnd('fetch origins') @@ -93,7 +93,7 @@ async function main() { } } return memo - }, /** @type {import('../batch/src').BatchOptions} */ ([])) + }, /** @type {import('../src').BatchOptions} */ ([])) ) await fs.writeFile(join(__dirname, `../result/urls${suffix}.json`), JSON.stringify(res2, null, ' ')) console.timeEnd('fetch urls') diff --git a/src/index.js b/src/index.js index 6f4f3b5..77e6664 100644 --- a/src/index.js +++ b/src/index.js @@ -1,10 +1,16 @@ +import { randomDelay } from './utils' + /** - * @typedef {{ key: string, fetch?: function, maxRetries?: number, maxRetryTimeout?: number }} CreateQueryRecordOptions + * @typedef {{ key: string, fetch?: function, maxRetries?: number, maxRetryTimeout?: number }} CreateOptions * @typedef {{ url?: string, origin?: string, formFactor?: FormFactor, effectiveConnectionType?: Connection }} QueryRecordOptions + * @typedef {QueryRecordOptions[]} BatchOptions + * @typedef {(SuccessResponse | null)[]} BatchResponse + * * @typedef {'ALL_FORM_FACTORS' | 'PHONE' | 'DESKTOP' | 'TABLET'} FormFactor * @typedef {'4G' | '3G' | '2G' | 'slow-2G' | 'offline'} Connection * @typedef {{ histogram: { start: number | string, end: number | string, density: number }[], percentiles: { p75: number | string } }} MetricValue * @typedef {'first_contentful_paint' | 'largest_contentful_paint' | 'first_input_delay' | 'cumulative_layout_shift'} MetricName + * @typedef {{ error: { code: number, message: string, status: string } }} ErrorResponse * @typedef {{ * record: { * key: { @@ -25,14 +31,13 @@ * normalizedUrl: string * } * }} SuccessResponse - * @typedef {{ error: { code: number, message: string, status: string } }} ErrorResponse */ /** * Fetch CrUX API and handles 4xx errors. * Inspired by: https://github.com/GoogleChrome/CrUX/blob/master/js/crux-api-util.js * - * @param {CreateQueryRecordOptions} createOptions + * @param {CreateOptions} createOptions */ export function createQueryRecord(createOptions) { @@ -58,7 +63,7 @@ export function createQueryRecord(createOptions) { if (error.code === 404) return null if (error.code === 429) { if (retryCounter <= maxRetries) { - await new Promise((resolve) => setTimeout(resolve, random(maxRetryTimeout))) + await randomDelay(maxRetryTimeout) return queryRecord(queryOptions, retryCounter + 1) } else { throw new Error('Max retries reached') @@ -81,14 +86,3 @@ export function normalizeUrl(url) { const u = new URL(url) return u.origin + u.pathname } - -/** - * Random from 1 to `max`. - * Based on: https://stackoverflow.com/a/29246176 - * - * @param {number} max - */ - -function random(max) { - return Math.floor(Math.random() * max) + 1 -} diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..87b0d64 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,11 @@ +/** + * Random delay from 1ms to `max`. + * Random logic is based on: https://stackoverflow.com/a/29246176 + * + * @param {number} max + */ + +export function randomDelay(max) { + const timeout = Math.floor(Math.random() * max) + 1 + return new Promise((resolve) => setTimeout(resolve, timeout)) +} From 93b1a3d688df3f58b5860a608481987d95556022 Mon Sep 17 00:00:00 2001 From: Aleksey Kulikov Date: Thu, 22 Oct 2020 14:39:39 +0200 Subject: [PATCH 5/9] debug rate-limit errors --- batch/src/index.js | 7 ++++--- package.json | 2 +- src/index.js | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/batch/src/index.js b/batch/src/index.js index ee85b00..644381a 100644 --- a/batch/src/index.js +++ b/batch/src/index.js @@ -14,7 +14,7 @@ export function createBatch(createOptions) { const key = createOptions.key const fetch = createOptions.fetch || window.fetch const maxRetries = createOptions.maxRetries || 10 - const maxRetryTimeout = createOptions.maxRetryTimeout || 60 * 1000 // 60s + const maxRetryTimeout = createOptions.maxRetryTimeout || 100 * 1000 // 100s return batch /** @@ -55,8 +55,9 @@ export function createBatch(createOptions) { batchValues[index].result = json } }) - const hasRateLimitedRequests = batchValues.some(({ result }) => result === undefined) - if (hasRateLimitedRequests) { + const rateLimitedRequests = batchValues.filter(({ result }) => result === undefined) + if (rateLimitedRequests.length) { + console.log('Rate-limit #%s: %s/%s', retryCounter, rateLimitedRequests.length, results.length) if (retryCounter <= maxRetries) { await randomDelay(maxRetryTimeout) return batchRequest(retryCounter + 1) diff --git a/package.json b/package.json index f7621a1..539d377 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "path": "./src/index.js" }, { - "limit": "800B", + "limit": "850B", "path": "./batch/src/index.js" } ], diff --git a/src/index.js b/src/index.js index 77e6664..bb5f8eb 100644 --- a/src/index.js +++ b/src/index.js @@ -43,8 +43,8 @@ import { randomDelay } from './utils' export function createQueryRecord(createOptions) { const key = createOptions.key const fetch = createOptions.fetch || window.fetch - const maxRetries = createOptions.maxRetries || 5 - const maxRetryTimeout = createOptions.maxRetryTimeout || 60 * 1000 // 60s + const maxRetries = createOptions.maxRetries || 10 + const maxRetryTimeout = createOptions.maxRetryTimeout || 100 * 1000 // 100s return queryRecord /** From 22075e82f618c8d68a66f8275ab468c8408eae7f Mon Sep 17 00:00:00 2001 From: Aleksey Kulikov Date: Thu, 22 Oct 2020 15:26:56 +0200 Subject: [PATCH 6/9] improve docs --- README.md | 64 +++++++++++++++++++++++++++------------------------- package.json | 2 +- 2 files changed, 34 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index b42b240..8cbd13d 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,19 @@ # crux-api -> A [Chrome UX Report API](https://developers.google.com/web/tools/chrome-user-experience-report/api/reference) wrapper that handles errors, provides types, and supports batching. +> A [Chrome UX Report API](https://developers.google.com/web/tools/chrome-user-experience-report/api/reference) wrapper that supports batching, handles errors, and provides types. **Motivation**: [CrUX API](https://web.dev/chrome-ux-report-api/) is a fantastic tool to get RUM data without installing any script. -While using the API in [Treo](https://treo.sh/), we discovered a few cases that require extra code: error responses, not found entries, API limits, URLs normalization, TypeScript notations. The `crux-api` library makes it easy to work with CrUX API by handling errors and providing TypeScript support. +While using the API in [Treo](https://treo.sh/), we discovered a few complications like API errors and limits, not found entries, a complicated multipart response from the batch API, URLs normalization, TypeScript notations. The `crux-api` library makes it easy to work with CrUX API by supporting batch requests, handling errors, and providing TypeScript notations. **Features**: -- A tiny (450 byte) wrapper for [Chrome UX Report API](https://developers.google.com/web/tools/chrome-user-experience-report/api/reference); +- A tiny (450 bytes) wrapper for [Chrome UX Report API](https://developers.google.com/web/tools/chrome-user-experience-report/api/reference); - [Batch API support](https://developers.google.com/web/tools/chrome-user-experience-report/api/guides/batch) for up to 1000 records per one request; -- TypeScript support for [options and responses](https://developers.google.com/web/tools/chrome-user-experience-report/api/reference/rest/v1/records/queryRecord); +- TypeScript notations for [options and responses](https://developers.google.com/web/tools/chrome-user-experience-report/api/reference/rest/v1/records/queryRecord); +- Isomorphic: works in a browser and node.js; - Returns `null` for the `404 (CrUX data not found)` response; -- Handles the `429 (Quota exceeded)` response with automatic retries; +- Automatic retry when hits the API rate limits: `429 (Quota exceeded)`; - URL normalization helper to match the CrUX API index; -- Isomorphic: works in a browser and node.js; ## Usage @@ -33,6 +33,20 @@ const res1 = await queryRecord({ url: 'https://www.github.com/' }) // fetch all const res2 = await queryRecord({ url: 'https://www.github.com/explore', formFactor: 'DESKTOP' }) // fetch data for desktop devices ``` +Use the [CrUX Batch API](https://developers.google.com/web/tools/chrome-user-experience-report/api/guides/batch) to combine up to 1000 requests and get results in less than 1 second: + +```js +import { createBatch } from 'crux-api/batch' +const batch = createBatch({ key: CRUX_API_KEY }) + +const records = await batch([ + { url: 'https://github.com/', formFactor: 'MOBILE', effectiveConnectionType: '4G' }, + { url: 'https://github.com/marketplace', formFactor: 'DESKTOP' }, + { url: 'https://www.github.com/explore', formFactor: 'TABLET' }, + // ... up to 1000 records. +]) +``` + Fetch origin-level data in node.js using [`node-fetch`](https://www.npmjs.com/package/node-fetch): ```js @@ -81,31 +95,17 @@ Result is `null` or an `object` with [queryRecord response body](https://develop } ``` -Use the [CrUX Batch API](https://developers.google.com/web/tools/chrome-user-experience-report/api/guides/batch) to combine multiple requests into one: - -```js -import { createBatch } from 'crux-api/batch' -const batch = createBatch({ key: CRUX_API_KEY }) - -const records = await batch([ - { origin: 'https://github.com' } - { url: 'https://github.com/marketplace', formFactor: 'DESKTOP' }, - { url: 'https://github.com/', formFactor: 'MOBILE', effectiveConnectionType: '4G' }, - { url: 'https://www.github.com/explore', formFactor: 'TABLET' }, -]) -``` - ## API ### Single Record Request -#### createQueryRecord(createQueryOptions) +#### createQueryRecord(createOptions) Returns a `queryRecord` function. -- _createQueryOptions.key_ (**required**) - CrUX API key, use https://goo.gle/crux-api-key to generate a new key; -- _createQueryOptions.fetch_ (optional, default: `window.fetch`) - pass a [WHATWG fetch](https://github.com/whatwg/fetch) implementation for a non-browser environment; -- _createQueryOptions.maxRetries_ (optional, default: 5) and **options.maxRetryTimeout** (optional, default: 60000) - retry limit after `429` error and the maximum time to wait for a retry. +- _createOptions.key_ (**required**) - CrUX API key, use https://goo.gle/crux-api-key to generate a new key; +- _createOptions.fetch_ (optional, default: `window.fetch`) - pass a [WHATWG fetch](https://github.com/whatwg/fetch) implementation for a non-browser environment; +- _createOptions.maxRetries_ (optional, default: 10) and **options.maxRetryTimeout** (optional, default: 100000) - retry limit after `429` error and the maximum time to wait for a retry. #### queryRecord(queryOptions) @@ -132,20 +132,19 @@ const res = await queryRecord({ ### Batch Request -#### createBatch(createBatchOptions) +It's heavier (850 bytes) due to a complexity of constructing and parsing multipart requests. -Returns a `batch` function. +#### createBatch(createOptions) -- _createBatchOptions.key_ (**required**) - CrUX API key; -- _createBatchOptions.fetch_ (optional, default: `window.fetch`) - a [WHATWG fetch](https://github.com/whatwg/fetch) polyfill; +Accepts the same [`createOptions` as the `createQueryRecord`](https://github.com/treosh/crux-api/tree/batch-api-limits#createqueryrecordcreateoptions) and returns a `batch` function. #### batch(batchOptions) -An array of [queryRecord](#queryrecordqueryoptions) options. +Accepts an array of [`queryRecord`](#queryrecordqueryoptions) options and returns an array with an exact position for each record. If the record doesn't exist in CrUX index, the value set to null. If some requests hit rate-limit, `batch` will retry them after a short timeout. ```js -import nodeFetch from 'node-fetch' import { createBatch } from 'crux-api/batch' +import nodeFetch from 'node-fetch' const batch = createBatch({ key: process.env.CRUX_KEY, fetch: nodeFetch }) const res = await batch([ @@ -153,7 +152,10 @@ const res = await batch([ { url: 'https://github.com/', formFactor: 'DESKTOP' }, { origin: 'https://fooo.bar' }, ]) -console.log(JSON.stringify(res, null, ' ')) + +// res[0] -> origin-level data for https://example.com +// res[1] -> URL-level data for https://github.com/ on desktop devices +// res[2] -> null (invalid origin that not found in the CrUX index) ``` ### normalizeUrl(url) diff --git a/package.json b/package.json index 539d377..9b75347 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "crux-api", "version": "1.1.0", - "description": "A Chrome UX Report API wrapper that handles errors and provides types.", + "description": "A Chrome UX Report API wrapper wrapper that supports batching, handles errors, and provides types.", "repository": "https://github.com/treosh/crux-api", "bugs": "https://github.com/treosh/crux-api/issues", "license": "MIT", From b4366404526a164238ca090d85b0106c22d7d30b Mon Sep 17 00:00:00 2001 From: Aleksey Kulikov Date: Thu, 22 Oct 2020 15:39:05 +0200 Subject: [PATCH 7/9] remove maxRetries && maxRetryTimeout options --- README.md | 8 ++++---- batch/src/index.js | 15 ++++----------- src/index.js | 15 +++------------ src/retry.js | 20 ++++++++++++++++++++ src/utils.js | 11 ----------- 5 files changed, 31 insertions(+), 38 deletions(-) create mode 100644 src/retry.js delete mode 100644 src/utils.js diff --git a/README.md b/README.md index 8cbd13d..498905d 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,6 @@ Returns a `queryRecord` function. - _createOptions.key_ (**required**) - CrUX API key, use https://goo.gle/crux-api-key to generate a new key; - _createOptions.fetch_ (optional, default: `window.fetch`) - pass a [WHATWG fetch](https://github.com/whatwg/fetch) implementation for a non-browser environment; -- _createOptions.maxRetries_ (optional, default: 10) and **options.maxRetryTimeout** (optional, default: 100000) - retry limit after `429` error and the maximum time to wait for a retry. #### queryRecord(queryOptions) @@ -120,18 +119,19 @@ Returns a Promise with a raw [`queryRecord` response](https://developers.google. ```js import { createQueryRecord } from 'crux-api' -// disable retries, throw 429 error, similar to 400 and 404 -const queryRecord = createQueryRecord({ key: process.env.CRUX_API_KEY, maxRetries: 0 }) - +const queryRecord = createQueryRecord({ key: process.env.CRUX_API_KEY }) const res = await queryRecord({ url: 'https://github.com/marketplace?type=actions', formFactor: 'DESKTOP', effectiveConnectionType: '4G', }) + +// res -> URL-level data for https://github.com/marketplace ``` ### Batch Request +It uses a separate namespace, because it's powered by a different API. It's heavier (850 bytes) due to a complexity of constructing and parsing multipart requests. #### createBatch(createOptions) diff --git a/batch/src/index.js b/batch/src/index.js index 644381a..339daf2 100644 --- a/batch/src/index.js +++ b/batch/src/index.js @@ -1,20 +1,18 @@ -import { randomDelay } from '../../src/utils' - -/** @typedef {{ options: import('../../src').QueryRecordOptions, result: import('../../src').SuccessResponse | null | undefined }[]} BatchValues */ +import { retryAfterTimeout } from '../../src/retry' const boundary = 'BATCH_BOUNDARY' /** * Create batch interface for CrUX API. * https://developers.google.com/web/tools/chrome-user-experience-report/api/guides/batch * + * @typedef {{ options: import('../../src').QueryRecordOptions, result: import('../../src').SuccessResponse | null | undefined }[]} BatchValues + * * @param {import('../../src').CreateOptions} createOptions */ export function createBatch(createOptions) { const key = createOptions.key const fetch = createOptions.fetch || window.fetch - const maxRetries = createOptions.maxRetries || 10 - const maxRetryTimeout = createOptions.maxRetryTimeout || 100 * 1000 // 100s return batch /** @@ -58,12 +56,7 @@ export function createBatch(createOptions) { const rateLimitedRequests = batchValues.filter(({ result }) => result === undefined) if (rateLimitedRequests.length) { console.log('Rate-limit #%s: %s/%s', retryCounter, rateLimitedRequests.length, results.length) - if (retryCounter <= maxRetries) { - await randomDelay(maxRetryTimeout) - return batchRequest(retryCounter + 1) - } else { - throw new Error('Max retries reached') - } + return retryAfterTimeout(retryCounter, () => batchRequest(retryCounter + 1)) } return batchValues.map(({ result }) => /** @type {import('../../src').SuccessResponse | null} */ (result)) } diff --git a/src/index.js b/src/index.js index bb5f8eb..aa49933 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,7 @@ -import { randomDelay } from './utils' +import { retryAfterTimeout } from './retry' /** - * @typedef {{ key: string, fetch?: function, maxRetries?: number, maxRetryTimeout?: number }} CreateOptions + * @typedef {{ key: string, fetch?: function }} CreateOptions * @typedef {{ url?: string, origin?: string, formFactor?: FormFactor, effectiveConnectionType?: Connection }} QueryRecordOptions * @typedef {QueryRecordOptions[]} BatchOptions * @typedef {(SuccessResponse | null)[]} BatchResponse @@ -43,8 +43,6 @@ import { randomDelay } from './utils' export function createQueryRecord(createOptions) { const key = createOptions.key const fetch = createOptions.fetch || window.fetch - const maxRetries = createOptions.maxRetries || 10 - const maxRetryTimeout = createOptions.maxRetryTimeout || 100 * 1000 // 100s return queryRecord /** @@ -61,14 +59,7 @@ export function createQueryRecord(createOptions) { if (json && json.error) { const { error } = /** @type {ErrorResponse} */ (json) if (error.code === 404) return null - if (error.code === 429) { - if (retryCounter <= maxRetries) { - await randomDelay(maxRetryTimeout) - return queryRecord(queryOptions, retryCounter + 1) - } else { - throw new Error('Max retries reached') - } - } + if (error.code === 429) return retryAfterTimeout(retryCounter, () => queryRecord(queryOptions, retryCounter + 1)) throw new Error(JSON.stringify(error)) } if (!json || (json && !json.record.key)) throw new Error(`Invalid response: ${JSON.stringify(json)}`) diff --git a/src/retry.js b/src/retry.js new file mode 100644 index 0000000..1ca7b15 --- /dev/null +++ b/src/retry.js @@ -0,0 +1,20 @@ +const maxRetries = 10 +const maxRetryTimeout = 100 * 1000 // 100s + +/** + * Random delay from 1ms to `maxRetryTimeout`. + * Random logic is based on: https://stackoverflow.com/a/29246176 + * + * @param {number} retryCounter + * @param {function} request + */ + +export async function retryAfterTimeout(retryCounter, request) { + if (retryCounter <= maxRetries) { + const timeout = Math.floor(Math.random() * maxRetryTimeout) + 1 + await new Promise((resolve) => setTimeout(resolve, timeout)) + return request() + } else { + throw new Error('Max retries reached') + } +} diff --git a/src/utils.js b/src/utils.js deleted file mode 100644 index 87b0d64..0000000 --- a/src/utils.js +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Random delay from 1ms to `max`. - * Random logic is based on: https://stackoverflow.com/a/29246176 - * - * @param {number} max - */ - -export function randomDelay(max) { - const timeout = Math.floor(Math.random() * max) + 1 - return new Promise((resolve) => setTimeout(resolve, timeout)) -} From fde50e345acf5f3004c5349737034b6f91d64fe8 Mon Sep 17 00:00:00 2001 From: Aleksey Kulikov Date: Thu, 22 Oct 2020 15:53:22 +0200 Subject: [PATCH 8/9] better docs --- README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 498905d..d38b0d5 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ > A [Chrome UX Report API](https://developers.google.com/web/tools/chrome-user-experience-report/api/reference) wrapper that supports batching, handles errors, and provides types. **Motivation**: [CrUX API](https://web.dev/chrome-ux-report-api/) is a fantastic tool to get RUM data without installing any script. -While using the API in [Treo](https://treo.sh/), we discovered a few complications like API errors and limits, not found entries, a complicated multipart response from the batch API, URLs normalization, TypeScript notations. The `crux-api` library makes it easy to work with CrUX API by supporting batch requests, handling errors, and providing TypeScript notations. +While using the API in [Treo](https://treo.sh/), we discovered a few complex cases like API errors and limits, not found entries, a complicated multipart response from the batch API, URLs normalization, and TypeScript notations. So we decided to build the `crux-api` library to makes it easier to work with the CrUX API. **Features**: @@ -129,10 +129,13 @@ const res = await queryRecord({ // res -> URL-level data for https://github.com/marketplace ``` -### Batch Request +### Batching Requests -It uses a separate namespace, because it's powered by a different API. -It's heavier (850 bytes) due to a complexity of constructing and parsing multipart requests. +It uses a separate namespace because a different API powers it. And it's bigger (850 bytes) due to the complexity of constructing and parsing multipart requests. + +It uses the [CrUX Batch API](https://developers.google.com/web/tools/chrome-user-experience-report/api/guides/batch), which allows combining 1000 calls in a single batch request. + +_Note_: A set of `n` requests batched together counts toward your usage limit as `n` requests, not as one request. That's why the sometimes a batch response contains `429` responses. But the `crux-api` automatically retries these responses, aiming always to return the data you need. #### createBatch(createOptions) From 66f3c209311590810629e7cf168ecf2213fd4168 Mon Sep 17 00:00:00 2001 From: Aleksey Kulikov Date: Thu, 22 Oct 2020 16:00:14 +0200 Subject: [PATCH 9/9] batch docs --- README.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d38b0d5..20c3acf 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ > A [Chrome UX Report API](https://developers.google.com/web/tools/chrome-user-experience-report/api/reference) wrapper that supports batching, handles errors, and provides types. **Motivation**: [CrUX API](https://web.dev/chrome-ux-report-api/) is a fantastic tool to get RUM data without installing any script. -While using the API in [Treo](https://treo.sh/), we discovered a few complex cases like API errors and limits, not found entries, a complicated multipart response from the batch API, URLs normalization, and TypeScript notations. So we decided to build the `crux-api` library to makes it easier to work with the CrUX API. +While using the API in [Treo](https://treo.sh/), we discovered a few complex cases like API errors, rate limits, not found entries, a complicated multipart response from the batch API, URLs normalization, and TypeScript notations. We decided to build the `crux-api` library to makes it easier to work with the CrUX API. **Features**: @@ -129,17 +129,15 @@ const res = await queryRecord({ // res -> URL-level data for https://github.com/marketplace ``` -### Batching Requests +### Batch Request -It uses a separate namespace because a different API powers it. And it's bigger (850 bytes) due to the complexity of constructing and parsing multipart requests. - -It uses the [CrUX Batch API](https://developers.google.com/web/tools/chrome-user-experience-report/api/guides/batch), which allows combining 1000 calls in a single batch request. +`crux-api/batch` uses the [CrUX Batch API](https://developers.google.com/web/tools/chrome-user-experience-report/api/guides/batch), which allows combining 1000 calls in a single batch request. It's a separate namespace because the API is different, and it's bigger (850 bytes) due to the complexity of constructing and parsing multipart requests. _Note_: A set of `n` requests batched together counts toward your usage limit as `n` requests, not as one request. That's why the sometimes a batch response contains `429` responses. But the `crux-api` automatically retries these responses, aiming always to return the data you need. #### createBatch(createOptions) -Accepts the same [`createOptions` as the `createQueryRecord`](https://github.com/treosh/crux-api/tree/batch-api-limits#createqueryrecordcreateoptions) and returns a `batch` function. +Accepts the same [`createOptions` as the `createQueryRecord`](#createqueryrecordcreateoptions) and returns a `batch` function. #### batch(batchOptions) @@ -164,7 +162,7 @@ const res = await batch([ ### normalizeUrl(url) Normalize a URL to match the CrUX API internal index. -It is a URL's `origin` + `pathname` ([source](https://github.com/treosh/crux-api/blob/main/src/index.js#L81)). +It is a URL's `origin` + `pathname` ([source](./src/index.js#76)). ```js import { normalizeUrl } from 'crux-api'