Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,39 @@ All notable changes to the Apify Go client are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.14.0] - 2026-10-30

### Added

- `RunListOptions.Deployment` and `StoreListOptions.IncludeInputSchema` list parameters.
- `UserClient` limits and monthly usage methods work on `client.User(id)` too, not only `Me()`.
- `StreamedLog` (`RunClient.NewStreamedLog`) redirects a run's log to a writer; `ActorClient.Call`
and `CallRaw` use it by default, `ActorStartOptions.Log` configures or disables it.
- `RunClient.IterateDatasetItems` and `IterateRunDatasetItems[T]` iterate a run's dataset while it
is still being filled.
- `KeyValueStoreClient.SetRecordRawWithOptions` and `SetRecordJSONWithOptions`.
- `RequestQueueClient.PaginateRequestsWithOptions` iterates with `Limit`, `Cursor` and `Filter`.
- Typed URL fields on the models (`ConsoleURL`, `ItemsPublicURL`, `RecordPublicURL`,
`User.Profile`, `WebhookDispatch.Webhook` and others), returned normalized.

### Changed

- Bumped `APISpecVersion` to `v2-2026-10-29T100357Z` and `ClientVersion` to `0.14.0`.
- **Breaking:** `ActorVersionCollectionClient.List` and `Iterate` take no options.
- The typed URL fields are no longer in the `Extra` map of their model.
- `ListRequestsOptions.ExclusiveStartID` is deprecated in favor of `Cursor`.
- `RunClient.GetStreamedLog` is deprecated: it returns the raw stream, use `NewStreamedLog`.
- `Call` starts the run without a client-side timeout.
- Requests that are not idempotent (`Start`, `Build`, `PushItems`) are not retried after a timeout.
- Some `KeyValueStoreClient` and `DatasetClient` methods use the "long" timeout tier.
- Storages, log and public URLs of a `LastRun` carry its `status` and `origin` filters.
- Documented that `GetRecord` and `SetRecordRaw` handle raw bytes.

### Fixed

- `DatasetClient.PushItems` rejects values that are not a JSON object or an array of objects.
- Methods that lacked an ID check now fail on an empty or dot-segment ID.

## [0.13.1] - 2026-10-26

### Changed
Expand Down
58 changes: 41 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ request queues, tasks, schedules, webhooks, the store, users and logs).
- Transparent authentication, `User-Agent` header, retries with exponential backoff, and
per-request timeouts applied to every call.
- Replaceable HTTP transport (the `HTTPBackend` interface) with a default implementation.
- Convenience helpers: start-and-wait, build/run polling, lazy store and request-queue
iterators, dataset export, signed public URLs, request-queue locking, and more.
- Convenience helpers: start-and-wait with live log redirection, build/run polling, lazy store
and request-queue iterators, live iteration of a running Actor's dataset items, dataset
export, signed public URLs, request-queue locking, and more.
- Forward-compatible models that keep unknown API fields in an `Extra` map.
- A single third-party dependency (`github.com/andybalholm/brotli`, used for Brotli
request-body compression); everything else is the Go standard library.
Expand Down Expand Up @@ -124,18 +125,27 @@ func main() {
### Timeouts

Every request the client sends is assigned one of three timeout tiers, matching the kind of
operation its method performs — `short` for a metadata read/write, `medium` for a listing,
batch or trigger call, `long` for a download, upload or streaming call — except the polling
requests behind `WaitForFinish`/`Call`, which run with no client-imposed timeout of their own
(the pure time budget `waitSecs`/`timeoutSecs` already bounds the overall wait). The duration of
each tier is configured on the client (see the table above); a timeout still grows with each
retry, capped at `WithTimeoutMax`.

A single call that needs a different bound already has full control via the
`context.Context` every method takes: canceling it, or giving it an earlier deadline with
`context.WithTimeout`, overrides whichever tier the method would otherwise use (the client's
internal per-attempt deadline is always the *later* of the two to apply — i.e.
`context.WithTimeout` composes with whichever is sooner).
operation its method performs: `short` for a metadata read/write, `medium` for a listing, batch or
trigger call, `long` for a download, upload or streaming call. The duration of each tier is
configured on the client (see the table above); a timeout still grows with each retry, capped at
`WithTimeoutMax`.

Two kinds of request have no client-side timeout of their own: the polling requests behind
`WaitForFinish` and `Call`, and the start request of `Call`. `waitSecs` bounds how long `Call` and
`WaitForFinish` wait for the run, and the `context.Context` bounds the whole call.

Every method takes a `context.Context`, and its deadline or cancellation can only shorten a
request: the request ends when its tier's timeout or the context's deadline passes, whichever is
sooner. To give one call a shorter bound than its tier, pass a context created with
`context.WithTimeout`; a context cannot extend a tier, so raise the tier (`WithTimeoutShort`,
`WithTimeoutMedium`, `WithTimeoutLong`, `WithTimeoutMax`) for a call that needs longer.

A request that timed out is retried like any other transient failure, with a longer timeout,
except one that is not idempotent, where the timed-out attempt may already have taken effect:
starting a run (`Start`, `StartRaw`, a task's `Start`), building an Actor (`Build`), pushing
dataset items (`PushItems`) and, when `SetRecordOptions.DoNotRetryTimeouts` is set, storing a
record. Those fail on the first timeout. The other failures (a 429 or 5xx response, a network
error) are retried for them as well.

A method with a `waitForFinishSecs`/`WaitForFinish` parameter that asks the API to hold the
response (e.g. `RunClient.GetWithWait`, `ActorClient.Start`'s `WaitForFinish` option) extends
Expand All @@ -158,6 +168,20 @@ Apify platform. It is driven solely by the `APIFY_IS_AT_HOME` environment variab
variable the JavaScript reference client reads); if it is set to a non-empty value, the flag is
`true`, otherwise `false`.

### URL fields

The fields the API specification declares as URLs (`consoleUrl`, `containerUrl`, `itemsPublicUrl`,
`keysPublicUrl`, `recordsPublicUrl`, `recordPublicUrl`, `standbyUrl`, the webhook `requestUrl`, a
run's `output` links, a Store Actor's `url` and `userPictureUrl`, and a user profile's
`pictureUrl` and `websiteUrl`) are returned in a normalized form, close to the WHATWG URL serialization that the JavaScript
client returns: the scheme and host are lowercased, a non-ASCII host becomes punycode, an IPv4 or
IPv6 host is written canonically, a default port is dropped, an empty path becomes `/`, dot
segments are resolved, and unsafe characters are percent-encoded. It covers the http(s), ws(s),
ftp and file URLs the API returns; it does not apply the full Unicode (UTS #46) mapping to a
non-ASCII host. Compare a field against a stored URL after normalizing the stored one the same
way (for example by reading it back from the API). A value that is not an absolute URL makes
decoding the response fail.

## Resource clients

| Accessor | Returns | Purpose |
Expand All @@ -173,7 +197,7 @@ variable the JavaScript reference client reads); if it is set to a non-empty val
| `client.Webhooks()` / `client.Webhook(id)` | `*WebhookCollectionClient` / `*WebhookClient` | List/create webhooks; manage and test a single webhook. |
| `client.WebhookDispatches()` / `client.WebhookDispatch(id)` | `*WebhookDispatchCollectionClient` / `*WebhookDispatchClient` | List/inspect webhook dispatches. |
| `client.Store()` | `*StoreCollectionClient` | Browse and iterate the Apify Store. |
| `client.Me()` / `client.User(id)` | `*UserClient` | Account details, usage and limits (account scope for `Me()`). |
| `client.Me()` / `client.User(id)` | `*UserClient` | Account details, usage and limits (for your own account or your organization with `User(id)`). |
| `client.Log(buildOrRunID)` | `*LogClient` | Read or stream a build/run log. |

## Error handling
Expand Down Expand Up @@ -258,7 +282,7 @@ func main() {

- `apify.ClientVersion` — the semantic version of this library.
- `apify.APISpecVersion` — the Apify OpenAPI spec version this client was built against
(`v2-2026-10-26T112451Z`).
(`v2-2026-10-29T100357Z`).

### Releasing

Expand Down Expand Up @@ -295,7 +319,7 @@ APIFY_TOKEN=<your-token> go run ./examples/run_store_actor
| `run_store_actor` | Run a Store Actor, wait for it, and read its default dataset. |
| `run_and_last_run_storages` | Start a run, wait for it to finish, then fetch the Actor's last run and its storages. |
| `iterate_store` | Lazily iterate Actors in the Apify Store. |
| `log_redirection` | Start an Actor and stream its log in real time. |
| `log_redirection` | Run an Actor and redirect its log to stdout in real time. |
| `create_build_run_actor` | Create an Actor, build it, run it, and print the run log. |
| `public_build_no_token` | Fetch a public Actor's default build with an unauthenticated client (no token). |

Expand Down
61 changes: 49 additions & 12 deletions actor.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/base64"
"encoding/json"
"net/http"
"time"
)

// ActorStartOptions configures starting an Actor or task run
Expand Down Expand Up @@ -43,6 +44,11 @@ type ActorStartOptions struct {
// actor-memory-limit-exceeded as well and never starts, so WaitForResourcesIndefinitely
// retries it forever.
WaitForResources *int64
// Log configures the redirection of the run's log that [ActorClient.Call] and
// [ActorClient.CallRaw] do while they wait (see [StreamedLog]). Nil redirects it to os.Stderr
// with the default prefix; set Log.Disabled to turn the redirection off. Start and StartRaw
// ignore it.
Log *StreamedLogOptions
}

func (o ActorStartOptions) apply(q *QueryParams) {
Expand Down Expand Up @@ -133,7 +139,7 @@ func (c *ActorClient) Start(ctx context.Context, input any, options ActorStartOp
if err != nil {
return ActorRun{}, err
}
return c.startWithBody(ctx, body, contentTypeJSON, options)
return c.startWithBody(ctx, body, contentTypeJSON, options, c.startTimeout(options))
}

// StartRaw starts the Actor with a raw request body instead of a JSON-serializable value.
Expand All @@ -144,46 +150,70 @@ func (c *ActorClient) Start(ctx context.Context, input any, options ActorStartOp
// handles the serialization. Mirrors the reference client, whose Actor input accepts a plain
// object, an array of them, or raw bytes.
func (c *ActorClient) StartRaw(ctx context.Context, input []byte, options ActorStartOptions) (ActorRun, error) {
return c.startWithBody(ctx, input, "application/octet-stream", options)
return c.startWithBody(ctx, input, "application/octet-stream", options, c.startTimeout(options))
}

// startTimeout is the timeout of a start request: the "medium" tier, extended to cover the time
// the API may hold the response for options.WaitForFinish.
func (c *ActorClient) startTimeout(options ActorStartOptions) time.Duration {
return extendForServerWait(c.ctx.mediumTimeout(), options.WaitForFinish)
}

// startWithBody is the shared implementation of Start and StartRaw: it applies the run-start
// options as query parameters and posts the given body, falling back to defaultContentType only
// when the caller did not set options.ContentType.
func (c *ActorClient) startWithBody(ctx context.Context, body []byte, defaultContentType string, options ActorStartOptions) (ActorRun, error) {
// when the caller did not set options.ContentType. timeout bounds each start request.
//
// A start request that times out is not retried: it may already have created the run, and a
// retry would then start a second, billed one.
func (c *ActorClient) startWithBody(ctx context.Context, body []byte, defaultContentType string, options ActorStartOptions, timeout time.Duration) (ActorRun, error) {
params := NewQueryParams()
options.apply(params)
contentType := defaultContentType
if options.ContentType != nil && *options.ContentType != "" {
contentType = *options.ContentType
}
timeout := extendForServerWait(c.ctx.mediumTimeout(), options.WaitForFinish)
return startWaitingForResources(ctx, options.WaitForResources, func() (ActorRun, error) {
return postWithBody[ActorRun](ctx, c.ctx, "runs", params, body, contentType, timeout)
return postWithBody[ActorRun](ctx, c.ctx.withoutTimeoutRetry(), "runs", params, body, contentType, timeout)
})
}

// Call starts the Actor and waits (client-side polling) for it to finish.
//
// waitSecs bounds the wait; nil waits indefinitely. It returns the finished run (or the
// still-running run if the wait budget was exhausted).
//
// While it waits, it redirects the run's log to os.Stderr (or to options.Log.Output), prefixed
// with the Actor name and the run ID, like the reference client's call(): see [StreamedLog], and
// set options.Log.Disabled to turn it off.
//
// Neither the start request nor the polls have a client-side timeout of their own (matching the
// reference client's call(), whose default timeout is noTimeout): ctx bounds the whole call.
func (c *ActorClient) Call(ctx context.Context, input any, options ActorStartOptions, waitSecs *int64) (ActorRun, error) {
run, err := c.Start(ctx, input, options)
body, err := marshalInput(input)
if err != nil {
return ActorRun{}, err
}
// Use the root client's run client so polling targets the canonical run route.
return c.root.Run(run.ID).WaitForFinish(ctx, waitSecs)
return c.callWithBody(ctx, body, contentTypeJSON, options, waitSecs)
}

// CallRaw starts the Actor with a raw request body ([ActorClient.StartRaw]) and waits for it to
// finish, exactly like [ActorClient.Call] but for a non-JSON input.
func (c *ActorClient) CallRaw(ctx context.Context, input []byte, options ActorStartOptions, waitSecs *int64) (ActorRun, error) {
run, err := c.StartRaw(ctx, input, options)
return c.callWithBody(ctx, input, "application/octet-stream", options, waitSecs)
}

// callWithBody is the shared implementation of Call and CallRaw.
func (c *ActorClient) callWithBody(ctx context.Context, body []byte, defaultContentType string, options ActorStartOptions, waitSecs *int64) (ActorRun, error) {
run, err := c.startWithBody(ctx, body, defaultContentType, options, noRequestTimeout)
if err != nil {
return ActorRun{}, err
}
return c.root.Run(run.ID).WaitForFinish(ctx, waitSecs)
// Use the root client's run client so polling targets the canonical run route.
runClient := c.root.Run(run.ID)
streamed := startLogRedirection(ctx, runClient, options.Log)
finished, err := runClient.WaitForFinish(ctx, waitSecs)
stopLogRedirection(ctx, streamed, options.Log)
return finished, err
}

// Build builds the given version of the Actor and returns the created build.
Expand All @@ -195,14 +225,18 @@ func (c *ActorClient) Build(ctx context.Context, versionNumber string, options A
AddBool("useCache", options.UseCache).
AddInt("waitForFinish", options.WaitForFinish)
timeout := extendForServerWait(c.ctx.mediumTimeout(), options.WaitForFinish)
return postWithBody[Build](ctx, c.ctx, "builds", params, nil, contentTypeJSON, timeout)
// A build that timed out is not retried: it may already have been created.
return postWithBody[Build](ctx, c.ctx.withoutTimeoutRetry(), "builds", params, nil, contentTypeJSON, timeout)
}

// DefaultBuild resolves the Actor's default build and returns a client for it.
//
// waitForFinish optionally bounds how long (in seconds) the API waits for the build to
// finish before responding, matching the reference client's defaultBuild(options).
func (c *ActorClient) DefaultBuild(ctx context.Context, waitForFinish *int64) (*BuildClient, error) {
if c.ctx.idErr != nil {
return nil, c.ctx.idErr
}
params := NewQueryParams()
params.AddInt("waitForFinish", waitForFinish)
url := params.applyToURL(c.ctx.subURL("builds/default"))
Expand Down Expand Up @@ -237,6 +271,9 @@ func (c *ActorClient) ValidateInput(ctx context.Context, input any) (json.RawMes
// On success it returns the raw JSON validation result from the API (a JSON object reporting
// whether the input is valid and, if not, the schema violations).
func (c *ActorClient) ValidateInputForBuild(ctx context.Context, input any, build string) (json.RawMessage, error) {
if c.ctx.idErr != nil {
return nil, c.ctx.idErr
}
body, err := json.Marshal(input)
if err != nil {
return nil, err
Expand Down
24 changes: 10 additions & 14 deletions actor_version.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,18 @@ func newActorVersionCollectionClient(hc *httpClient, actorURL string) *ActorVers
return &ActorVersionCollectionClient{ctx: newCollectionContext(hc, actorURL, "versions")}
}

// List lists the Actor's versions.
func (c *ActorVersionCollectionClient) List(ctx context.Context, options ListOptions) (PaginationList[ActorVersion], error) {
params := NewQueryParams()
options.apply(params)
return listResource[ActorVersion](ctx, c.ctx, "", params)
// List lists the Actor's versions. The endpoint takes no pagination parameters (per the OpenAPI
// specification, it always returns every version in one response), so neither does this method.
func (c *ActorVersionCollectionClient) List(ctx context.Context) (PaginationList[ActorVersion], error) {
return listResource[ActorVersion](ctx, c.ctx, "", NewQueryParams())
}

// Iterate returns a lazy iterator over the Actor's versions. The versions endpoint ignores
// offset/limit/desc (per the OpenAPI specification, it always returns every version in one
// response), so options and chunkSize are accepted only for interface symmetry with the other
// collections' Iterate and have no effect; the iterator drains a single page. Mirrors the
// reference client's versions().list(), which likewise exposes no pagination options.
func (c *ActorVersionCollectionClient) Iterate(options ListOptions, chunkSize *int64) *ListIterator[ActorVersion] {
return newUnpaginatedIterator(func(ctx context.Context) (PaginationList[ActorVersion], error) {
return c.List(ctx, options)
})
// Iterate returns a lazy iterator over the Actor's versions. The versions endpoint is not
// offset-paginated (it always returns every version in one response), so there is no
// Limit/chunkSize control; the iterator drains that one page. Mirrors the reference client's
// versions().list(), which likewise exposes no pagination options.
func (c *ActorVersionCollectionClient) Iterate() *ListIterator[ActorVersion] {
return newUnpaginatedIterator(c.List)
}

// Create creates a new Actor version. version is any JSON-serializable version definition.
Expand Down
Loading
Loading