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 = {