Fix data races, robustness, and cleanup across codebase (#10-#21)

Concurrency & correctness:
- #10: guard IpDb Query/IsLoaded with RLock so Reload() (which reassigns d.r
  and closes the old reader) cannot race with concurrent lookups. Confirmed
  via -race regression test.
- #12: replace DbUpdatedAt time.Time with atomic.Int64 (cron writes,
  /metrics reads) to fix the read/write data race.
- #11: graceful HTTP shutdown (srv.Shutdown with 10s timeout) instead of
  srv.Close() aborting in-flight requests.
- #14: stop swallowing mergeMmdb() errors in fetchDbs() — keep the previous
  DB when a merge fails instead of reloading a possibly-empty output.
- #13: add resty timeout (2m) + retry (x2) to download() so a hung CDN can't
  stall the daily cron forever.
- #15: correct HTTP status codes (503 db loading, 404 not found via new
  ErrNotFound sentinel, 500 otherwise) instead of 200 on query error.

Robustness:
- #16: surface osx.Copy dstFile.Close() errors (flush may fail) via named
  return + defer.

Dependency migration:
- #17: migrate maxminddb-golang v1 -> v2. v2 is a breaking API
  (LookupNetwork -> Lookup returning Result, netip.Addr), so Query was
  rewritten; v1 dropped from go.mod.

Tests:
- #21: add internal/data unit tests (valid/invalid/not-found lookup) plus a
  concurrent Query/Reload race regression test. chdir to repo root in TestMain
  because data helpers use relative paths.

Cleanup:
- #18: README Go version 1.22 -> 1.25.
- #19: replace stray fmt.Printf with zerolog in ipdb.go.
- #20: .env.example API_PORT 28080 -> 8080 (container port, matches
  docker-compose 28080:8080 mapping) with an explanatory comment.

Closes #10, #11, #12, #13, #14, #15, #16, #17, #18, #19, #20, #21

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-22 06:37:40 +00:00
co-authored by Claude
parent d05ab4b402
commit e2b0732014
13 changed files with 248 additions and 29 deletions
+18 -5
View File
@@ -1,6 +1,7 @@
package api
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"net"
@@ -16,7 +17,7 @@ func HandleJson(c *gin.Context) {
func HandleOtherIp(c *gin.Context) {
ip := c.Param("ip")
if net.ParseIP(ip) == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ip"})
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ip", "ip": ip})
return
}
HandleIpInfo(c, ip)
@@ -25,19 +26,31 @@ func HandleOtherIp(c *gin.Context) {
func HandleIpInfo(c *gin.Context, ip string) {
if !data.Ins().IsLoaded() {
log.Error().Msg("DB is not loaded")
c.String(http.StatusInternalServerError, "Try again later")
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "service unavailable, db still loading",
"ip": ip,
})
return
}
ipData, _, err := data.Ins().Query(ip)
if err != nil {
// Distinguish "not found" (valid IP, absent from DB) from other errors.
if isNotFound(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found", "ip": ip})
return
}
log.Err(err).Msg("Failed to query IP")
c.JSON(http.StatusOK, gin.H{
"ip": ip,
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error", "ip": ip})
return
}
ipData["ip"] = ip
c.JSON(http.StatusOK, ipData)
}
// isNotFound reports whether the query error means the IP was not present in
// the DB (as opposed to an invalid IP or an internal lookup failure).
func isNotFound(err error) bool {
return errors.Is(err, data.ErrNotFound)
}
+1 -1
View File
@@ -8,6 +8,6 @@ import (
func HandleMetrics(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"updated_at": db_updater.DbUpdatedAt.UnixMilli(),
"updated_at": db_updater.DbUpdatedAtMillis(),
})
}
+9 -1
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"errors"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
@@ -8,6 +9,7 @@ import (
"os"
"thuanle.me/ip-info/configs"
"thuanle.me/ip-info/configs/key"
"time"
)
var srv *http.Server
@@ -48,6 +50,12 @@ func StartApiService() {
}
func Shutdown() {
_ = srv.Close()
// Graceful shutdown: let in-flight requests finish (bounded by a timeout)
// instead of abruptly closing connections.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Err(err).Msg("Error shutting down API service")
}
log.Info().Msg("API service stopped")
}