BuildAPIError consumes a parsed Lark API response and returns a typed error. Returns nil when resp is nil or resp["code"] is 0. Routing by Category: Authorization → *errs.PermissionError (with MissingScopes / Identity / ConsoleURL) Authentication → *errs.AuthenticationError Config → *errs.Confi
(resp map[string]any, cc ClassifyContext)
| 43 | // Unknown Lark codes (LookupCodeMeta returns false) fall back to |
| 44 | // CategoryAPI + SubtypeUnknown. |
| 45 | func BuildAPIError(resp map[string]any, cc ClassifyContext) error { |
| 46 | if resp == nil { |
| 47 | return nil |
| 48 | } |
| 49 | code := intFromAny(resp["code"]) |
| 50 | if code == 0 { |
| 51 | return nil |
| 52 | } |
| 53 | msg, _ := resp["msg"].(string) |
| 54 | if msg == "" { |
| 55 | // Upstream omitted or sent non-string msg. Keep Problem.Message non-empty |
| 56 | // so the typed wire envelope still carries a human-readable signal. |
| 57 | msg = fmt.Sprintf("API error: [%d]", code) |
| 58 | } |
| 59 | // Lark API responses sometimes carry log_id at the top level |
| 60 | // ({"code":..., "log_id":"..."}) and sometimes nested under "error" |
| 61 | // ({"code":..., "error":{"log_id":"..."}}). Prefer top level and fall |
| 62 | // back to the nested location so log_id always surfaces on the typed |
| 63 | // envelope. |
| 64 | logID, _ := resp["log_id"].(string) |
| 65 | if logID == "" { |
| 66 | if errBlock, ok := resp["error"].(map[string]any); ok { |
| 67 | if nested, ok := errBlock["log_id"].(string); ok { |
| 68 | logID = nested |
| 69 | } |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | meta, ok := LookupCodeMeta(code) |
| 74 | if !ok { |
| 75 | meta = CodeMeta{Category: errs.CategoryAPI, Subtype: errs.SubtypeUnknown} |
| 76 | } |
| 77 | |
| 78 | base := errs.Problem{ |
| 79 | Category: meta.Category, |
| 80 | Subtype: meta.Subtype, |
| 81 | Code: code, |
| 82 | Message: msg, |
| 83 | LogID: logID, |
| 84 | Retryable: meta.Retryable, |
| 85 | } |
| 86 | // Upstream-provided diagnostic URL (resp.error.troubleshooter). Lifted |
| 87 | // universally before the category switch so every classified typed |
| 88 | // error surfaces it when present. The remaining contents of resp["error"] |
| 89 | // (permission_violations.subject, data.challenge_url, data.hint) are |
| 90 | // either lifted into category-specific typed extension fields below or |
| 91 | // intentionally dropped as redundant with the typed envelope. |
| 92 | if errBlock, ok := resp["error"].(map[string]any); ok { |
| 93 | if ts, _ := errBlock["troubleshooter"].(string); ts != "" { |
| 94 | base.Troubleshooter = ts |
| 95 | } |
| 96 | } |
| 97 | // Upstream-provided field-level reasons (resp.error.details[].value). Lark |
| 98 | // returns these as free-text reason strings with no machine-readable field |
| 99 | // name (verified for code 190014: |
| 100 | // {"error":{"details":[{"value":"end_time should be later than start_time"}]}}), |
| 101 | // so they are lifted into Problem.Hint — the sanctioned free-text recovery |
| 102 | // prompt — rather than fabricated structured params. Lifted before the |