gastownhall/beads · error

failed to parse milestones response: %w

Error message

failed to parse milestones response: %w

What it means

After FetchMilestones receives a successful HTTP response from /projects/:id/milestones, the body is unmarshaled into []Milestone. This error wraps the json.Unmarshal failure when the body is not a valid JSON array of milestones or contains values that conflict with the Milestone struct. The HTTP call itself succeeded, so the payload is malformed or unexpected.

Source

Thrown at internal/gitlab/client.go:430

// FetchMilestones retrieves milestones from the project with optional state filter.
// state can be: "active", "closed", or "" (all).
func (c *Client) FetchMilestones(ctx context.Context, state string) ([]Milestone, error) {
	params := map[string]string{
		"per_page": strconv.Itoa(MaxPageSize),
	}
	if state != "" {
		params["state"] = state
	}

	urlStr := c.buildURL("/projects/"+c.projectPath()+"/milestones", params)
	respBody, _, err := c.doRequest(ctx, http.MethodGet, urlStr, nil)
	if err != nil {
		return nil, fmt.Errorf("failed to fetch milestones: %w", err)
	}

	var milestones []Milestone
	if err := json.Unmarshal(respBody, &milestones); err != nil {
		return nil, fmt.Errorf("failed to parse milestones response: %w", err)
	}

	return milestones, nil
}

// FetchMilestoneByIID retrieves a single milestone by its project-scoped IID.
// Returns nil if no milestone matches the given IID.
func (c *Client) FetchMilestoneByIID(ctx context.Context, iid int) (*Milestone, error) {
	params := map[string]string{
		"iids[]": strconv.Itoa(iid),
	}

	urlStr := c.buildURL("/projects/"+c.projectPath()+"/milestones", params)
	respBody, _, err := c.doRequest(ctx, http.MethodGet, urlStr, nil)
	if err != nil {
		return nil, fmt.Errorf("failed to fetch milestone by IID %d: %w", iid, err)
	}

View on GitHub (pinned to 71377f2769)

Solutions

  1. Log the raw body and the wrapped json.SyntaxError/*json.UnmarshalTypeError to see what the server actually returned
  2. Verify the base URL points at the GitLab API host, not the web UI or an SSO redirect
  3. Align the Milestone struct json tags/types with your GitLab version's payload
  4. Retry if a transient gateway error could have truncated the response

Example fix

// before
ms, err := client.FetchMilestones(ctx, "")
if err != nil { return nil, err }
// after
ms, err := client.FetchMilestones(ctx, "")
if err != nil {
    var te *json.UnmarshalTypeError
    if errors.As(err, &te) {
        log.Printf("milestone payload mismatch at %s: %v", te.Field, te.Error())
    }
    return nil, err
}
Defensive patterns

Strategy: type-guard

Validate before calling

// Ensure API base URL targets the GitLab API, not a proxy/web UI
u, err := url.Parse(gitlabHost)
if err != nil || u.Host == "" {
    return fmt.Errorf("invalid gitlab host %q", gitlabHost)
}

Type guard

func isMalformedResponse(err error) bool {
    var se *json.SyntaxError
    var te *json.UnmarshalTypeError
    return errors.As(err, &se) || errors.As(err, &te)
}

Try / catch

ms, err := client.FetchMilestones(ctx, state)
if err != nil {
    if isMalformedResponse(err) {
        return nil, fmt.Errorf("gitlab returned malformed milestones payload: %w", err)
    }
    return nil, err
}

Prevention

When it happens

Trigger: Calling Client.FetchMilestones when a proxy/SSO returns HTML, the response is empty/truncated, or a GitLab version emits milestone fields whose JSON types clash with the Milestone struct (e.g. null vs string).

Common situations: Reverse proxies injecting HTML error pages; self-managed GitLab with a divergent milestones payload; wrong base URL landing on a non-API route; encoding/charset issues from an intermediary.

Understand the failure class

Related errors


AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30). Data as JSON: /api/errors/e98bf0f163c9254f. Report an issue: GitHub.