grafana/k6 · error

unmarshalling options for SDK

Error message

unmarshalling options for SDK: %w

What it means

Returned by Client.StartLocalExecution (internal/cloudapi/provisioning/api.go:201) when json.Unmarshal cannot decode req.Options (a json.RawMessage of pre-marshalled lib.Options) into map[string]any before handing it to the cloud SDK. It fails when Options is empty/nil ('unexpected end of JSON input'), is valid JSON but not an object (array/string/number → type error), or contains a syntax error. This is a caller-side contract violation: the field must be the output of marshalling lib.Options.

Solutions

  1. Ensure req.Options is produced by json.Marshal of a lib.Options value (or at minimum is a valid JSON object) before calling StartLocalExecution.
  2. If Options can legitimately be absent, pass explicit 'null' or '{}' consistently with what the backend expects rather than an empty slice.
  3. Add a json.Valid(req.Options) plus object-shape check at the construction site to fail with a clearer message (see defense code).
  4. Log the first ~200 bytes of Options on failure to spot double-encoding or truncation.

Example fix

// before
resp, err := c.StartLocalExecution(ctx, loadTestID, provisioning.StartLocalExecutionRequest{
	Options: rawOpts, // rawOpts assumed valid, may be nil
})

// after — guarantee the contract at construction
marshalled, err := json.Marshal(opts) // opts is lib.Options
if err != nil {
	return fmt.Errorf("marshalling options: %w", err)
}
if !json.Valid(marshalled) {
	return errors.New("internal error: marshalled options are not valid JSON")
}
resp, err := c.StartLocalExecution(ctx, loadTestID, provisioning.StartLocalExecutionRequest{Options: marshalled})
Defensive patterns

Strategy: validation

Validate before calling

// Validate Options is a JSON object before calling StartLocalExecution.
func validJSONObject(raw json.RawMessage) bool {
	if !json.Valid(raw) {
		return false
	}
	var probe map[string]any
	return json.Unmarshal(raw, &probe) == nil
}

if !validJSONObject(req.Options) {
	return errors.New("StartLocalExecutionRequest.Options must be marshalled lib.Options JSON (an object)")
}

Type guard

func isJSONObject(raw json.RawMessage) bool {
	trimmed := bytes.TrimSpace(raw)
	return len(trimmed) > 0 && trimmed[0] == '{' && json.Valid(trimmed)
}

Try / catch

if _, err := c.StartLocalExecution(ctx, id, req); err != nil {
	var synErr *json.SyntaxError
	if errors.As(err, &synErr) || strings.Contains(err.Error(), "cannot unmarshal") {
		// caller passed bad Options — fix construction, do not retry
		log.Errorf("Options payload invalid; first bytes: %.80s", req.Options)
	}
}

Prevention

When it happens

Trigger: Calling StartLocalExecution with Options nil or empty; passing JSON that is an array or scalar rather than an object; passing already-double-encoded JSON (a quoted string containing JSON); passing truncated marshal output.

Common situations: New call sites in cmd/ or xk6 extensions building StartLocalExecutionRequest by hand instead of via json.Marshal(lib.Options); refactors that pass an intermediate struct; tests with hand-written option strings.

Understand the failure class

Background: JSON parse error: "Unexpected token" / "not valid JSON" / "failed to parse" — what JSON parsers are really complaining about — this error's family across 45 libraries.

Related errors


AI-assisted analysis of grafana/k6@01ffac6f24 (2026-08-18). Data as JSON: /api/errors/b8f9e4291257aef9. Report an issue: GitHub.

Appendix: source

Thrown at internal/cloudapi/provisioning/api.go:201

	}
}

// StartLocalExecution starts a local-execution test run via the
// provisioning API. It generates a K6-Idempotency-Key header for
// safe retries. The caller provides options as pre-marshalled JSON.
func (c *Client) StartLocalExecution(
	ctx context.Context, loadTestID int64, req StartLocalExecutionRequest,
) (*StartLocalExecutionResponse, error) {
	// Generate idempotency key: 8 random bytes hex-encoded (16 chars).
	var key [8]byte
	if _, err := rand.Read(key[:]); err != nil {
		return nil, fmt.Errorf("generating idempotency key: %w", err)
	}

	// SDK adapter: unmarshal json.RawMessage → map[string]any.
	var opts map[string]any
	if err := json.Unmarshal(req.Options, &opts); err != nil {
		return nil, fmt.Errorf("unmarshalling options for SDK: %w", err)
	}

	maxVUs, err := toInt32(req.MaxVUs)
	if err != nil {
		return nil, fmt.Errorf("max_vus: %w", err)
	}
	totalDuration, err := toInt32(req.TotalDuration)
	if err != nil {
		return nil, fmt.Errorf("total_duration: %w", err)
	}

	sdkReq := k6cloud.NewStartLocalExecutionTestRequest(opts, maxVUs, totalDuration)

	if req.ArchiveSize > 0 {
		v, err := toInt32(req.ArchiveSize)
		if err != nil {
			return nil, fmt.Errorf("archive_size: %w", err)
		}

View on GitHub (pinned to 01ffac6f24)