diff --git a/docs/02_concepts/02_error-handling.md b/docs/02_concepts/02_error-handling.md index fa36091a2..515de370d 100644 --- a/docs/02_concepts/02_error-handling.md +++ b/docs/02_concepts/02_error-handling.md @@ -76,6 +76,28 @@ try { } ``` +## Missing resources + +When you address a resource by ID, `get()` resolves to `undefined` on a 404 instead of throwing, and `delete()` resolves without error. `getRecord()` and `getRequest()` do the same for a missing record or request, `recordExists()` answers `false`, and `lastRun()` resolves to `undefined` for an Actor or task with no matching run. + +Everywhere else a 404 throws a `NotFoundError`. That covers clients chained off a run or build without an ID, such as `client.run('run-id').dataset()` or `client.build('build-id').log()`, where the missing resource may be the parent rather than the sub-resource. It also covers fixed sub-paths such as `getStatistics()`, `monthlyUsage()`, `limits()`, `getLog()`, `getInput()` and `test()`, where a 404 means the parent is gone. + +```js +import { ApifyClient, NotFoundError } from 'apify-client'; + +const client = new ApifyClient({ token: 'MY-APIFY-TOKEN' }); + +const actor = await client.actor('missing-actor').get(); // undefined + +try { + await client.run('missing-run').dataset().get(); +} catch (error) { + if (error instanceof NotFoundError) { + // Either the run or its default dataset does not exist. + } +} +``` + ## Invalid arguments Before sending a request, the client validates the arguments you passed. When a value doesn't match the expected shape, the client throws an `ArgumentValidationError` without reaching the API. Its `message` names the offending field and the value it received. For programmatic inspection, `issues` carries the structured [zod](https://zod.dev) issues and `cause` carries the original `ZodError`. diff --git a/docs/04_upgrading/upgrading_v3.md b/docs/04_upgrading/upgrading_v3.md index 66cf16dcf..c0671c655 100644 --- a/docs/04_upgrading/upgrading_v3.md +++ b/docs/04_upgrading/upgrading_v3.md @@ -97,6 +97,52 @@ Two things change as a result: - `error.name`, and with it the first line of the printed stack, now carries the subclass name, such as `NotFoundError: Actor task was not found` instead of `ApifyApiError: Actor task was not found`. Log tooling that matches on the `ApifyApiError` name has to match the subclass names as well. - Methods that swallow a 404 response, such as `get()` returning `undefined` or `delete()` succeeding silently, now swallow every 404, whatever its `type`. In v2 they swallowed only the `record-not-found` and `record-or-token-not-found` types and threw for any other 404. The same helper backs `waitForFinish()` and `call()`, which read a swallowed 404 as "the run is not visible yet", so a 404 that used to throw now keeps them polling until `waitSecs` runs out. +## A 404 throws where it used to resolve to `undefined` + +Fetching a resource by ID still resolves to `undefined` when the API answers 404, and `delete()` on such a client still resolves without error. The change affects endpoints where a 404 can't be pinned to one resource: the missing thing may be the parent or the sub-resource, and the response doesn't say which. Those now throw an `ApifyApiError` with `statusCode` 404 instead of hiding the cause behind `undefined`. + +```js +import { ApifyApiError, ApifyClient } from 'apify-client'; + +const client = new ApifyClient({ token: 'MY-APIFY-TOKEN' }); + +// v2: resolved to undefined on 404. v3: throws. +let dataset; +try { + dataset = await client.run('run-id').dataset().get(); +} catch (error) { + if (!(error instanceof ApifyApiError) || error.statusCode !== 404) throw error; +} +``` + +Affected calls: + +- Clients chained off a run or build without an ID: `run.dataset()`, `run.keyValueStore()`, `run.requestQueue()`, `run.log()` and `build.log()`. Their `get()` and `delete()` throw on a 404, and so does `log().get()`. `client.log(id).get()` keeps resolving to `undefined`. +- Singleton endpoints at a fixed path under a resource: `DatasetClient.getStatistics()`, `UserClient.monthlyUsage()`, `UserClient.limits()`, `ScheduleClient.getLog()`, `TaskClient.getInput()` and `WebhookClient.test()`. A 404 there means the parent resource is gone, so these throw as well, and their return types drop `| undefined`. + +Lookups by key keep the old behavior, because there the 404 is about the record itself: `KeyValueStoreClient.getRecord()` and `RequestQueueClient.getRequest()` still resolve to `undefined`, and `KeyValueStoreClient.recordExists()` still answers `false`. `ActorClient.lastRun()` and `TaskClient.lastRun()` also keep resolving to `undefined`, where having no run yet is an ordinary outcome. + +`LogClient.stream()` follows the same rule as `get()`: `client.log(id).stream()` resolves to `undefined` on a 404, `run.log().stream()` throws. + +A `StreamedLog` whose run no longer exists logs a warning and stops, the same way it handles any other error while streaming. + +### `UserClient.get()` declares the `undefined` it could always return + +`UserClient.get()` is typed `Promise`. It addresses a user by ID, so it belongs with the calls that read a 404 as a missing resource, and it already resolved to `undefined` for one. Only its signature said otherwise, which left the `undefined` to surface as a runtime error somewhere further along. Every other `get()` on the client is typed this way, as is `get()` on the [Python client](https://docs.apify.com/api/client/python). + +```diff +- const user = await client.user('some-id').get(); +- console.log(user.username); ++ const user = await client.user('some-id').get(); ++ console.log(user?.username); +``` + +### An empty string is no longer accepted as a version number or environment variable name + +`ActorClient.version()`, `ActorClient.build()` and `ActorVersionClient.envVar()` now throw an `ArgumentValidationError` for an empty string, which is what every other resource identifier has always done. + +An empty identifier used to build the URL of the whole collection instead of one member, so `actor.version('')` read every version of the Actor and a 404 no longer meant a missing version. Rejecting it up front keeps the 404 rules above unambiguous. + ## Published types now follow the OpenAPI specification Every output type the client publishes, such as `Dataset`, `KeyValueStore`, `Build`, `ActorRun`, `Webhook`, `Schedule`, `Task`, `RequestQueue`, and `User`, is now declared on top of a type generated from the published [OpenAPI specification](https://docs.apify.com/api/v2) instead of being hand-written. Several of the previous hand-written types were wrong, and some even contradicted the client's own runtime behavior. For example, `nextExclusiveStartKey` was typed as a required `string`, but `listKeys()` has always compared it to `null`. diff --git a/docs/public-api/apify-client.api.md b/docs/public-api/apify-client.api.md index 290bbcc74..24250a2e3 100644 --- a/docs/public-api/apify-client.api.md +++ b/docs/public-api/apify-client.api.md @@ -2421,7 +2421,7 @@ export class DatasetClient = Record; downloadItems(format: `${DownloadItemsFormat}`, options?: DatasetClientDownloadItemsOptions): Promise; get(): Promise; - getStatistics(): Promise; + getStatistics(): Promise; listItems(options?: DatasetClientListItemOptions): PaginatedIterator; pushItems(items: Data | Data[] | string | string[]): Promise; update(newFields: DatasetClientUpdateOptions): Promise; @@ -3509,9 +3509,7 @@ export interface RequestQueueUserOptions { // Not exported by the entry point; reachable only as a referenced type. // @public class ResourceClient extends ApiClient { - // (undocumented) protected _delete(timeoutMillis?: number): Promise; - // (undocumented) protected _get(schema: z.ZodType, options?: T, timeoutMillis?: number): Promise; // (undocumented) protected _update(schema: z.ZodType, newFields: T, timeoutMillis?: number): Promise; @@ -3680,7 +3678,7 @@ export class ScheduleClient extends ResourceClient { constructor(options: ApiClientSubResourceOptions); delete(): Promise; get(): Promise; - getLog(): Promise; + getLog(): Promise; update(newFields: ScheduleCreateOrUpdateData): Promise; } @@ -3803,7 +3801,7 @@ export class TaskClient extends ResourceClient { call(input?: Dictionary, options?: TaskCallOptions): Promise; delete(): Promise; get(): Promise; - getInput(): Promise; + getInput(): Promise; lastRun(options?: TaskLastRunOptions): RunClient; publish(): Promise; runs(): RunCollectionClient; @@ -3951,9 +3949,9 @@ export interface User extends Omit; - limits(): Promise; - monthlyUsage(): Promise; + get(): Promise; + limits(): Promise; + monthlyUsage(): Promise; updateLimits(options: LimitsUpdateOptions): Promise; } @@ -4031,7 +4029,7 @@ export class WebhookClient extends ResourceClient { delete(): Promise; dispatches(): WebhookDispatchCollectionClient; get(): Promise; - test(): Promise; + test(): Promise; update(newFields: WebhookUpdateData): Promise; } diff --git a/src/base/resource_client.ts b/src/base/resource_client.ts index 434a7fb84..7739d45a6 100644 --- a/src/base/resource_client.ts +++ b/src/base/resource_client.ts @@ -4,7 +4,7 @@ import type { z } from 'zod'; import type { ApifyApiError } from '../apify_api_error.js'; import type { ApifyRequestConfig } from '../http_client.js'; -import { catchNotFoundOrThrow, parseResponse } from '../utils.js'; +import { catchNotFoundForResourceOrThrow, catchNotFoundOrThrow, parseResponse } from '../utils.js'; import { ApiClient } from './api_client.js'; /** @@ -23,6 +23,10 @@ export const DEFAULT_TIMEOUT_MILLIS = 360 * 1000; // 6 minutes * @private */ export class ResourceClient extends ApiClient { + /** + * A 404 resolves to `undefined` only when the client names its resource by ID. A chained client without one, such + * as `run.dataset()`, throws it instead (see `catchNotFoundForResourceOrThrow()`). + */ protected async _get( schema: z.ZodType, options: T = {} as T, @@ -38,7 +42,7 @@ export class ResourceClient extends ApiClient { const response = await this.httpClient.call(requestOpts); return parseResponse(response, schema); } catch (err) { - catchNotFoundOrThrow(err as ApifyApiError); + catchNotFoundForResourceOrThrow(err as ApifyApiError, this.id); } return undefined; @@ -55,6 +59,10 @@ export class ResourceClient extends ApiClient { return parseResponse(response, schema); } + /** + * A 404 is swallowed, keeping the DELETE idempotent, only when the client names its resource by ID. A chained client + * without one throws it instead (see `catchNotFoundForResourceOrThrow()`). + */ protected async _delete(timeoutMillis?: number): Promise { try { await this.httpClient.call({ @@ -64,7 +72,7 @@ export class ResourceClient extends ApiClient { timeout: timeoutMillis, }); } catch (err) { - catchNotFoundOrThrow(err as ApifyApiError); + catchNotFoundForResourceOrThrow(err as ApifyApiError, this.id); } } diff --git a/src/resource_clients/actor.ts b/src/resource_clients/actor.ts index 1a88d6ae6..0940d273f 100644 --- a/src/resource_clients/actor.ts +++ b/src/resource_clients/actor.ts @@ -49,7 +49,7 @@ const validateInputOptionsSchema = z.strictObject({ build: z.string().optional(), contentType: z.string().optional(), }); -const versionNumberSchema = z.string(); +const versionNumberSchema = z.string().min(1); const buildOptionsSchema = z.strictObject({ betaPackages: z.boolean().optional(), tag: z.string().optional(), diff --git a/src/resource_clients/actor_version.ts b/src/resource_clients/actor_version.ts index c0c95cbcf..d7bd902af 100644 --- a/src/resource_clients/actor_version.ts +++ b/src/resource_clients/actor_version.ts @@ -8,7 +8,7 @@ import { anyObjectSchema, parseArgument } from '../utils.js'; import { ActorEnvVarClient } from './actor_env_var.js'; import { ActorEnvVarCollectionClient } from './actor_env_var_collection.js'; -const envVarNameSchema = z.string(); +const envVarNameSchema = z.string().min(1); export type { ActorEnvironmentVariable, diff --git a/src/resource_clients/build.ts b/src/resource_clients/build.ts index 58fa76a4a..fdff0ff34 100644 --- a/src/resource_clients/build.ts +++ b/src/resource_clients/build.ts @@ -159,6 +159,8 @@ export class BuildClient extends ResourceClient { /** * Returns a client for accessing the log of this Actor build. * + * A 404 from this client throws an `ApifyApiError`, since the build itself may be what is missing. + * * @returns A client for accessing the build's log * @see https://docs.apify.com/api/v2/actor-build-log-get * diff --git a/src/resource_clients/dataset.ts b/src/resource_clients/dataset.ts index d71b8c13b..ec15da699 100644 --- a/src/resource_clients/dataset.ts +++ b/src/resource_clients/dataset.ts @@ -3,7 +3,6 @@ import { z } from 'zod'; import type { STORAGE_GENERAL_ACCESS } from '@apify/consts'; import { createStorageContentSignatureAsync } from '@apify/utilities'; -import type { ApifyApiError } from '../apify_api_error.js'; import type { ApiClientSubResourceOptions } from '../base/api_client.js'; import { DEFAULT_TIMEOUT_MILLIS, @@ -11,7 +10,7 @@ import { ResourceClient, SMALL_TIMEOUT_MILLIS, } from '../base/resource_client.js'; -import type { ApifyRequestConfig, ApifyResponse } from '../http_client.js'; +import type { ApifyResponse } from '../http_client.js'; import type { Dataset, DatasetStatistics } from '../models.js'; import type { PaginatedIterator, PaginatedList, PaginationOptions } from '../utils.js'; import * as schemas from '../schemas.js'; @@ -19,7 +18,6 @@ import { anyObjectSchema, applyQueryParamsToUrl, cast, - catchNotFoundOrThrow, isNonArrayObject, paginationOptionsShape, parseArgument, @@ -335,24 +333,18 @@ export class DatasetClient< * Returns statistics for each field in the dataset, including information about * data types, null counts, and value ranges. * - * @returns Dataset statistics, or `undefined` if not available + * @returns Dataset statistics * @see https://docs.apify.com/api/v2/dataset-statistics-get * @since Added in 2.11.2 */ - async getStatistics(): Promise { - const requestOpts: ApifyRequestConfig = { + async getStatistics(): Promise { + const response = await this.httpClient.call({ url: this._url('statistics'), method: 'GET', params: this._params(), timeout: SMALL_TIMEOUT_MILLIS, - }; - try { - const response = await this.httpClient.call(requestOpts); - return parseResponse(response, schemas.DatasetStatistics()); - } catch (err) { - catchNotFoundOrThrow(err as ApifyApiError); - } - return undefined; + }); + return parseResponse(response, schemas.DatasetStatistics()); } /** diff --git a/src/resource_clients/log.ts b/src/resource_clients/log.ts index 3ad11d6cc..f2a01f2e2 100644 --- a/src/resource_clients/log.ts +++ b/src/resource_clients/log.ts @@ -10,7 +10,7 @@ import type { ApifyApiError } from '../apify_api_error.js'; import type { ApiClientSubResourceOptions } from '../base/api_client.js'; import { ResourceClient } from '../base/resource_client.js'; import type { ApifyRequestConfig } from '../http_client.js'; -import { cast, catchNotFoundOrThrow } from '../utils.js'; +import { cast, catchNotFoundForResourceOrThrow } from '../utils.js'; /** * Client for accessing Actor run or build logs. @@ -50,7 +50,8 @@ export class LogClient extends ResourceClient { * * @param options - Log retrieval options. * @param options.raw - If `true`, returns raw log content without any processing. Default is `false`. - * @returns The log content as a string, or `undefined` if it does not exist. + * @returns The log content as a string, or `undefined` if it does not exist. A chained client such as + * `run.log()` throws an `ApifyApiError` on a 404, since the run itself may be what is missing. * @see https://docs.apify.com/api/v2/log-get */ async get(options: LogOptions = {}): Promise { @@ -64,7 +65,7 @@ export class LogClient extends ResourceClient { const response = await this.httpClient.call(requestOpts); return cast(response.data); } catch (err) { - catchNotFoundOrThrow(err as ApifyApiError); + catchNotFoundForResourceOrThrow(err as ApifyApiError, this.id); } return undefined; @@ -75,7 +76,8 @@ export class LogClient extends ResourceClient { * * @param options - Log retrieval options. * @param options.raw - If `true`, returns raw log content without any processing. Default is `false`. - * @returns The log content as a Readable stream, or `undefined` if it does not exist. + * @returns The log content as a Readable stream, or `undefined` if it does not exist. A chained client such as + * `run.log()` throws an `ApifyApiError` on a 404, since the run itself may be what is missing. * @see https://docs.apify.com/api/v2/log-get */ async stream(options: LogOptions = {}): Promise { @@ -95,7 +97,7 @@ export class LogClient extends ResourceClient { const response = await this.httpClient.call(requestOpts); return cast(response.data); } catch (err) { - catchNotFoundOrThrow(err as ApifyApiError); + catchNotFoundForResourceOrThrow(err as ApifyApiError, this.id); } return undefined; @@ -198,11 +200,11 @@ export class StreamedLog { * Get log stream from response and redirect it to another log. */ private async streamLog(): Promise { - const logStream = await this.logClient.stream({ raw: true }); - if (!logStream) { - return; - } try { + const logStream = await this.logClient.stream({ raw: true }); + if (!logStream) { + return; + } const lastChunkRemainder = await this.logStreamChunks(logStream); // Process whatever is left when exiting. Maybe it is incomplete, maybe it is last log without EOL. const lastMessage = Buffer.from(lastChunkRemainder).toString().trim(); diff --git a/src/resource_clients/run.ts b/src/resource_clients/run.ts index 1cae51460..aeed395f8 100644 --- a/src/resource_clients/run.ts +++ b/src/resource_clients/run.ts @@ -354,6 +354,8 @@ export class RunClient extends ResourceClient { /** * Returns a client for the default dataset of this Actor run. * + * A 404 from this client throws an `ApifyApiError`, since the run itself may be what is missing. + * * @returns A client for accessing the run's default dataset * @see https://docs.apify.com/api/v2/actor-run-get * @@ -374,6 +376,9 @@ export class RunClient extends ResourceClient { /** * Returns a client for the default key-value store of this Actor run. * + * `get()` and `delete()` throw an `ApifyApiError` on a 404, since the run itself may be what is missing. Record + * lookups such as `getRecord()` read a 404 as a missing record. + * * @returns A client for accessing the run's default key-value store * @see https://docs.apify.com/api/v2/actor-run-get * @@ -394,6 +399,9 @@ export class RunClient extends ResourceClient { /** * Returns a client for the default Request queue of this Actor run. * + * `get()` and `delete()` throw an `ApifyApiError` on a 404, since the run itself may be what is missing. + * `getRequest()` reads a 404 as a missing request. + * * @returns A client for accessing the run's default Request queue * @see https://docs.apify.com/api/v2/actor-run-get * @@ -414,6 +422,8 @@ export class RunClient extends ResourceClient { /** * Returns a client for accessing the log of this Actor run. * + * A 404 from this client throws an `ApifyApiError`, since the run itself may be what is missing. + * * @returns A client for accessing the run's log * @see https://docs.apify.com/api/v2/actor-run-get * @@ -450,8 +460,8 @@ export class RunClient extends ResourceClient { const runId = runData?.id ?? ''; const actorId = runData?.actId ?? ''; - const actorData = (await this.apifyClient.actor(actorId).get()) || { name: '' }; - + // `apifyClient.actor()` rejects an empty ID, which is what a run that could not be read leaves here. + const actorData = actorId ? await this.apifyClient.actor(actorId).get() : undefined; const actorName = actorData?.name ?? ''; const name = [actorName, `runId:${runId}`].filter(Boolean).join(' '); diff --git a/src/resource_clients/schedule.ts b/src/resource_clients/schedule.ts index 5287f7152..759a5f443 100644 --- a/src/resource_clients/schedule.ts +++ b/src/resource_clients/schedule.ts @@ -1,13 +1,11 @@ import { z } from 'zod'; -import type { ApifyApiError } from '../apify_api_error.js'; import type { ApiClientSubResourceOptions } from '../base/api_client.js'; import { ResourceClient } from '../base/resource_client.js'; -import type { ApifyRequestConfig } from '../http_client.js'; import type { Schedule, ScheduleAction, ScheduleInvoked } from '../models.js'; import type { DistributiveOptional } from '../utils.js'; import * as schemas from '../schemas.js'; -import { anyObjectSchema, catchNotFoundOrThrow, parseArgument, parseResponse } from '../utils.js'; +import { anyObjectSchema, parseArgument, parseResponse } from '../utils.js'; export type { Schedule, @@ -90,23 +88,16 @@ export class ScheduleClient extends ResourceClient { /** * Retrieves the schedule's log. * - * @returns The schedule log, one entry per invocation, or `undefined` if the schedule does not exist. + * @returns The schedule log, one entry per invocation. * @see https://docs.apify.com/api/v2/schedule-log-get */ - async getLog(): Promise { - const requestOpts: ApifyRequestConfig = { + async getLog(): Promise { + const response = await this.httpClient.call({ url: this._url('log'), method: 'GET', params: this._params(), - }; - try { - const response = await this.httpClient.call(requestOpts); - return parseResponse(response, scheduleLogSchema); - } catch (err) { - catchNotFoundOrThrow(err as ApifyApiError); - } - - return undefined; + }); + return parseResponse(response, scheduleLogSchema); } } diff --git a/src/resource_clients/task.ts b/src/resource_clients/task.ts index 245c0e47a..4ade81ba0 100644 --- a/src/resource_clients/task.ts +++ b/src/resource_clients/task.ts @@ -2,21 +2,13 @@ import { z } from 'zod'; import { ACT_JOB_STATUSES, META_ORIGINS } from '@apify/consts'; -import type { ApifyApiError } from '../apify_api_error.js'; import type { ApiClientSubResourceOptions } from '../base/api_client.js'; import { ResourceClient } from '../base/resource_client.js'; import type { ApifyRequestConfig } from '../http_client.js'; import type { Task, TaskPublicConfig } from '../models.js'; import type { Dictionary } from '../utils.js'; import * as schemas from '../schemas.js'; -import { - anyObjectSchema, - cast, - catchNotFoundOrThrow, - parseArgument, - parseResponse, - stringifyWebhooksToBase64, -} from '../utils.js'; +import { anyObjectSchema, cast, parseArgument, parseResponse, stringifyWebhooksToBase64 } from '../utils.js'; import type { ActorLastRunOptions, ActorRun, ActorStartOptions } from './actor.js'; import { RunClient } from './run.js'; import { RunCollectionClient } from './run_collection.js'; @@ -229,23 +221,16 @@ export class TaskClient extends ResourceClient { /** * Retrieves the Actor task's input object. * - * @returns The Task's input, or `undefined` if it does not exist. + * @returns The Task's input. * @see https://docs.apify.com/api/v2/actor-task-input-get */ - async getInput(): Promise { - const requestOpts: ApifyRequestConfig = { + async getInput(): Promise { + const response = await this.httpClient.call({ url: this._url('input'), method: 'GET', params: this._params(), - }; - try { - const response = await this.httpClient.call(requestOpts); - return cast(response.data); - } catch (err) { - catchNotFoundOrThrow(err as ApifyApiError); - } - - return undefined; + }); + return cast(response.data); } /** diff --git a/src/resource_clients/user.ts b/src/resource_clients/user.ts index 8d3bd6080..9d9fc0969 100644 --- a/src/resource_clients/user.ts +++ b/src/resource_clients/user.ts @@ -1,10 +1,9 @@ -import type { ApifyApiError } from '../apify_api_error.js'; import type { ApiClientSubResourceOptions } from '../base/api_client.js'; import { ResourceClient } from '../base/resource_client.js'; import type { ApifyRequestConfig } from '../http_client.js'; import type { AccountAndUsageLimits, MonthlyUsage, User } from '../models.js'; import * as schemas from '../schemas.js'; -import { catchNotFoundOrThrow, parseResponse } from '../utils.js'; +import { parseResponse } from '../utils.js'; export type { AccountAndUsageLimits, @@ -68,58 +67,44 @@ export class UserClient extends ResourceClient { * Depending on whether ApifyClient was created with a token, * the method will either return public or private user data. * - * @returns The user object. + * @returns The user object, or `undefined` if it does not exist. * @see https://docs.apify.com/api/v2/user-get */ - async get(): Promise { - return this._get(schemas.UserPrivateInfo()) as Promise; + async get(): Promise { + return this._get(schemas.UserPrivateInfo()); } /** * Retrieves the user's monthly usage data. * - * @returns The monthly usage object, or `undefined` if it does not exist. + * @returns The monthly usage object. * @see https://docs.apify.com/api/v2/users-me-usage-monthly-get * @since Added in 2.9.2 */ - async monthlyUsage(): Promise { - const requestOpts: ApifyRequestConfig = { + async monthlyUsage(): Promise { + const response = await this.httpClient.call({ url: this._url('usage/monthly'), method: 'GET', params: this._params(), - }; - try { - const response = await this.httpClient.call(requestOpts); - // `dailyServiceUsages[].date` does not end in `At`, so it has to be named for `parseDateFields`. - return parseResponse(response, schemas.MonthlyUsage(), (key) => key === 'date'); - } catch (err) { - catchNotFoundOrThrow(err as ApifyApiError); - } - - return undefined; + }); + // `dailyServiceUsages[].date` does not end in `At`, so it has to be named for `parseDateFields`. + return parseResponse(response, schemas.MonthlyUsage(), (key) => key === 'date'); } /** * Retrieves the user's account and usage limits. * - * @returns The account and usage limits object, or `undefined` if it does not exist. + * @returns The account and usage limits object. * @see https://docs.apify.com/api/v2/users-me-limits-get * @since Added in 2.9.2 */ - async limits(): Promise { - const requestOpts: ApifyRequestConfig = { + async limits(): Promise { + const response = await this.httpClient.call({ url: this._url('limits'), method: 'GET', params: this._params(), - }; - try { - const response = await this.httpClient.call(requestOpts); - return parseResponse(response, schemas.AccountLimits()); - } catch (err) { - catchNotFoundOrThrow(err as ApifyApiError); - } - - return undefined; + }); + return parseResponse(response, schemas.AccountLimits()); } /** diff --git a/src/resource_clients/webhook.ts b/src/resource_clients/webhook.ts index 97256c155..1a1b7940f 100644 --- a/src/resource_clients/webhook.ts +++ b/src/resource_clients/webhook.ts @@ -1,10 +1,8 @@ -import type { ApifyApiError } from '../apify_api_error.js'; import type { ApiClientSubResourceOptions } from '../base/api_client.js'; import { ResourceClient } from '../base/resource_client.js'; -import type { ApifyRequestConfig } from '../http_client.js'; import type { Webhook, WebhookEventType } from '../models.js'; import * as schemas from '../schemas.js'; -import { anyObjectSchema, catchNotFoundOrThrow, parseArgument, parseResponse } from '../utils.js'; +import { anyObjectSchema, parseArgument, parseResponse } from '../utils.js'; import type { WebhookDispatch } from './webhook_dispatch.js'; import { WebhookDispatchCollectionClient } from './webhook_dispatch_collection.js'; @@ -93,24 +91,16 @@ export class WebhookClient extends ResourceClient { /** * Tests the webhook by dispatching a test event. * - * @returns The webhook dispatch object, or `undefined` if the test fails. + * @returns The webhook dispatch object. * @see https://docs.apify.com/api/v2/webhook-test-post */ - async test(): Promise { - const request: ApifyRequestConfig = { + async test(): Promise { + const response = await this.httpClient.call({ url: this._url('test'), method: 'POST', params: this._params(), - }; - - try { - const response = await this.httpClient.call(request); - return parseResponse(response, schemas.WebhookDispatch()); - } catch (err) { - catchNotFoundOrThrow(err as ApifyApiError); - } - - return undefined; + }); + return parseResponse(response, schemas.WebhookDispatch()); } /** diff --git a/src/utils.ts b/src/utils.ts index 3fc0c7d3c..fbc6dd686 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -96,6 +96,18 @@ export function catchNotFoundOrThrow(err: ApifyApiError): void { if (!(err instanceof NotFoundError)) throw err; } +/** + * Like `catchNotFoundOrThrow()`, but swallows the 404 only when the client names its resource by ID. + * + * A chained client without an ID, such as `run.dataset()` or `run.log()`, requests a path where a 404 can mean either + * the parent or the default sub-resource is missing. The response cannot tell the two apart, so the error propagates. + * @internal + */ +export function catchNotFoundForResourceOrThrow(err: ApifyApiError, resourceId: string | undefined): void { + if (!resourceId) throw err; + catchNotFoundOrThrow(err); +} + type ReturnJsonValue = string | number | boolean | null | Date | ReturnJsonObject | ReturnJsonArray; type ReturnJsonObject = { [Key in string]?: ReturnJsonValue }; type ReturnJsonArray = ReturnJsonValue[]; diff --git a/test/actors.test.ts b/test/actors.test.ts index 3f4295c22..134faea07 100644 --- a/test/actors.test.ts +++ b/test/actors.test.ts @@ -4,7 +4,14 @@ import { setTimeout } from 'node:timers/promises'; import c from 'ansi-colors'; import type { ActorCollectionCreateOptions, ActorCollectionListOptions, ActorVersion } from 'apify-client'; -import { ActorListSortBy, ActorSourceType, ApifyClient, LoggerActorRedirect } from 'apify-client'; +import { + ActorListSortBy, + ActorSourceType, + ApifyApiError, + ApifyClient, + ArgumentValidationError, + LoggerActorRedirect, +} from 'apify-client'; import express from 'express'; import type { Page } from 'puppeteer'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; @@ -463,6 +470,29 @@ describe('Actor methods', () => { }); describe('lastRun()', () => { + test('get() returns undefined on 404 status code (RECORD_NOT_FOUND)', async () => { + const actorId = '404'; + + const res = await client.actor(actorId).lastRun().get(); + expect(res).toBeUndefined(); + validateRequest({ query: {}, params: { actorId } }); + + const browserRes = await page.evaluate((aId) => client.actor(aId).lastRun().get(), actorId); + expect(browserRes).toBeUndefined(); + }); + + test('dataset().get() throws on 404 status code', async () => { + const actorId = '404'; + + const call = client.actor(actorId).lastRun().dataset().get(); + await expect(call).rejects.toThrow(ApifyApiError); + await expect(call).rejects.toMatchObject({ statusCode: 404 }); + + await expect( + page.evaluate((aId) => client.actor(aId).lastRun().dataset().get(), actorId), + ).rejects.toThrow(); + }); + test.each(['get', 'dataset', 'keyValueStore', 'requestQueue', 'log'] as const)( '%s() works', async (method) => { @@ -636,6 +666,14 @@ describe('Actor methods', () => { validateRequest({ query: {}, params: { actorId, versionNumber } }); }); + test('rejects an empty version number', async () => { + // An empty ID makes `ApiClient` build the collection URL, so the client would address + // every version instead of one, and a 404 could not be read as a missing version. + const call = () => client.actor('some-id').version(''); + expect(call).toThrow(ArgumentValidationError); + expect(call).toThrow('Too small'); + }); + test('update() works', async () => { const actorId = 'some-user/some-id'; const versionNumber = '0.0'; @@ -798,6 +836,12 @@ describe('Actor methods', () => { validateRequest({ query: {}, params: { actorId, versionNumber, envVarName } }); }); + test('rejects an empty environment variable name', async () => { + const call = () => client.actor('some-id').version('0.0').envVar(''); + expect(call).toThrow(ArgumentValidationError); + expect(call).toThrow('Too small'); + }); + test('update() works', async () => { const actorId = 'some-user/some-id'; const versionNumber = '0.0'; diff --git a/test/builds.test.ts b/test/builds.test.ts index 827a3dadf..f1ab19d85 100644 --- a/test/builds.test.ts +++ b/test/builds.test.ts @@ -1,6 +1,6 @@ import type { AddressInfo } from 'node:net'; -import { ApifyClient } from 'apify-client'; +import { ApifyApiError, ApifyClient } from 'apify-client'; import type { Page } from 'puppeteer'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'vitest'; @@ -138,5 +138,15 @@ describe('Build methods', () => { expect(browserRes).toEqual('build-log'); validateRequest({ query: {}, params: { buildId } }); }); + + test('log().get() throws on 404 status code', async () => { + const buildId = '404'; + + const call = client.build(buildId).log().get(); + await expect(call).rejects.toThrow(ApifyApiError); + await expect(call).rejects.toMatchObject({ statusCode: 404 }); + + await expect(page.evaluate((id) => client.build(id).log().get(), buildId)).rejects.toThrow(); + }); }); }); diff --git a/test/datasets.test.ts b/test/datasets.test.ts index 9e66df7c5..0181b2ea9 100644 --- a/test/datasets.test.ts +++ b/test/datasets.test.ts @@ -1,6 +1,6 @@ import type { AddressInfo } from 'node:net'; -import { ApifyClient, ArgumentValidationError, DownloadItemsFormat } from 'apify-client'; +import { ApifyApiError, ApifyClient, ArgumentValidationError, DownloadItemsFormat } from 'apify-client'; import type { Page } from 'puppeteer'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, test } from 'vitest'; @@ -118,6 +118,13 @@ describe('Dataset methods', () => { validateRequest({ query: {}, params: { datasetId } }); }); + test('delete() resolves on 404 status code (RECORD_NOT_FOUND)', async () => { + const datasetId = '404'; + + await expect(client.dataset(datasetId).delete()).resolves.toBeUndefined(); + validateRequest({ query: {}, params: { datasetId } }); + }); + test('update() works', async () => { const datasetId = 'some-id'; const dataset = { name: 'my-name' }; @@ -401,6 +408,16 @@ describe('Dataset methods', () => { validateRequest({ query: {}, params: { datasetId } }); }); + test('getStatistics() throws on 404 status code', async () => { + const datasetId = '404'; + + const call = client.dataset(datasetId).getStatistics(); + await expect(call).rejects.toThrow(ApifyApiError); + await expect(call).rejects.toMatchObject({ statusCode: 404 }); + + await expect(page.evaluate((id) => client.dataset(id).getStatistics(), datasetId)).rejects.toThrow(); + }); + describe('createItemsPublicUrl()', () => { it.each([ ['https://custom.public.url/', 'custom.public.url'], diff --git a/test/integration/apify_client.test.ts b/test/integration/apify_client.test.ts index 9b0d98c30..80e0175a4 100644 --- a/test/integration/apify_client.test.ts +++ b/test/integration/apify_client.test.ts @@ -13,5 +13,5 @@ beforeAll(() => { test('the client authenticates against the live API and resolves the current user', async () => { const me = await client.user('me').get(); - expect(me.username).toBeTruthy(); + expect(me?.username).toBeTruthy(); }); diff --git a/test/integration/dataset.test.ts b/test/integration/dataset.test.ts index 84fe50372..da8c0206d 100644 --- a/test/integration/dataset.test.ts +++ b/test/integration/dataset.test.ts @@ -370,8 +370,7 @@ test('getStatistics() returns per-field statistics', async () => { const statistics = await datasetClient.getStatistics(); - expect(statistics).toBeDefined(); - expect(statistics!.fieldStatistics).toBeTypeOf('object'); + expect(statistics.fieldStatistics).toBeTypeOf('object'); } finally { await datasetClient.delete(); } diff --git a/test/integration/log.test.ts b/test/integration/log.test.ts index 076580f02..a3ba307e3 100644 --- a/test/integration/log.test.ts +++ b/test/integration/log.test.ts @@ -47,8 +47,7 @@ test('get() returns the log of a build', async () => { const log = await client.build(buildsPage.items[0].id).log().get(); - // A build log can legitimately be empty, so only its presence is pinned - `get()` answers with - // `undefined` on a 404, which is the failure this is here to catch. + // A build log can legitimately be empty, so only its presence is pinned. expect(log).toBeDefined(); }); diff --git a/test/integration/user.test.ts b/test/integration/user.test.ts index d3a4b3995..46d663a34 100644 --- a/test/integration/user.test.ts +++ b/test/integration/user.test.ts @@ -13,36 +13,33 @@ beforeAll(() => { test('user().get() returns the authenticated user', async () => { const user = await client.user().get(); - expect(user.username).toBeTruthy(); + expect(user?.username).toBeTruthy(); }); test('user().limits() returns the account limits and current usage', async () => { const limits = await client.user().limits(); - expect(limits).toBeDefined(); - expect(limits!.limits).toBeTypeOf('object'); - expect(limits!.current).toBeTypeOf('object'); - expect(limits!.monthlyUsageCycle.startAt).toBeInstanceOf(Date); + expect(limits.limits).toBeTypeOf('object'); + expect(limits.current).toBeTypeOf('object'); + expect(limits.monthlyUsageCycle.startAt).toBeInstanceOf(Date); }); test('user().monthlyUsage() returns the current billing cycle usage', async () => { const usage = await client.user().monthlyUsage(); - expect(usage).toBeDefined(); - expect(usage!.usageCycle.startAt).toBeInstanceOf(Date); - expect(usage!.monthlyServiceUsage).toBeTypeOf('object'); - expect(Array.isArray(usage!.dailyServiceUsages)).toBe(true); + expect(usage.usageCycle.startAt).toBeInstanceOf(Date); + expect(usage.monthlyServiceUsage).toBeTypeOf('object'); + expect(Array.isArray(usage.dailyServiceUsages)).toBe(true); }); test('user().updateLimits() is accepted, or rejected with a client error the account does not allow', async () => { const accountLimits = await client.user().limits(); - expect(accountLimits, 'the account limits could not be read').toBeDefined(); // Data retention is an account-wide setting shared with every other suite and with the parallel // Node-version job, so the value it already has is written back rather than a new one. That still // exercises the request end to end, without a concurrent run observing or restoring the change. try { - await client.user().updateLimits({ dataRetentionDays: accountLimits!.limits.dataRetentionDays }); + await client.user().updateLimits({ dataRetentionDays: accountLimits.limits.dataRetentionDays }); } catch (err) { // Free accounts reject changes to their limits outright, so a 400 or 403 is as valid an outcome // here as success - anything else means the request itself was malformed. diff --git a/test/integration/webhook.test.ts b/test/integration/webhook.test.ts index 9ea7ecdda..3dbb7a467 100644 --- a/test/integration/webhook.test.ts +++ b/test/integration/webhook.test.ts @@ -92,7 +92,7 @@ test('test() creates a dispatch carrying a dummy payload', async () => { try { const dispatch = await webhookClient.test(); - expect(dispatch?.id).toBeTruthy(); + expect(dispatch.id).toBeTruthy(); } finally { await webhookClient.delete(); } diff --git a/test/logs.test.ts b/test/logs.test.ts index f8017e634..3e6bf52e5 100644 --- a/test/logs.test.ts +++ b/test/logs.test.ts @@ -49,6 +49,17 @@ describe('Log methods', () => { validateRequest({ query: {}, params: { logId } }); }); + test('get() returns undefined on 404 status code (RECORD_NOT_FOUND)', async () => { + const logId = '404'; + + const res = await client.log(logId).get(); + expect(res).toBeUndefined(); + validateRequest({ query: {}, params: { logId } }); + + const browserRes = await page.evaluate((id) => client.log(id).get(), logId); + expect(browserRes).toBeUndefined(); + }); + test('stream() works', async () => { const logId = 'some-id'; @@ -66,5 +77,13 @@ describe('Log methods', () => { expect(id).toBe('get-log'); validateRequest({ query: { stream: true }, params: { logId } }); }); + + test('stream() returns undefined on 404 status code', async () => { + const logId = '404'; + + const res = await client.log(logId).stream(); + expect(res).toBeUndefined(); + validateRequest({ query: { stream: true }, params: { logId } }); + }); }); }); diff --git a/test/mock_server/routes/add_routes.ts b/test/mock_server/routes/add_routes.ts index f2c09eb96..9ab2e008c 100644 --- a/test/mock_server/routes/add_routes.ts +++ b/test/mock_server/routes/add_routes.ts @@ -43,7 +43,7 @@ const HANDLERS = { const context = maybeParseContextFromResourceId(resourceId); const delayMillis = context && context.delayMillis; setTimeout(() => { - res.send(payload); + res.status(responseStatusCode).send(payload); }, delayMillis || 0); }; }, diff --git a/test/runs.test.ts b/test/runs.test.ts index 1522fae3b..3336d2b17 100644 --- a/test/runs.test.ts +++ b/test/runs.test.ts @@ -2,7 +2,7 @@ import type { AddressInfo } from 'node:net'; import { setTimeout as setTimeoutNode } from 'node:timers/promises'; import c from 'ansi-colors'; -import { ApifyClient, ArgumentValidationError } from 'apify-client'; +import { ApifyApiError, ApifyClient, ArgumentValidationError } from 'apify-client'; import type { Page } from 'puppeteer'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; @@ -384,6 +384,27 @@ describe('Run methods', () => { validateRequest({ query: {}, params: { runId } }); }); + test.each(['dataset', 'keyValueStore', 'requestQueue', 'log'] as const)( + '%s().get() throws on 404 status code', + async (method) => { + const runId = '404'; + + const call = client.run(runId)[method]().get(); + await expect(call).rejects.toThrow(ApifyApiError); + await expect(call).rejects.toMatchObject({ statusCode: 404 }); + + await expect(page.evaluate((rId, m) => client.run(rId)[m]().get(), runId, method)).rejects.toThrow(); + }, + ); + + test('dataset().delete() throws on 404 status code', async () => { + const runId = '404'; + + const call = client.run(runId).dataset().delete(); + await expect(call).rejects.toThrow(ApifyApiError); + await expect(call).rejects.toMatchObject({ statusCode: 404 }); + }); + test('charge() works', async () => { const runId = 'some-run-id'; @@ -452,6 +473,21 @@ describe('Redirect run logs', () => { }); }); + describe('run.getStreamedLog missing run', () => { + test('logs warning instead of throwing when the run log answers 404', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const streamedLog = await client.run('404').getStreamedLog({ fromStart: true }); + streamedLog?.start(); + await expect(streamedLog?.stop()).resolves.not.toThrow(); + expect( + warnSpy.mock.calls.some( + ([msg]) => typeof msg === 'string' && msg.includes('Log redirection stopped due to error'), + ), + ).toBe(true); + warnSpy.mockRestore(); + }); + }); + describe('run.getStreamedLog ECONNRESET', () => { test('logs warning instead of throwing on error', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); diff --git a/test/schedules.test.ts b/test/schedules.test.ts index b1e93cf70..3c2ca1d08 100644 --- a/test/schedules.test.ts +++ b/test/schedules.test.ts @@ -1,7 +1,7 @@ import type { AddressInfo } from 'node:net'; import type { ScheduleCreateOrUpdateData } from 'apify-client'; -import { ApifyClient, ScheduleActions } from 'apify-client'; +import { ApifyApiError, ApifyClient, ScheduleActions } from 'apify-client'; import type { Page } from 'puppeteer'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'vitest'; @@ -145,5 +145,15 @@ describe('Schedule methods', () => { expect(browserRes).toEqual(asBrowserResult(res)); validateRequest({ query: {}, params: { scheduleId } }); }); + + test('getLog() throws on 404 status code', async () => { + const scheduleId = '404'; + + const call = client.schedule(scheduleId).getLog(); + await expect(call).rejects.toThrow(ApifyApiError); + await expect(call).rejects.toMatchObject({ statusCode: 404 }); + + await expect(page.evaluate((id) => client.schedule(id).getLog(), scheduleId)).rejects.toThrow(); + }); }); }); diff --git a/test/tasks.test.ts b/test/tasks.test.ts index fd37799c9..73275fee7 100644 --- a/test/tasks.test.ts +++ b/test/tasks.test.ts @@ -1,6 +1,6 @@ import type { AddressInfo } from 'node:net'; -import { ApifyClient } from 'apify-client'; +import { ApifyApiError, ApifyClient } from 'apify-client'; import type { Page } from 'puppeteer'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'vitest'; @@ -387,6 +387,16 @@ describe('Task methods', () => { validateRequest({ query: {}, params: { taskId } }); }); + test('getInput() throws on 404 status code', async () => { + const taskId = '404'; + + const call = client.task(taskId).getInput(); + await expect(call).rejects.toThrow(ApifyApiError); + await expect(call).rejects.toMatchObject({ statusCode: 404 }); + + await expect(page.evaluate((id) => client.task(id).getInput(), taskId)).rejects.toThrow(); + }); + test('updateInput() works', async () => { const taskId = 'some-task-id'; const input = { foo: 'bar' }; diff --git a/test/users.test.ts b/test/users.test.ts index 79312cca0..29ed5b48f 100644 --- a/test/users.test.ts +++ b/test/users.test.ts @@ -1,6 +1,6 @@ import type { AddressInfo } from 'node:net'; -import { ApifyClient } from 'apify-client'; +import { ApifyApiError, ApifyClient } from 'apify-client'; import type { Page } from 'puppeteer'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'vitest'; @@ -44,7 +44,7 @@ describe('User methods', () => { const userId = 'some-id'; const res = await client.user(userId).get(); - expect(res.id).toEqual('get-user'); + expect(res?.id).toEqual('get-user'); validateRequest({ query: {}, params: { userId } }); const browserRes = await page.evaluate((id) => client.user(id).get(), userId); @@ -54,7 +54,7 @@ describe('User methods', () => { test('get() with no userId', async () => { const res = await client.user().get(); - expect(res.id).toEqual('get-user'); + expect(res?.id).toEqual('get-user'); validateRequest({ query: {}, params: { userId: ME_USER_NAME_PLACEHOLDER } }); const browserRes = await page.evaluate((id) => client.user(id).get(), undefined); @@ -109,6 +109,16 @@ describe('User methods', () => { validateRequest({ query: {}, params: { userId } }); }); + test.each(['monthlyUsage', 'limits'] as const)('%s() throws on 404 status code', async (method) => { + const userId = '404'; + + const call = client.user(userId)[method](); + await expect(call).rejects.toThrow(ApifyApiError); + await expect(call).rejects.toMatchObject({ statusCode: 404 }); + + await expect(page.evaluate((id, m) => client.user(id)[m](), userId, method)).rejects.toThrow(); + }); + test('updateLimits() works', async () => { const userId = 'me'; const opts = { maxMonthlyUsageUsd: 1000, dataRetentionDays: 20 }; diff --git a/test/webhooks.test.ts b/test/webhooks.test.ts index c61338fb7..a466aada4 100644 --- a/test/webhooks.test.ts +++ b/test/webhooks.test.ts @@ -1,7 +1,7 @@ import type { AddressInfo } from 'node:net'; import type { WebhookUpdateData } from 'apify-client'; -import { ApifyClient } from 'apify-client'; +import { ApifyApiError, ApifyClient } from 'apify-client'; import type { Page } from 'puppeteer'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'vitest'; @@ -167,6 +167,16 @@ describe('Webhook methods', () => { }); }); + test('test() throws on 404 status code', async () => { + const webhookId = '404'; + + const call = client.webhook(webhookId).test(); + await expect(call).rejects.toThrow(ApifyApiError); + await expect(call).rejects.toMatchObject({ statusCode: 404 }); + + await expect(page.evaluate((id) => client.webhook(id).test(), webhookId)).rejects.toThrow(); + }); + test('listDispatches() works', async () => { const webhookId = 'webhook_id'; const opts = {