gastownhall/beads · error

failed to parse milestone response: %w

Error message

failed to parse milestone response: %w

What it means

After FetchMilestoneByIID gets a successful response from /projects/:id/milestones?iids[]=N, the body is unmarshaled into []Milestone. This error wraps the json.Unmarshal failure when the body is not a valid JSON array matching the Milestone struct. Note FetchMilestoneByIID itself returns (nil, nil) for an empty array, so this error is strictly about malformed payloads, not 'not found'.

Source

Thrown at internal/gitlab/client.go:451

	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)
	}

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

	if len(milestones) == 0 {
		return nil, nil
	}

	return &milestones[0], nil
}

// CreateMilestone creates a new milestone in GitLab.
func (c *Client) CreateMilestone(ctx context.Context, title, description string) (*Milestone, error) {
	body := map[string]interface{}{
		"title":       title,
		"description": description,
	}

	urlStr := c.buildURL("/projects/"+c.projectPath()+"/milestones", nil)
	respBody, _, err := c.doRequest(ctx, http.MethodPost, urlStr, body)

View on GitHub (pinned to 71377f2769)

Solutions

  1. Log the raw response body and the wrapped json error (SyntaxError vs UnmarshalTypeError) to identify the mismatch
  2. Verify the base URL targets the GitLab API host, not a web UI or SSO redirect
  3. Update the Milestone struct json tags/types to match your GitLab version's actual payload
  4. Retry if a transient gateway error could have truncated the body

Example fix

// before
m, err := client.FetchMilestoneByIID(ctx, iid)
// after
m, err := client.FetchMilestoneByIID(ctx, iid)
if err != nil {
    var se *json.SyntaxError
    if errors.As(err, &se) {
        log.Printf("non-JSON milestone payload (offset %d): likely HTML from proxy", se.Offset)
    }
    return err
}
Defensive patterns

Strategy: type-guard

Validate before calling

// Validate API host configuration to avoid proxy-injected HTML
if u, err := url.Parse(gitlabHost); err != nil || u.Scheme == "" || u.Host == "" {
    return fmt.Errorf("misconfigured gitlab host: %q", gitlabHost)
}

Type guard

func isPayloadError(err error) bool {
    var se *json.SyntaxError
    var te *json.UnmarshalTypeError
    return errors.As(err, &se) || errors.As(err, &te)
}
// Also narrow the nil result:
func milestoneFound(m *Milestone) bool { return m != nil && m.ID != 0 }

Try / catch

m, err := client.FetchMilestoneByIID(ctx, iid)
if err != nil {
    if isPayloadError(err) {
        return fmt.Errorf("malformed milestone payload: %w", err)
    }
    return err
}
if !milestoneFound(m) {
    return nil // legitimately not found — function returns nil,nil
}

Prevention

When it happens

Trigger: Calling Client.FetchMilestoneByIID when a proxy/SSO injects HTML into the response, the body is empty/truncated, or a GitLab version emits milestone field types that conflict with the Milestone struct (e.g. number-vs-string IDs).

Common situations: Corporate proxies or error pages returned with 200 responses; self-managed GitLab versions with divergent milestone schemas; wrong base URL hitting a non-API route; truncated responses from flaky gateways.

Understand the failure class

Related errors


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