mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
* refactor(go): adopt modern stdlib idioms across kernel and desktop Mechanical sweep with golangci-lint's modernize/intrange/copyloopvar/ usestdlibvars fixers plus hand-fixes for what they could not rewrite: slices.Contains, min/max, slices.Backward, WaitGroup.Go, reflect.TypeFor, maps.Copy, strings.Cut/CutPrefix/SplitSeq, strings.Builder, range-over-int. Net -549 lines with no behavior change. Three loops in session.go stay index-based: slices.Backward yields element copies, so the fixer's rewrite silently dropped writes to DecisionReceipts and ToolCalls (caught by TestDelivery* and TestDependentSameBatch*). All 49 slices.Backward sites were audited for that shape; only these three write through the index, and they carry a nolint with the reason. CaptureAfter's scan loop had an empty body and never ran any logic, so the fixer could not express it. Drop it and the accessor it was the only caller of. omitzero is deliberately untouched: turning omitempty into omitzero changes the JSON written for nested structs, which is a wire-format change to session files and the extension protocol. * build(lint): gate the modern-idiom linters Nothing stopped the codebase from drifting back to pre-1.22 idioms. Both modules are now clean for modernize, intrange, copyloopvar, and usestdlibvars, so enable them rather than leaving the sweep as a one-off. omitzero stays disabled with the reason inline; usetesting, perfsprint, and errorlint still have residue and land separately.
593 lines
16 KiB
Go
593 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"reasonix/internal/config"
|
|
"reasonix/internal/event"
|
|
"reasonix/internal/recovery"
|
|
)
|
|
|
|
// metrics_app.go is the aggregate desktop-metrics flush: anonymous (signal,
|
|
// bucket) counters observed from the event stream and safe desktop preference
|
|
// snapshots, POSTed once per launch. Never carries content, keys, prompts, paths,
|
|
// or base URLs; custom provider/model identifiers are normalized into bounded
|
|
// buckets. Gated on config desktop.metrics (default on), dev-skipped.
|
|
|
|
var metricsEndpoint = "https://crash.reasonix.io/v1/metrics"
|
|
|
|
const metricsPendingFile = "metrics-pending.json"
|
|
const metricsPostTimeout = 8 * time.Second
|
|
|
|
var statusCodePattern = regexp.MustCompile(`status (\d{3})`)
|
|
var metricsPendingMu sync.Mutex
|
|
|
|
type counters map[string]map[string]int // signal -> bucket -> count
|
|
|
|
func (c counters) add(signal, bucket string, n int) {
|
|
if c[signal] == nil {
|
|
c[signal] = map[string]int{}
|
|
}
|
|
c[signal][bucket] += n
|
|
}
|
|
|
|
func (c counters) merge(other counters) {
|
|
for sig, buckets := range other {
|
|
for b, n := range buckets {
|
|
c.add(sig, b, n)
|
|
}
|
|
}
|
|
}
|
|
|
|
// metricsAggregator accumulates one session's (signal, bucket) counts and merges
|
|
// them into a pending file that flushMetrics drains on the next launch.
|
|
type metricsAggregator struct {
|
|
path string
|
|
mu sync.Mutex
|
|
c counters
|
|
}
|
|
|
|
func newMetricsAggregator(configDir string) *metricsAggregator {
|
|
return &metricsAggregator{path: filepath.Join(configDir, metricsPendingFile), c: counters{}}
|
|
}
|
|
|
|
func (m *metricsAggregator) inc(signal, bucket string) {
|
|
m.mu.Lock()
|
|
m.c.add(signal, bucket, 1)
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
func boolBucket(v bool) string {
|
|
if v {
|
|
return "on"
|
|
}
|
|
return "off"
|
|
}
|
|
|
|
func statusBarItemsCountBucket(n int) string {
|
|
if n < 0 {
|
|
n = 0
|
|
}
|
|
return "n_" + strconv.Itoa(n)
|
|
}
|
|
|
|
func countBucket(n int) string {
|
|
if n < 0 {
|
|
n = 0
|
|
}
|
|
switch {
|
|
case n == 0:
|
|
return "n_0"
|
|
case n == 1:
|
|
return "n_1"
|
|
case n <= 3:
|
|
return "n_2_3"
|
|
case n <= 5:
|
|
return "n_4_5"
|
|
default:
|
|
return "n_6_plus"
|
|
}
|
|
}
|
|
|
|
func knownBucket(value string, allowed ...string) string {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
if slices.Contains(allowed, value) {
|
|
return value
|
|
}
|
|
return "other"
|
|
}
|
|
|
|
func knownBucketDefault(value, def string, allowed ...string) string {
|
|
if strings.TrimSpace(value) == "" {
|
|
value = def
|
|
}
|
|
return knownBucket(value, allowed...)
|
|
}
|
|
|
|
func metricBucket(value string) string {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
if value == "" {
|
|
return "default"
|
|
}
|
|
var b strings.Builder
|
|
lastUnderscore := false
|
|
for _, r := range value {
|
|
ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
|
|
if ok {
|
|
b.WriteRune(r)
|
|
lastUnderscore = false
|
|
continue
|
|
}
|
|
if !lastUnderscore {
|
|
b.WriteByte('_')
|
|
lastUnderscore = true
|
|
}
|
|
}
|
|
out := strings.Trim(b.String(), "_")
|
|
if out == "" {
|
|
return "other"
|
|
}
|
|
if len(out) > 96 {
|
|
return out[:96]
|
|
}
|
|
return out
|
|
}
|
|
|
|
func metricsOfficialProviderHost(baseURL string) string {
|
|
u, err := url.Parse(strings.TrimSpace(baseURL))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.ToLower(u.Hostname())
|
|
}
|
|
|
|
func officialProviderBucket(e *config.ProviderEntry) string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
switch config.CanonicalDesktopOfficialProviderName(e.Name) {
|
|
case "deepseek":
|
|
if metricsOfficialProviderHost(e.BaseURL) == "api.deepseek.com" {
|
|
return "deepseek"
|
|
}
|
|
case "mimo-api":
|
|
if metricsOfficialProviderHost(e.BaseURL) == "api.xiaomimimo.com" {
|
|
return "mimoapi"
|
|
}
|
|
case "mimo-token-plan":
|
|
if metricsOfficialProviderHost(e.BaseURL) == "token-plan-cn.xiaomimimo.com" {
|
|
return "mimoplan"
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func providerMetricsBucket(e *config.ProviderEntry) string {
|
|
if b := officialProviderBucket(e); b != "" {
|
|
return b
|
|
}
|
|
if e == nil {
|
|
return "unknown"
|
|
}
|
|
return metricBucket("custom_" + e.Name)
|
|
}
|
|
|
|
func safeModelBucket(c *config.Config, ref string) string {
|
|
ref = strings.TrimSpace(ref)
|
|
if ref == "" {
|
|
ref = c.DefaultModel
|
|
}
|
|
e, ok := c.ResolveModel(ref)
|
|
if !ok {
|
|
return "unresolved"
|
|
}
|
|
provider := providerMetricsBucket(e)
|
|
return metricBucket(provider + "_" + e.Model)
|
|
}
|
|
|
|
func plannerModelBucket(c *config.Config) string {
|
|
if strings.TrimSpace(c.Agent.PlannerModel) == "" {
|
|
return "off"
|
|
}
|
|
return safeModelBucket(c, c.Agent.PlannerModel)
|
|
}
|
|
|
|
func safeProviderAccessBucket(c *config.Config, name string) string {
|
|
if p, ok := c.Provider(name); ok {
|
|
return providerMetricsBucket(p)
|
|
}
|
|
return metricBucket("custom_" + name)
|
|
}
|
|
|
|
func (m *metricsAggregator) observeSettingsSnapshot(c *config.Config) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
lang := c.DesktopLanguage()
|
|
if lang == "" {
|
|
lang = "auto"
|
|
}
|
|
themeStyle := c.DesktopThemeStyle()
|
|
if themeStyle == "" {
|
|
themeStyle = "default"
|
|
}
|
|
m.inc("settings_language", lang)
|
|
m.inc("client_surface", "desktop")
|
|
m.inc("client_version", metricBucket(version))
|
|
m.inc("settings_desktop_layout", c.DesktopLayoutStyle())
|
|
m.inc("settings_theme", c.DesktopTheme())
|
|
m.inc("settings_theme_style", themeStyle)
|
|
m.inc("settings_close_behavior", c.DesktopCloseBehavior())
|
|
m.inc("settings_display_mode", c.DesktopDisplayMode())
|
|
m.inc("settings_status_bar_style", c.DesktopStatusBarStyle())
|
|
m.inc("settings_status_bar_items_count", statusBarItemsCountBucket(len(c.DesktopStatusBarItems())))
|
|
m.inc("settings_check_updates", boolBucket(c.DesktopCheckUpdates()))
|
|
m.inc("settings_default_model", safeModelBucket(c, c.DefaultModel))
|
|
m.inc("settings_planner_model", plannerModelBucket(c))
|
|
m.inc("settings_subagent_model", safeModelBucket(c, c.Agent.SubagentModel))
|
|
m.inc("settings_subagent_effort", knownBucketDefault(c.Agent.SubagentEffort, "auto", "auto", "low", "medium", "high", "xhigh", "max", "off"))
|
|
m.inc("settings_reasoning_language", config.NormalizeReasoningLanguage(c.Agent.ReasoningLanguage))
|
|
m.inc("settings_provider_count", countBucket(len(c.Providers)))
|
|
m.inc("settings_provider_access_count", countBucket(len(c.Desktop.ProviderAccess)))
|
|
for _, name := range c.Desktop.ProviderAccess {
|
|
m.inc("settings_provider_access", safeProviderAccessBucket(c, name))
|
|
}
|
|
m.observeBotSettingsSnapshot(c)
|
|
}
|
|
|
|
func (m *metricsAggregator) observeBotSettingsSnapshot(c *config.Config) {
|
|
bot := c.Bot
|
|
m.inc("settings_bot_enabled", boolBucket(bot.Enabled))
|
|
m.inc("settings_bot_model", safeModelBucket(c, bot.Model))
|
|
m.inc("settings_bot_tool_approval", knownBucketDefault(bot.ToolApprovalMode, "ask", "ask", "auto", "yolo"))
|
|
m.inc("settings_bot_allowlist", boolBucket(bot.Allowlist.Enabled))
|
|
m.inc("settings_bot_allow_all", boolBucket(bot.Allowlist.AllowAll))
|
|
m.inc("settings_bot_qq_enabled", boolBucket(bot.QQ.Enabled))
|
|
m.inc("settings_bot_feishu_enabled", boolBucket(bot.Feishu.Enabled))
|
|
m.inc("settings_bot_weixin_enabled", boolBucket(bot.Weixin.Enabled))
|
|
m.inc("settings_bot_connection_count", countBucket(len(bot.Connections)))
|
|
for _, conn := range bot.Connections {
|
|
provider := knownBucket(conn.Provider, "qq", "feishu", "weixin")
|
|
m.inc("settings_bot_connection_provider", provider)
|
|
m.inc("settings_bot_connection_enabled", boolBucket(conn.Enabled))
|
|
m.inc("settings_bot_connection_status", knownBucket(conn.Status, "disconnected", "pending", "connected", "error"))
|
|
m.inc("settings_bot_connection_model", safeModelBucket(c, conn.Model))
|
|
m.inc("settings_bot_connection_approval", knownBucketDefault(conn.ToolApprovalMode, "default", "default", "ask", "auto", "yolo"))
|
|
}
|
|
}
|
|
|
|
func (a *App) recordSettingsMetricsSnapshot(c *config.Config) {
|
|
if version == "dev" || c == nil {
|
|
return
|
|
}
|
|
m := a.metrics.Load()
|
|
if m == nil {
|
|
return
|
|
}
|
|
m.observeSettingsSnapshot(c)
|
|
m.persist()
|
|
}
|
|
|
|
// recordDiagnosticMetric persists one bounded operational signal even when the
|
|
// native event arrives before Wails OnStartup installs the session aggregator.
|
|
func (a *App) recordDiagnosticMetric(signal, bucket string) {
|
|
if version == "dev" {
|
|
return
|
|
}
|
|
m := a.metrics.Load()
|
|
if m == nil {
|
|
cfg, err := config.Load()
|
|
if err != nil || !cfg.DesktopMetrics() {
|
|
return
|
|
}
|
|
m = newMetricsAggregator(config.MemoryUserDir())
|
|
}
|
|
m.inc(signal, metricBucket(bucket))
|
|
m.persist()
|
|
}
|
|
|
|
// observe maps one event to counter increments, reading only enumerated facts
|
|
// (finish reason, error class, cache-hit bucket) — never message text.
|
|
func (m *metricsAggregator) observe(e event.Event) {
|
|
switch e.Kind {
|
|
case event.Usage:
|
|
if e.Usage == nil {
|
|
return
|
|
}
|
|
if e.Usage.FinishReason != "" {
|
|
m.inc("finish_reason", e.Usage.FinishReason)
|
|
}
|
|
if e.Usage.CacheHitTokens+e.Usage.CacheMissTokens > 0 {
|
|
m.inc("cache_hit", cacheBucket(e.Usage.CacheHitTokens, e.Usage.CacheMissTokens))
|
|
}
|
|
case event.TurnDone:
|
|
m.inc("turns", "total")
|
|
if e.Err != nil && e.Outcome != event.TurnOutcomeRecoveryPaused {
|
|
m.inc("provider_error", errorClass(e.Err.Error()))
|
|
}
|
|
case event.ToolResult:
|
|
if e.Tool.Err != "" {
|
|
m.inc("tool_error", toolErrorClass(e.Tool.Err))
|
|
}
|
|
case event.CompactionDone:
|
|
m.inc("compaction", "total")
|
|
case event.Notice:
|
|
if e.Text == "No visible answer was produced; asking the assistant to respond again." || strings.HasPrefix(e.Detail, "empty final answer blocked") {
|
|
m.inc("empty_final", "total")
|
|
}
|
|
}
|
|
}
|
|
|
|
func cacheBucket(hit, miss int) string {
|
|
pct := float64(hit) / float64(hit+miss) * 100
|
|
switch {
|
|
case pct < 50:
|
|
return "0_50"
|
|
case pct < 80:
|
|
return "50_80"
|
|
case pct < 95:
|
|
return "80_95"
|
|
case pct < 99:
|
|
return "95_99"
|
|
default:
|
|
return "99_100"
|
|
}
|
|
}
|
|
|
|
// badRequestReason separates the 400s that need different fixes. Every arm
|
|
// returns a fixed label matched against a fixed substring, so nothing the
|
|
// provider echoed back can reach the bucket — the same constraint errorClass
|
|
// works under. Unrecognized shapes stay plain http_400 rather than guessing.
|
|
func badRequestReason(low string) string {
|
|
switch {
|
|
case strings.Contains(low, "image_url"), strings.Contains(low, "unknown variant"):
|
|
return "content"
|
|
case strings.Contains(low, "is not of type"), strings.Contains(low, "invalid schema for function"):
|
|
return "schema"
|
|
case strings.Contains(low, "thinking") && strings.Contains(low, "passed back"):
|
|
return "reasoning_replay"
|
|
case strings.Contains(low, "thinking") && (strings.Contains(low, "expected a boolean") || strings.Contains(low, "invalid type")):
|
|
return "thinking_shape"
|
|
case strings.Contains(low, "context length"), strings.Contains(low, "maximum context"), strings.Contains(low, "too long"):
|
|
return "context_length"
|
|
case strings.Contains(low, "tool_calls"), strings.Contains(low, "missing field name"):
|
|
return "tool_calls"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// errorClass extracts only the failure category — never the message itself, which
|
|
// can echo request content back from a provider.
|
|
func errorClass(msg string) string {
|
|
if mm := statusCodePattern.FindStringSubmatch(msg); mm != nil {
|
|
switch code := mm[1]; {
|
|
case code == "400":
|
|
if reason := badRequestReason(strings.ToLower(msg)); reason != "" {
|
|
return "http_400_" + reason
|
|
}
|
|
return "http_400"
|
|
case code == "401" || code == "403":
|
|
return "http_401"
|
|
case code == "429":
|
|
return "http_429"
|
|
case code[0] == '5':
|
|
return "http_5xx"
|
|
}
|
|
}
|
|
low := strings.ToLower(msg)
|
|
switch {
|
|
case strings.Contains(low, "authorization cancelled"):
|
|
return "authorization_cancelled"
|
|
case strings.Contains(low, "authorization failed"):
|
|
return "authorization_failed"
|
|
case strings.Contains(low, "package manager busy"):
|
|
return "package_manager_busy"
|
|
case strings.Contains(low, "package install failed"):
|
|
return "package_install_failed"
|
|
case strings.Contains(low, "package verify failed"), strings.Contains(low, "signature verification failed"):
|
|
return "package_verify_failed"
|
|
case strings.Contains(low, "reset"), strings.Contains(low, "interrupt"), strings.Contains(low, "eof"):
|
|
return "stream_interrupted"
|
|
case strings.Contains(low, "timeout"), strings.Contains(low, "deadline"):
|
|
return "timeout"
|
|
default:
|
|
return "other"
|
|
}
|
|
}
|
|
|
|
func toolErrorClass(msg string) string {
|
|
low := strings.ToLower(msg)
|
|
switch {
|
|
case strings.Contains(low, "permission"):
|
|
return "permission"
|
|
case strings.Contains(low, "plan mode"):
|
|
return "planmode"
|
|
case strings.Contains(low, "recovery"):
|
|
return "recovery"
|
|
case strings.Contains(low, "hook"):
|
|
return "hook"
|
|
case strings.Contains(low, "timeout"), strings.Contains(low, "deadline"):
|
|
return "timeout"
|
|
default:
|
|
return "exec"
|
|
}
|
|
}
|
|
|
|
// observeRecoveryMetrics merges content-free recovery counters from a controller
|
|
// (failure events, rule/review continues, human prompts/actions, reviewer errors).
|
|
func (m *metricsAggregator) observeRecoveryMetrics(stats recovery.Metrics) {
|
|
if m == nil {
|
|
return
|
|
}
|
|
add := func(signal string, n int64) {
|
|
for range n {
|
|
m.inc(signal, "total")
|
|
}
|
|
}
|
|
add("recovery_failure", stats.FailureEvents)
|
|
add("recovery_rule_continue", stats.RuleContinues)
|
|
add("recovery_review_continue", stats.ReviewContinues)
|
|
add("recovery_human_prompt", stats.HumanPrompts)
|
|
add("recovery_human_continue", stats.HumanContinues)
|
|
add("recovery_human_revise", stats.HumanRevises)
|
|
add("recovery_review_error", stats.ReviewErrors)
|
|
add("recovery_repeat_prompt", stats.RepeatPrompts)
|
|
if stats.ReviewLatencyCount > 0 {
|
|
avg := stats.ReviewLatencyMsSum / stats.ReviewLatencyCount
|
|
switch {
|
|
case avg < 500:
|
|
m.inc("recovery_review_latency", "lt_500ms")
|
|
case avg < 2000:
|
|
m.inc("recovery_review_latency", "lt_2s")
|
|
case avg < 10000:
|
|
m.inc("recovery_review_latency", "lt_10s")
|
|
default:
|
|
m.inc("recovery_review_latency", "gte_10s")
|
|
}
|
|
}
|
|
}
|
|
|
|
func observeControllerRecoveryMetrics(m *metricsAggregator, ctrl any) {
|
|
if m == nil || ctrl == nil {
|
|
return
|
|
}
|
|
if drainer, ok := ctrl.(interface {
|
|
DrainRecoveryMetrics() recovery.Metrics
|
|
}); ok {
|
|
m.observeRecoveryMetrics(drainer.DrainRecoveryMetrics())
|
|
}
|
|
}
|
|
|
|
// persist merges the session delta into the pending file and resets it, so a
|
|
// force-kill loses at most the counts since the last turn.
|
|
func (m *metricsAggregator) persist() {
|
|
m.mu.Lock()
|
|
if len(m.c) == 0 {
|
|
m.mu.Unlock()
|
|
return
|
|
}
|
|
delta := m.c
|
|
m.c = counters{}
|
|
m.mu.Unlock()
|
|
|
|
metricsPendingMu.Lock()
|
|
pending := readCounters(m.path)
|
|
pending.merge(delta)
|
|
writeCounters(m.path, pending)
|
|
metricsPendingMu.Unlock()
|
|
}
|
|
|
|
func readCounters(path string) counters {
|
|
b, err := readFileUTF8(path)
|
|
if err != nil {
|
|
return counters{}
|
|
}
|
|
var c counters
|
|
if json.Unmarshal(b, &c) != nil || c == nil {
|
|
return counters{}
|
|
}
|
|
return c
|
|
}
|
|
|
|
func writeCounters(path string, c counters) {
|
|
if b, err := json.Marshal(c); err == nil {
|
|
_ = os.WriteFile(path, b, 0o644)
|
|
}
|
|
}
|
|
|
|
type metricCounter struct {
|
|
Signal string `json:"signal"`
|
|
Bucket string `json:"bucket"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
type metricsPayload struct {
|
|
InstallID string `json:"installId,omitempty"`
|
|
Version string `json:"version"`
|
|
OS string `json:"os"`
|
|
Counters []metricCounter `json:"counters"`
|
|
}
|
|
|
|
func flatten(c counters) []metricCounter {
|
|
out := make([]metricCounter, 0, len(c))
|
|
for sig, buckets := range c {
|
|
for b, n := range buckets {
|
|
if n > 0 {
|
|
out = append(out, metricCounter{Signal: sig, Bucket: b, Count: n})
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// flushMetrics drains the pending file from prior sessions and POSTs it, then
|
|
// clears it on success or folds it back to retry next launch. Runs at launch
|
|
// (mirroring the ping) so the current session's counts ship next time.
|
|
func (a *App) flushMetrics() {
|
|
if version == "dev" {
|
|
return
|
|
}
|
|
cfg, err := config.Load()
|
|
if err != nil || !cfg.DesktopMetrics() {
|
|
return
|
|
}
|
|
path := filepath.Join(config.MemoryUserDir(), metricsPendingFile)
|
|
temp := path + ".sending"
|
|
metricsPendingMu.Lock()
|
|
if os.Rename(path, temp) != nil {
|
|
metricsPendingMu.Unlock()
|
|
return // nothing pending
|
|
}
|
|
metricsPendingMu.Unlock()
|
|
flat := flatten(readCounters(temp))
|
|
payload := metricsPayload{Version: version, OS: runtime.GOOS, Counters: flat}
|
|
if id, err := installID(); err == nil {
|
|
payload.InstallID = id
|
|
}
|
|
if len(flat) == 0 || a.postMetrics(payload) {
|
|
_ = os.Remove(temp)
|
|
return
|
|
}
|
|
metricsPendingMu.Lock()
|
|
pending := readCounters(path)
|
|
pending.merge(readCounters(temp))
|
|
writeCounters(path, pending)
|
|
metricsPendingMu.Unlock()
|
|
_ = os.Remove(temp)
|
|
}
|
|
|
|
func (a *App) postMetrics(p metricsPayload) bool {
|
|
body, err := json.Marshal(p)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
c, err := httpClient()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
c.Timeout = metricsPostTimeout
|
|
req, err := http.NewRequestWithContext(a.bootContext(), http.MethodPost, metricsEndpoint, bytes.NewReader(body))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := c.Do(req)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
resp.Body.Close()
|
|
return resp.StatusCode < 300
|
|
}
|