Skip to content

IP 归属地接口报错怎么排查?400/403/429/超时/空结果的定位思路与 Go 兜底写法 ​

把 IP 归属地查询接进业务(登录风控、地域统计、本地化展示)之后,迟早会收到这样一张工单:「今天的地域统计少了一大截,是不是接口挂了?」

打开日志一看,情况通常是这几种之一:一堆 400、429 Too Many Requests、context deadline exceeded、或者最迷惑的一种——请求返回 200,但归属地是空的,代码没报错,数据悄悄少了一批。

这篇文章把「调用 IP 归属地接口」这条路线上真正会遇到的错误分类讲清楚,每类的定位步骤是什么,然后用一份 Go 实现把超时、重试、限速、缓存、降级和错误分类统计一次做齐。Go 适合这种场景是因为它经常就是那个「被上游调用的网关/后端服务」本身的位置——兜底逻辑放在这里,上层业务就不用各自写一遍。

一、错误分类:先分清是哪一类,再谈怎么修 ​

排查的第一原则是别把所有失败都记成一句「接口调用失败」。下面这几类的成因、表现、处理方式完全不同:

现象常见成因该怎么处理
HTTP 400 / ret=400IP 字符串本身有问题:带了端口(8.8.8.8:80)、CIDR 斜杠、前后空格、X-Forwarded-For 整串没拆修数据入口,请求前本地校验;这类错误重试没有意义
HTTP 403触发了前置防护(请求速率异常激进,短时间内密集突发)退避一段时间,检查限速器是否失效;不要立刻重试
HTTP 429 / ret=429超过免费版 60 次/分钟的单机限额等待后重试(指数退避),并把出站速率压在限额以下
context deadline exceeded / 超时客户端没设超时,或网络抖动、DNS 慢设 2~3 秒超时;超时可重试一次
5xx服务端异常可重试,配合退避
返回不是 JSON被中间网关/代理拦截或改写(企业出口、容器网络里有透明代理时常见)记录原始响应体,检查网络链路
ret=200 但 city 为空IP 是内网/保留地址(192.168.x.x、10.x.x.x、127.0.0.1),或海外 IP 没有国内省市字段不是故障,但要把「没有位置」和「调用失败」分开统计,否则数据质量永远说不清

最后一行是最容易被忽略的。「调用成功但拿不到位置」和「调用失败」是两件事:前者是输入问题(你给了内网 IP),重试一百次也没用;后者是链路问题,需要退避重试。混在一个错误计数里,告警会一直响,但真正的故障反而被淹掉。

二、几个实测数据 ​

写这篇文章的过程中,我对免费版做了一轮实测,几个数字可以直接作为排查时的参照:

正常查询延迟在毫秒级。 单个 IP 查询的响应耗时约 200~250ms(含网络往返),接口返回体里的 qt 字段是 0.001(服务端处理耗时,单位秒)。如果你的日志里看到平均延迟涨到秒级,问题基本在网络链路或客户端连接池,不在接口本身。

内网地址回报文,不报错。 查 192.168.1.1、10.0.0.1、127.0.0.1,返回都是 ret=200,country 是「保留」,isp 是「内网地址」或「回环地址」,而 prov、city、lng、lat 全是空字符串。如果你只判断 ret == 200,这些记录就会以「属地为空」的样子流进业务表。

带端口的 IP 会被判非法。 查 8.8.8.8:80,HTTP 状态码是 400,响应体是 {"data":[],"ret":400}。同样,明显超范围的地址(比如 999.1.1.1)也是 400,响应结构一样。所以「先剥端口再查」和「先判断 IP 是否合法再查」这两件事,做了能省掉一大批无效请求。

突发会被限速,返回形式还不止一种。 用不带间隔的连续请求压测:

  • 连续打 70 次,前 40 次正常,从第 41 次开始返回 HTTP 429,响应体是 {"ret": 429, "msg": "rate_limit; upgrade ..."}。
  • 更激进的突发(约 1.9 秒内发出 75 次)会被前置防护直接拦成 HTTP 403,响应体不是 JSON。
  • 按合规节奏发(55 次,间隔 1.1 秒)则 55/55 全部正常,耗时 74 秒。

这三条合起来说明一件事:限速保护是分层的——激进突发先被防护挡(403,连 JSON 都没有),接近限额后由限流器返回 429。所以代码里这两种状态码都要认,不能只处理 429。

三、Go 实现:一个带兜底的属地客户端 ​

下面这份代码只用标准库,go run main.go 直接能跑。结构是:错误分类 → 令牌桶限速 → singleflight 去重 → 超时与退避重试 → 缓存 → 降级 → 指标统计。

go
// IP 归属地接口的调用兜底与故障定位:超时、重试、限速、缓存、降级、错误分类统计
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"math/rand"
	"net/http"
	"net/url"
	"strings"
	"sync"
	"time"
)

const apiBase = "https://ip9.com.cn/get"

type kind string

const (
	kindOK        kind = "ok"
	kindBadIP     kind = "bad_ip"      // ret=400 / HTTP 400:IP 字符串非法(带端口、超范围)
	kindBlocked   kind = "blocked"     // HTTP 403/429:触发限速或前置防护
	kindTimeout   kind = "timeout"     // 客户端超时
	kindNetwork   kind = "network"     // DNS/连接类错误
	kindUpstream  kind = "upstream"    // 5xx,服务端异常,可重试
	kindBadBody   kind = "bad_body"    // 返回不是合法 JSON(被网关劫持)
	kindNoGeo     kind = "no_location" // ret=200 但 city 为空:内网、保留地址、海外 IP
)

type Geo struct {
	IP      string `json:"ip"`
	Country string `json:"country"`
	Prov    string `json:"prov"`
	City    string `json:"city"`
	ISP     string `json:"isp"`
	Lng     string `json:"lng"`
	Lat     string `json:"lat"`
	BigArea string `json:"big_area"`
}

type apiResp struct {
	Ret  int             `json:"ret"`
	Data json.RawMessage `json:"data"`
}

type Result struct {
	Geo   Geo
	Kind  kind
	Err   error
	Calls int // 实际发生的 HTTP 次数(含重试)
}

// ---------- 令牌桶:把出站请求压在免费版 60 次/分钟以内 ----------

type bucket struct {
	mu       sync.Mutex
	tokens   float64
	capacity float64
	rate     float64 // 每秒补充
	last     time.Time
}

func newBucket(perMin int) *bucket {
	return &bucket{tokens: float64(perMin), capacity: float64(perMin),
		rate: float64(perMin) / 60.0, last: time.Now()}
}

func (b *bucket) take() {
	for {
		b.mu.Lock()
		now := time.Now()
		b.tokens += now.Sub(b.last).Seconds() * b.rate
		if b.tokens > b.capacity {
			b.tokens = b.capacity
		}
		b.last = now
		if b.tokens >= 1 {
			b.tokens--
			b.mu.Unlock()
			return
		}
		wait := time.Duration((1-b.tokens)/b.rate*1000) * time.Millisecond
		b.mu.Unlock()
		time.Sleep(wait) // 排队等待,而不是把请求打出去撞 429
	}
}

// ---------- singleflight:同一个 IP 的并发查询只真正打一次接口 ----------

type call struct {
	done chan struct{}
	res  Result
}

type flight struct {
	mu sync.Mutex
	m  map[string]*call
}

func newFlight() *flight { return &flight{m: map[string]*call{}} }

func (f *flight) do(key string, fn func() Result) Result {
	f.mu.Lock()
	if c, ok := f.m[key]; ok {
		f.mu.Unlock()
		<-c.done // 已有同 IP 在查,等它的结果,别重复打接口
		return c.res
	}
	c := &call{done: make(chan struct{})}
	f.m[key] = c
	f.mu.Unlock()

	c.res = fn() // 只在这里真正发请求
	close(c.done)
	f.mu.Lock()
	delete(f.m, key)
	f.mu.Unlock()
	return c.res
}

// ---------- 客户端:超时 + 重试 + 缓存 + 降级 ----------

type Client struct {
	http    *http.Client
	bucket  *bucket
	flight  *flight
	mu      sync.RWMutex
	cache   map[string]cacheItem
	ttl     time.Duration
	metrics map[kind]int
	mmu     sync.Mutex
}

type cacheItem struct {
	geo Geo
	exp time.Time
}

func NewClient(ttl time.Duration, perMin int) *Client {
	return &Client{
		http:    &http.Client{Timeout: 2 * time.Second}, // 必须有超时,否则会拖垮上层响应时间
		bucket:  newBucket(perMin),
		flight:  newFlight(),
		cache:   map[string]cacheItem{},
		ttl:     ttl,
		metrics: map[kind]int{},
	}
}

func (c *Client) count(k kind) {
	c.mmu.Lock()
	c.metrics[k]++
	c.mmu.Unlock()
}

// Lookup 是唯一出口:任何失败都返回 (空 Geo, false),不 panic、不阻塞业务
func (c *Client) Lookup(ip string) (Geo, bool) {
	ip = strings.TrimSpace(ip)

	c.mu.RLock()
	item, hit := c.cache[ip]
	c.mu.RUnlock()
	if hit && time.Now().Before(item.exp) {
		c.count(kindOK)
		return item.geo, item.geo.City != ""
	}

	if !validIP(ip) { // 本地先挡住明显非法的输入,省掉一次无效请求
		c.count(kindBadIP)
		return Geo{IP: ip}, false
	}

	res := c.flight.do(ip, func() Result { return c.fetchWithRetry(ip) })
	c.count(res.Kind)
	if res.Kind == kindOK || res.Kind == kindNoGeo {
		c.mu.Lock()
		c.cache[ip] = cacheItem{geo: res.Geo, exp: time.Now().Add(c.ttl)}
		c.mu.Unlock()
	}
	// 降级:返回空 Geo,调用方按「本次无位置信号」处理,绝不因此拒绝用户请求
	return res.Geo, res.Kind == kindOK && res.Geo.City != ""
}

func (c *Client) fetchWithRetry(ip string) Result {
	backoff := 100 * time.Millisecond
	var last Result
	for attempt := 0; attempt < 3; attempt++ {
		c.bucket.take()
		last = c.fetchOnce(ip)
		last.Calls = attempt + 1
		if last.Kind == kindOK || last.Kind == kindNoGeo {
			return last
		}
		if last.Kind == kindBadIP { // 400 重试没意义,直接放弃
			return last
		}
		time.Sleep(backoff + time.Duration(rand.Intn(50))*time.Millisecond)
		backoff *= 2
	}
	return last
}

func (c *Client) fetchOnce(ip string) Result {
	u := apiBase + "?ip=" + url.QueryEscape(ip)
	resp, err := c.http.Get(u)
	if err != nil {
		if strings.Contains(err.Error(), "timeout") || errors.Is(err, io.ErrUnexpectedEOF) {
			return Result{Kind: kindTimeout, Err: err}
		}
		return Result{Kind: kindNetwork, Err: err}
	}
	defer resp.Body.Close()

	switch {
	case resp.StatusCode == 400:
		return Result{Kind: kindBadIP, Err: fmt.Errorf("upstream returned 400")}
	case resp.StatusCode == 403 || resp.StatusCode == 429:
		return Result{Kind: kindBlocked, Err: fmt.Errorf("upstream returned %d", resp.StatusCode)}
	case resp.StatusCode >= 500:
		return Result{Kind: kindUpstream, Err: fmt.Errorf("upstream returned %d", resp.StatusCode)}
	}

	var parsed apiResp
	if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
		return Result{Kind: kindBadBody, Err: err}
	}
	if parsed.Ret != 200 {
		if parsed.Ret == 400 {
			return Result{Kind: kindBadIP, Err: fmt.Errorf("ret=400")}
		}
		return Result{Kind: kindUpstream, Err: fmt.Errorf("ret=%d", parsed.Ret)}
	}

	var g Geo
	if err := json.Unmarshal(parsed.Data, &g); err != nil {
		return Result{Kind: kindBadBody, Err: err}
	}
	if g.City == "" {
		return Result{Geo: g, Kind: kindNoGeo} // 200 但没城市:内网/保留地址或未收录
	}
	return Result{Geo: g, Kind: kindOK}
}

func validIP(s string) bool {
	if s == "" || strings.ContainsAny(s, "/ ,\t") {
		return false
	}
	if strings.Count(s, ":") == 1 {
		return false // IPv4:端口 这类写法
	}
	if strings.Count(s, ".") == 3 {
		return true
	}
	return strings.Contains(s, ":") // IPv6
}

func (c *Client) Report() {
	c.mmu.Lock()
	defer c.mmu.Unlock()
	fmt.Println("\n===== 错误分类统计(用于告警看板) =====")
	for k, v := range c.metrics {
		fmt.Printf("  %-13s %d\n", k, v)
	}
}

主函数用一组「正常 + 各种异常」的输入跑一遍,看每一类是否被正确分类:

go
func main() {
	client := NewClient(6*time.Hour, 55)

	inputs := []string{
		"114.114.114.114", "119.29.29.29", "8.8.8.8",
		"192.168.1.1",     // 内网:ret=200 但城市为空
		"8.8.8.8:80",      // 带端口:HTTP 400
		" 202.96.128.86 ", // 前后空格,Lookup 内部会 trim
		"999.1.1.1",       // 超范围,服务端判非法
	}
	for _, in := range inputs {
		start := time.Now()
		geo, ok := client.Lookup(in)
		fmt.Printf("%-18s ok=%-5v %s%s / %s  (%.0fms)\n",
			in, ok, geo.Prov, geo.City, geo.ISP, float64(time.Since(start).Microseconds())/1000)
	}

	client.Lookup("114.114.114.114") // 走缓存,不再产生出站请求
	client.Report()
}

实测输出(2026-09-16 跑的真实结果):

114.114.114.114    ok=true  江苏南京 / 114DNS  (818ms)
119.29.29.29       ok=true  广东广州 / 腾讯云/DNSPod DNS+  (233ms)
8.8.8.8            ok=false  / Google Cloud  (226ms)
192.168.1.1        ok=false  / 内网地址  (237ms)
8.8.8.8:80         ok=false  /   (0ms)
 202.96.128.86     ok=true  广东广州 / 中国电信/DNS  (230ms)
999.1.1.1          ok=false  /   (226ms)

===== 错误分类统计(用于告警看板) =====
  bad_ip        2
  ok            4
  no_location   2

对着输出逐个看:

  • 8.8.8.8 是海外的 Google Public DNS,返回 country=美国、isp=Google Cloud,但国内省市字段是空的,所以 ok=false、归类 no_location——这是正确的行为,业务侧应该把它当成「境外 IP」处理,而不是当成查询失败。
  • 192.168.1.1 同样归到 no_location,说明业务把内网地址当公网 IP 传进来了。
  • 8.8.8.8:80 耗时 0ms,因为本地校验就拦下了,一次请求都没发出去。
  • 999.1.1.1 走了服务端,返回 400,归到 bad_ip,且不会重试(fetchWithRetry 里 400 直接 return)——这一点很重要,400 类错误重试是纯浪费额度。

四、排查清单:拿着这张表去对日志 ​

真在生产环境遇到问题时,按这个顺序走:

第一步,看错误计数的分布,而不是总量。 如果 bad_ip 突增,是上游传进来的 IP 脏了(新增了某个带端口的字段、某端开始传 X-Forwarded-For 整串);如果 no_location 突增,是有一批内网/海外 IP 进了查询管道;如果 blocked 突增,才是真的在撞限速。

第二步,确认你取到的是不是真实客户端 IP。 这个坑在反向代理后面特别常见:应用读到的 RemoteAddr 是 Nginx 的地址(127.0.0.1),查出来自然是空的。要按可信链路解析 X-Forwarded-For,并且只信任自己那几层代理写入的值——直接把 XFF 第一个值拿来用,等于让客户端自己声明 IP,风控场景下等于没有防护。

第三步,检查并发和限速器是否真的生效。 有没有哪个后台任务/定时脚本绕过了限速器直接发请求?多实例部署时,令牌桶是每实例一份,5 个副本各自压 60 次/分钟,合计就是 300 次/分钟,必然 429。多实例场景要么集中配额(把限速做成 Redis 令牌桶),要么按实例数分摊(单实例设 10 次/分钟)。

第四步,看 403 和 429 的区别。 403 说明突发太激进(前置防护介入),通常是限速器失灵或出现瞬时并发洪峰;429 说明匀速情况下已经超过限额。两者的处置不同:前者先降并发,后者先降速率。

第五步,检查返回不是 JSON 的情况。 如果日志里出现 bad_body,先抓原始响应体。企业出口的透明代理、容器网络的 sidecar、以及某些安全设备都可能返回一个 HTML 页面代替正常响应。这类问题改代码没用,得改网络链路。

五、兜底设计的几条原则 ​

降级方向永远是「业务照常,属地缺失」。 属地是辅助信息,不是身份认证。登录、下单、发帖这些主流程遇到属地查询失败,正确做法是标记「本次无位置信号」继续走,而不是拒绝服务。反过来做的系统,会在接口抖动的十分钟里产生一大批客诉。

缓存是兜底的第一层,不是优化项。 把 TTL 设置成合理的窗口(属地类数据 6~24 小时都合适),命中率通常能到七成以上,等于把出站请求砍掉一大半——这比任何重试策略都更能避免 429。

重试要有边界。 最多 2~3 次、指数退避、带随机抖动,只对超时/5xx/429 重试,对 400 直接放弃。没有边界的重试是 429 的头号制造者。

指标要按类别打点。 只统计「调用成功/失败」两个数字,出问题时你只能猜;按 bad_ip / blocked / timeout / upstream / no_location 分开打点,看一眼就知道该找谁修。

别忽略「位置拿到了但不准」这一类。 前面表格里没有这一类,但它真实存在:用户走 VPN、公司专线、云桌面时,属地会落在大区总部或者机房所在城市。这类问题不体现在错误率上,只体现在业务指标上(比如「同城匹配的通过率下降」)。所以除了接口层面的指标,业务侧也应该有一个「属地与实际不符」的反馈入口。

总结 ​

这套东西落地之后,你得到的不只是一个能重试的 HTTP 客户端,而是一条可观测的调用链路:哪一类失败、有多少、是不是该重试、对业务有没有影响,都在一张指标表里。

动手顺序建议:先把错误分类和指标打点加上(这步不需要改动任何业务逻辑,收益最大),再加限速器和缓存,最后才是重试与降级。上线后观察一周各分类的占比,通常会惊讶于 no_location 的比例——那是数据质量的真实水位。

接口用 IP9 免费版:https://ip9.com.cn/get?ip=<IP> 查指定 IP(IPv4/IPv6 都支持),不带参数返回请求方自己的归属地,免注册、无需鉴权、60 次/分钟,返回 ip、country、prov、city、city_code、post_code、area_code、isp、lng、lat(城市中心经纬度)、long_ip、big_area、qt 等字段;ret=400 表示 IP 非法。量大了、或者需要区县 / ip_type(ISP 家庭、BUS 企业、IDC 机房)/ ip_asn 这些字段时再上 VIP 版(18 万次/分钟、SLA 99.95%),调用方式和返回结构都一致,切换成本很低。官网:https://www.ip9.com.cn