Skip to content
Merged
22 changes: 22 additions & 0 deletions docs/02_concepts/02_error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. <ApiLink to="class/KeyValueStoreClient#getRecord">`getRecord()`</ApiLink> and <ApiLink to="class/RequestQueueClient#getRequest">`getRequest()`</ApiLink> do the same for a missing record or request, <ApiLink to="class/KeyValueStoreClient#recordExists">`recordExists()`</ApiLink> answers `false`, and `lastRun()` resolves to `undefined` for an Actor or task with no matching run.

Everywhere else a 404 throws a <ApiLink to="class/NotFoundError">`NotFoundError`</ApiLink>. 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 <ApiLink to="class/ArgumentValidationError">`ArgumentValidationError`</ApiLink> 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`.
Expand Down
46 changes: 46 additions & 0 deletions docs/04_upgrading/upgrading_v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ApiLink to="class/RunClient#waitForFinish">`waitForFinish()`</ApiLink> and <ApiLink to="class/ActorClient#call">`call()`</ApiLink>, 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 <ApiLink to="class/ApifyApiError">`ApifyApiError`</ApiLink> 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: <ApiLink to="class/DatasetClient#getStatistics">`DatasetClient.getStatistics()`</ApiLink>, <ApiLink to="class/UserClient#monthlyUsage">`UserClient.monthlyUsage()`</ApiLink>, <ApiLink to="class/UserClient#limits">`UserClient.limits()`</ApiLink>, <ApiLink to="class/ScheduleClient#getLog">`ScheduleClient.getLog()`</ApiLink>, <ApiLink to="class/TaskClient#getInput">`TaskClient.getInput()`</ApiLink> and <ApiLink to="class/WebhookClient#test">`WebhookClient.test()`</ApiLink>. 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: <ApiLink to="class/KeyValueStoreClient#getRecord">`KeyValueStoreClient.getRecord()`</ApiLink> and <ApiLink to="class/RequestQueueClient#getRequest">`RequestQueueClient.getRequest()`</ApiLink> still resolve to `undefined`, and <ApiLink to="class/KeyValueStoreClient#recordExists">`KeyValueStoreClient.recordExists()`</ApiLink> still answers `false`. <ApiLink to="class/ActorClient#lastRun">`ActorClient.lastRun()`</ApiLink> and <ApiLink to="class/TaskClient#lastRun">`TaskClient.lastRun()`</ApiLink> also keep resolving to `undefined`, where having no run yet is an ordinary outcome.

<ApiLink to="class/LogClient#stream">`LogClient.stream()`</ApiLink> follows the same rule as `get()`: `client.log(id).stream()` resolves to `undefined` on a 404, `run.log().stream()` throws.

A <ApiLink to="class/StreamedLog">`StreamedLog`</ApiLink> 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

<ApiLink to="class/UserClient#get">`UserClient.get()`</ApiLink> is typed `Promise<User | undefined>`. 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

<ApiLink to="class/ActorClient#version">`ActorClient.version()`</ApiLink>, <ApiLink to="class/ActorClient#build">`ActorClient.build()`</ApiLink> and <ApiLink to="class/ActorVersionClient#envVar">`ActorVersionClient.envVar()`</ApiLink> now throw an <ApiLink to="class/ArgumentValidationError">`ArgumentValidationError`</ApiLink> 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 <ApiLink to="interface/Dataset">`Dataset`</ApiLink>, <ApiLink to="interface/KeyValueStore">`KeyValueStore`</ApiLink>, <ApiLink to="interface/Build">`Build`</ApiLink>, <ApiLink to="interface/ActorRun">`ActorRun`</ApiLink>, <ApiLink to="interface/Webhook">`Webhook`</ApiLink>, <ApiLink to="interface/Schedule">`Schedule`</ApiLink>, <ApiLink to="interface/Task">`Task`</ApiLink>, <ApiLink to="interface/RequestQueue">`RequestQueue`</ApiLink>, and <ApiLink to="interface/User">`User`</ApiLink>, 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`.
Expand Down
16 changes: 7 additions & 9 deletions docs/public-api/apify-client.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -2421,7 +2421,7 @@ export class DatasetClient<Data extends Record<string | number, any> = Record<st
delete(): Promise<void>;
downloadItems(format: `${DownloadItemsFormat}`, options?: DatasetClientDownloadItemsOptions): Promise<Buffer>;
get(): Promise<Dataset | undefined>;
getStatistics(): Promise<DatasetStatistics | undefined>;
getStatistics(): Promise<DatasetStatistics>;
listItems(options?: DatasetClientListItemOptions): PaginatedIterator<Data>;
pushItems(items: Data | Data[] | string | string[]): Promise<void>;
update(newFields: DatasetClientUpdateOptions): Promise<Dataset>;
Expand Down Expand Up @@ -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<void>;
// (undocumented)
protected _get<T, R>(schema: z.ZodType, options?: T, timeoutMillis?: number): Promise<R | undefined>;
// (undocumented)
protected _update<T, R>(schema: z.ZodType, newFields: T, timeoutMillis?: number): Promise<R>;
Expand Down Expand Up @@ -3680,7 +3678,7 @@ export class ScheduleClient extends ResourceClient {
constructor(options: ApiClientSubResourceOptions);
delete(): Promise<void>;
get(): Promise<Schedule | undefined>;
getLog(): Promise<ScheduleInvoked[] | undefined>;
getLog(): Promise<ScheduleInvoked[]>;
update(newFields: ScheduleCreateOrUpdateData): Promise<Schedule>;
}

Expand Down Expand Up @@ -3803,7 +3801,7 @@ export class TaskClient extends ResourceClient {
call(input?: Dictionary, options?: TaskCallOptions): Promise<ActorRun>;
delete(): Promise<void>;
get(): Promise<Task | undefined>;
getInput(): Promise<Dictionary | Dictionary[] | undefined>;
getInput(): Promise<Dictionary | Dictionary[]>;
lastRun(options?: TaskLastRunOptions): RunClient;
publish(): Promise<Task>;
runs(): RunCollectionClient;
Expand Down Expand Up @@ -3951,9 +3949,9 @@ export interface User extends Omit<Schemas['UserPrivateInfo'], keyof UserRePoint
// @public
export class UserClient extends ResourceClient {
constructor(options: ApiClientSubResourceOptions);
get(): Promise<User>;
limits(): Promise<AccountAndUsageLimits | undefined>;
monthlyUsage(): Promise<MonthlyUsage | undefined>;
get(): Promise<User | undefined>;
limits(): Promise<AccountAndUsageLimits>;
monthlyUsage(): Promise<MonthlyUsage>;
updateLimits(options: LimitsUpdateOptions): Promise<void>;
}

Expand Down Expand Up @@ -4031,7 +4029,7 @@ export class WebhookClient extends ResourceClient {
delete(): Promise<void>;
dispatches(): WebhookDispatchCollectionClient;
get(): Promise<Webhook | undefined>;
test(): Promise<WebhookDispatch | undefined>;
test(): Promise<WebhookDispatch>;
update(newFields: WebhookUpdateData): Promise<Webhook>;
}

Expand Down
14 changes: 11 additions & 3 deletions src/base/resource_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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<T, R>(
schema: z.ZodType,
options: T = {} as T,
Expand All @@ -38,7 +42,7 @@ export class ResourceClient extends ApiClient {
const response = await this.httpClient.call(requestOpts);
return parseResponse<R>(response, schema);
} catch (err) {
catchNotFoundOrThrow(err as ApifyApiError);
catchNotFoundForResourceOrThrow(err as ApifyApiError, this.id);
}

return undefined;
Expand All @@ -55,6 +59,10 @@ export class ResourceClient extends ApiClient {
return parseResponse<R>(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<void> {
try {
await this.httpClient.call({
Expand All @@ -64,7 +72,7 @@ export class ResourceClient extends ApiClient {
timeout: timeoutMillis,
});
} catch (err) {
catchNotFoundOrThrow(err as ApifyApiError);
catchNotFoundForResourceOrThrow(err as ApifyApiError, this.id);
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/resource_clients/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion src/resource_clients/actor_version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/resource_clients/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
20 changes: 6 additions & 14 deletions src/resource_clients/dataset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,21 @@ 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,
MEDIUM_TIMEOUT_MILLIS,
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';
import {
anyObjectSchema,
applyQueryParamsToUrl,
cast,
catchNotFoundOrThrow,
isNonArrayObject,
paginationOptionsShape,
parseArgument,
Expand Down Expand Up @@ -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<DatasetStatistics | undefined> {
const requestOpts: ApifyRequestConfig = {
async getStatistics(): Promise<DatasetStatistics> {
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());
}

/**
Expand Down
20 changes: 11 additions & 9 deletions src/resource_clients/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<string | undefined> {
Expand All @@ -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;
Expand All @@ -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<Readable | undefined> {
Expand All @@ -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;
Expand Down Expand Up @@ -198,11 +200,11 @@ export class StreamedLog {
* Get log stream from response and redirect it to another log.
*/
private async streamLog(): Promise<void> {
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();
Expand Down
Loading
Loading