fish2018/pansou · error

[ ] 解析 API 响应失败

Error message

[%s] 解析 API 响应失败: %w

What it means

dyyj.executeSearchAPI received a response but the JSON decoder could not unmarshal it into dyyjAPIResponse (encodingjson.NewDecoder(...).Decode(&payload) failed). This means the body was not valid JSON or its shape broke decoding (e.g. HTML error page returned with 200, truncated JSON).

Solutions

  1. Log the first 200 bytes of the raw body before decoding to see what arrived
  2. Check resp.StatusCode and Content-Type; fail early if Content-Type is not JSON
  3. Verify the Flarum API endpoint still exists and matches the dyyjAPIResponse struct
  4. Handle authentication requirements (Flarum may need a token for some queries)
  5. Retry on transient truncation

Example fix

// before
var payload dyyjAPIResponse
if err := encodingjson.NewDecoder(resp.Body).Decode(&payload); err != nil {
	return nil, fmt.Errorf("[%s] 解析 API 响应失败: %w", p.Name(), err)
}
// after
ct := resp.Header.Get("Content-Type")
if !strings.Contains(ct, "json") {
	header, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
	return nil, fmt.Errorf("[%s] API 返回非 JSON 响应 (Content-Type=%s): %s", p.Name(), ct, header)
}
var payload dyyjAPIResponse
if err := encodingjson.NewDecoder(resp.Body).Decode(&payload); err != nil {
	return nil, fmt.Errorf("[%s] 解析 API 响应失败: %w", p.Name(), err)
}
Defensive patterns

Strategy: type-guard

Validate before calling

if !strings.Contains(resp.Header.Get("Content-Type"), "json") {
	return errors.New("API did not return JSON")
}

Type guard

func isJSONResponse(resp *http.Response) bool {
	return resp.StatusCode == http.StatusOK && strings.Contains(resp.Header.Get("Content-Type"), "json")
}

Try / catch

var payload dyyjAPIResponse
if err := encodingjson.NewDecoder(io.LimitReader(resp.Body, maxBodySize)).Decode(&payload); err != nil {
	header, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
	return nil, fmt.Errorf("decode API response: %w (head=%q)", err, header)
}

Prevention

When it happens

Trigger: Flarum API returned non-JSON content (HTML login/challenge page, empty body, or a plain-text error) while the plugin expected application/vnd.api+json; response truncated mid-stream.

Common situations: Session expired so the API returns an HTML redirect/login page; WAF interposes an HTML challenge; API version change altering the response envelope; server 500 with text body.

Related errors


AI-assisted analysis of fish2018/pansou@beaa561337 (2026-09-07). Data as JSON: /api/errors/9c79c22259fb34c2. Report an issue: GitHub.

Appendix: source

Thrown at plugin/dyyj/dyyj.go:429

	defer cancel()
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
	if err != nil {
		return nil, fmt.Errorf("[%s] 创建 API 请求失败: %w", p.Name(), err)
	}
	req.Header.Set("User-Agent", UserAgent)
	req.Header.Set("Accept", "application/vnd.api+json, application/json")
	req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
	req.Header.Set("Referer", BaseURL+"/")

	resp, err := p.doRequestWithRetry(req, client)
	if err != nil {
		return nil, fmt.Errorf("[%s] API 搜索请求失败: %w", p.Name(), err)
	}
	defer resp.Body.Close()

	var payload dyyjAPIResponse
	if err := encodingjson.NewDecoder(resp.Body).Decode(&payload); err != nil {
		return nil, fmt.Errorf("[%s] 解析 API 响应失败: %w", p.Name(), err)
	}

	posts := make(map[string]dyyjIncluded, len(payload.Included))
	for _, post := range payload.Included {
		posts[post.ID] = post
	}

	results := make([]model.SearchResult, 0, len(payload.Data))
	for _, discussion := range payload.Data {
		post, ok := posts[discussion.Relationships.MostRelevantPost.Data.ID]
		if !ok {
			continue
		}
		links := p.extractAPIContentLinks(post.Attributes.ContentHTML)
		if len(links) == 0 {
			continue
		}
		createdAt, parseErr := time.Parse(time.RFC3339, discussion.Attributes.CreatedAt)

View on GitHub (pinned to beaa561337)