gohugoio/hugo · error

invalid timeout for resource

Error message

invalid timeout for resource %s: %w

What it means

Wrapped error from `Client.FromRemote` (remote.go:212-216) when the `timeout` option value cannot be parsed as a duration via `cast.ToDurationE`. The timeout is extracted from the options map before the request is built. Valid forms include Go duration strings (`"30s"`), or numeric values that cast can interpret; anything else triggers this.

Solutions

  1. Use a Go-style duration string: `(dict "timeout" "30s")` or `"500ms"`.
  2. Quote duration literals in TOML: `timeout = "30s"`.
  3. Confirm the frontmatter field holds a string or integer, not a list/map.
  4. Avoid ambiguous forms like `"30 seconds"` -- only `s`, `m`, `ms`, `h` suffixes parse.

Example fix

// template -- before
{{ $r := resources.GetRemote $url (dict "timeout" "30 seconds") }}

// after
{{ $r := resources.GetRemote $url (dict "timeout" "30s") }}
Defensive patterns

Strategy: validation

Validate before calling

import (
	"fmt"
	"time"
	"github.com/spf13/cast"
)
func validateDuration(v any) error {
	if _, err := cast.ToDurationE(v); err != nil {
		return fmt.Errorf("invalid duration %v: %w", v, err)
	}
	return nil
}

Prevention

When it happens

Trigger: Calling `resources.GetRemote` with `(dict "timeout" <bad>)` where `<bad>` is e.g. a struct, a list, an unparseable string like `"30 sec"`, or a bool. `cast.ToDurationE` accepts things like `30000000000` (ns int) or `"5s"` but rejects free-form text.

Common situations: Typing `timeout = "30"` expecting seconds (cast reads it as 30 nanoseconds, not an error, but functionally wrong) vs `timeout = "30 seconds"` (parse error); passing a frontmatter value coerced to the wrong type; mixing YAML duration syntax (`timeout: 30s`) with TOML unquoted (`timeout = 30s` triggers parse error in some loaders).

Understand the failure class

Related errors


AI-assisted analysis of gohugoio/hugo@52c9bd7908 (2026-08-09). Data as JSON: /api/errors/ec51fc6fc8f0722e. Report an issue: GitHub.

Appendix: source

Thrown at resources/resource_factories/create/remote.go:215

	if err != nil {
		return nil, fmt.Errorf("failed to parse URL for resource %s: %w", uri, err)
	}

	method := "GET"
	if s, _, ok := hmaps.LookupEqualFold(optionsm, "method"); ok {
		method = strings.ToUpper(s.(string))
	}
	isHeadMethod := method == "HEAD"

	optionsm = maps.Clone(optionsm)

	// Extract timeout before computing cache keys: it only affects fetch behaviour,
	// not the cached content, so it must not influence the cache key.
	var perRequestTimeout time.Duration
	if v, k, ok := hmaps.LookupEqualFold(optionsm, "timeout"); ok {
		d, err := cast.ToDurationE(v)
		if err != nil {
			return nil, fmt.Errorf("invalid timeout for resource %s: %w", uri, err)
		}
		perRequestTimeout = d
		delete(optionsm, k)
	}

	userKey, optionsKey := remoteResourceKeys(uri, optionsm)

	// A common pattern is to use the key in the options map as
	// a way to control cache eviction,
	// so make sure we use any user provided key as the file cache key,
	// but the auto generated and more stable key for everything else.
	filecacheKey := userKey

	return c.rs.ResourceCache.CacheResourceRemote.GetOrCreate(optionsKey, func(key string) (resource.Resource, error) {
		options, err := decodeRemoteOptions(optionsm)
		if err != nil {
			return nil, fmt.Errorf("failed to decode options for resource %s: %w", uri, err)
		}

View on GitHub (pinned to 52c9bd7908)