diff --git a/cmd/beacon/main.go b/cmd/beacon/main.go index dc87e2b..e3af349 100644 --- a/cmd/beacon/main.go +++ b/cmd/beacon/main.go @@ -45,7 +45,7 @@ import ( var version = "dev" // @title MeshCore Beacon API -// @version 2.0.0 +// @version 2.0.1 // @description MeshCore network observation backend. Ingests LoRa packets from MQTT brokers, stores in PostgreSQL, and streams live events via WebSocket. // @description REST requests share a configurable per-client rate limit (default 300/minute and a 300-request one-second burst cap). Exceeded limits return HTTP 429 with error.code=rate_limited and a Retry-After header in seconds. CORS preflights and WebSocket upgrades do not consume this API budget. // @description WebSocket upgrade attempts at /ws have a configurable per-client limit (default 10/minute, including failed handshakes). Rate exhaustion returns HTTP 429 with Retry-After before upgrade. An accepted socket exceeding the concurrent cap closes with code 1013 before hello; established connections remain open. diff --git a/config.yaml.example b/config.yaml.example index 6044840..98ec8f7 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -66,7 +66,7 @@ ratelimit: #meshmapper: # scopes: # enabled: false -# refresh_interval: 1h # 1h-24h (MeshMapper allows one get_scopes.php per region per 55m); one source per 15s tick +# refresh_interval: 24h # 1h-24h (MeshMapper allows one get_scopes.php per region per 55m); one source per 15s tick # # Region boundaries (Zones API). Every known IATA, including ones auto-created # from traffic, is looked up in its country's zone list, then its get_geojson.php diff --git a/docs/docs.go b/docs/docs.go index 1d20bed..6fc5abc 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -5811,7 +5811,7 @@ const docTemplate = `{ // SwaggerInfo holds exported Swagger Info so clients can modify it var SwaggerInfo = &swag.Spec{ - Version: "2.0.0", + Version: "2.0.1", Host: "localhost:8080", BasePath: "/api/v1", Schemes: []string{"http", "https"}, diff --git a/docs/swagger.json b/docs/swagger.json index 809064f..9deb9fd 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -15,7 +15,7 @@ "license": { "name": "AGPL-3-or-later" }, - "version": "2.0.0" + "version": "2.0.1" }, "host": "localhost:8080", "basePath": "/api/v1", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 3e872fd..9844449 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1635,7 +1635,7 @@ info: name: AGPL-3-or-later termsOfService: https://github.com/MeshCore-Beacon/beacon-server title: MeshCore Beacon API - version: 2.0.0 + version: 2.0.1 paths: /admin/accounts: get: diff --git a/internal/config/meshmapper.go b/internal/config/meshmapper.go index 8f8a0c7..eacf32e 100644 --- a/internal/config/meshmapper.go +++ b/internal/config/meshmapper.go @@ -60,7 +60,7 @@ type MeshMapperScopesConfig struct { func (c MeshMapperScopesConfig) Interval() time.Duration { if c.RefreshInterval.Duration == 0 { - return time.Hour + return 24 * time.Hour } return c.RefreshInterval.Duration } diff --git a/internal/config/meshmapper_test.go b/internal/config/meshmapper_test.go index 5127959..181db8f 100644 --- a/internal/config/meshmapper_test.go +++ b/internal/config/meshmapper_test.go @@ -38,7 +38,7 @@ func TestMeshMapperConfig(t *testing.T) { } }) } - if (MeshMapperScopesConfig{}).Interval() != time.Hour { + if (MeshMapperScopesConfig{}).Interval() != 24*time.Hour { t.Fatal("wrong default") } } diff --git a/internal/meshmapper/scopes.go b/internal/meshmapper/scopes.go index 4099aae..7677018 100644 --- a/internal/meshmapper/scopes.go +++ b/internal/meshmapper/scopes.go @@ -150,7 +150,15 @@ func (i *Importer) Refresh(ctx context.Context) (err error) { return fmt.Errorf("list IATAs for MeshMapper scopes: %w", err) } i.track(iatas, nil) - for n := range i.sources { + // Longest-waiting first, so a pass longer than the interval can't starve the tail. + order := make([]int, len(i.sources)) + for n := range order { + order[n] = n + } + slices.SortStableFunc(order, func(a, b int) int { + return i.sources[a].cache.NextAttempt.Compare(i.sources[b].cache.NextAttempt) + }) + for _, n := range order { s := &i.sources[n] if now.Before(s.cache.NextAttempt) { continue diff --git a/internal/meshmapper/scopes_test.go b/internal/meshmapper/scopes_test.go index cd64550..804410a 100644 --- a/internal/meshmapper/scopes_test.go +++ b/internal/meshmapper/scopes_test.go @@ -425,3 +425,62 @@ func TestScopesRequestCutShortStillCounts(t *testing.T) { t.Fatal("abandoned request not persisted", row) } } + +// newSweep serves a CA zone list whose IATAs each have a catalogue; fail lists IATAs answering 500. +func newSweep(t *testing.T, store *memoryStore, fail ...string) (*Importer, *[]string) { + t.Helper() + var hits []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/get_zones.php" { + var zones []string + for _, iata := range store.iatas { + zones = append(zones, fmt.Sprintf(`{"code":%q,"url":"%s/%s/","has_boundary":true,"group":null}`, iata, "http://"+r.Host, iata)) + } + _, _ = fmt.Fprintf(w, `{"country":"CA","zones":[%s]}`, strings.Join(zones, ",")) + return + } + iata := strings.Split(r.URL.Path, "/")[1] + hits = append(hits, iata) + if slices.Contains(fail, iata) { + w.WriteHeader(500) + return + } + _, _ = w.Write([]byte(strings.ReplaceAll(string(catalogue("x")), "YOW", iata))) + })) + t.Cleanup(server.Close) + dir := NewDirectory(newZoneListMemory()) + dir.listURL = server.URL + "/get_zones.php" + imp, err := New(context.Background(), config.MeshMapperScopesConfig{Enabled: true}, store, dir, scopestore.New(), nil) + if err != nil { + t.Fatal(err) + } + imp.scopesURL = func(site string) (string, bool) { return site + "get_scopes.php", true } + refreshN(t, imp, 1) // zone list fetch + return imp, &hits +} + +func TestScopesLongestWaitingGoesFirst(t *testing.T) { + store := newMemoryStore("YOW", "YUL", "YVR", "YYZ") + now := time.Now() + // Restored mid-pass: YOW/YUL done, YVR never reached; YOW is already due again. + store.rows[sourceKey{"YOW", "old"}] = Cache{AttemptedAt: now.Add(-time.Hour), NextAttempt: now.Add(-time.Minute)} + store.rows[sourceKey{"YUL", "old"}] = Cache{AttemptedAt: now, NextAttempt: now.Add(time.Hour)} + store.rows[sourceKey{"YVR", "old"}] = Cache{AttemptedAt: now.Add(-48 * time.Hour), NextAttempt: now.Add(-47 * time.Hour)} + imp, hits := newSweep(t, store) + refreshN(t, imp, 3) + if want := []string{"YYZ", "YVR", "YOW"}; !reflect.DeepEqual(*hits, want) { + t.Fatalf("refresh order %v, want %v", *hits, want) + } +} + +func TestScopesFailureDoesNotStallThePass(t *testing.T) { + store := newMemoryStore("YOW", "YUL", "YVR") + imp, hits := newSweep(t, store, "YOW") + refreshN(t, imp, 4) + if want := []string{"YOW", "YUL", "YVR"}; !reflect.DeepEqual(*hits, want) { + t.Fatalf("refresh order %v, want %v", *hits, want) + } + if s := imp.sources[0]; s.iata != "YOW" || time.Until(s.cache.NextAttempt) > failureRetry { + t.Fatalf("failed source not retried early: %+v", s.cache) + } +}