Appearance
IP 归属地接口报错怎么排查?400/403/429/超时/空结果的定位思路与 Go 兜底写法
把 IP 归属地查询接进业务(登录风控、地域统计、本地化展示)之后,迟早会收到这样一张工单:「今天的地域统计少了一大截,是不是接口挂了?」
打开日志一看,情况通常是这几种之一:一堆 400、429 Too Many Requests、context deadline exceeded、或者最迷惑的一种——请求返回 200,但归属地是空的,代码没报错,数据悄悄少了一批。
这篇文章把「调用 IP 归属地接口」这条路线上真正会遇到的错误分类讲清楚,每类的定位步骤是什么,然后用一份 Go 实现把超时、重试、限速、缓存、降级和错误分类统计一次做齐。Go 适合这种场景是因为它经常就是那个「被上游调用的网关/后端服务」本身的位置——兜底逻辑放在这里,上层业务就不用各自写一遍。
一、错误分类:先分清是哪一类,再谈怎么修
排查的第一原则是别把所有失败都记成一句「接口调用失败」。下面这几类的成因、表现、处理方式完全不同:
| 现象 | 常见成因 | 该怎么处理 |
|---|---|---|
HTTP 400 / ret=400 | IP 字符串本身有问题:带了端口(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