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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"thuanle.me/ip-info/configs"
|
||||
"time"
|
||||
)
|
||||
|
||||
func download(url string) bool {
|
||||
@@ -16,7 +17,10 @@ func download(url string) bool {
|
||||
|
||||
etag, _ := readFile(filenameEtag)
|
||||
|
||||
client := resty.New()
|
||||
client := resty.New().
|
||||
SetTimeout(2 * time.Minute).
|
||||
SetRetryCount(2).
|
||||
SetRetryWaitTime(5 * time.Second)
|
||||
resp, err := client.R().
|
||||
SetHeader("If-None-Match", etag).
|
||||
Get(url)
|
||||
|
||||
@@ -3,12 +3,22 @@ package db_updater
|
||||
import (
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/rs/zerolog/log"
|
||||
"sync/atomic"
|
||||
"thuanle.me/ip-info/configs"
|
||||
"thuanle.me/ip-info/internal/data"
|
||||
"time"
|
||||
)
|
||||
|
||||
var DbUpdatedAt time.Time
|
||||
// DbUpdatedAt stores the DB last-updated time as unix milliseconds.
|
||||
// Accessed concurrently by the cron goroutine (writer) and the /metrics
|
||||
// handler (reader), so it must be synchronized — hence atomic.Int64.
|
||||
var DbUpdatedAt atomic.Int64
|
||||
|
||||
// DbUpdatedAtMillis returns the last DB update time in unix milliseconds,
|
||||
// or 0 if the DB has never been updated.
|
||||
func DbUpdatedAtMillis() int64 {
|
||||
return DbUpdatedAt.Load()
|
||||
}
|
||||
|
||||
func StartUpdateDbService() {
|
||||
c := cron.New()
|
||||
@@ -25,7 +35,12 @@ func fetchDbs() {
|
||||
|
||||
if newFlag {
|
||||
log.Info().Msg("New DB downloaded. Recreating mmdb")
|
||||
_ = mergeMmdb()
|
||||
if err := mergeMmdb(); err != nil {
|
||||
// Keep the current (previous) DB rather than reloading a
|
||||
// potentially empty/corrupt merge output.
|
||||
log.Err(err).Msg("Failed to merge mmdb, keeping current DB")
|
||||
return
|
||||
}
|
||||
|
||||
err := data.Ins().Reload()
|
||||
if err != nil {
|
||||
@@ -33,6 +48,6 @@ func fetchDbs() {
|
||||
return
|
||||
}
|
||||
|
||||
DbUpdatedAt = time.Now()
|
||||
DbUpdatedAt.Store(time.Now().UnixMilli())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user