package main import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "os" "path/filepath" "strconv" "sync" "time" ) type Cache struct { mu sync.RWMutex filePath string contentType string lastSuccess time.Time lastAttempt time.Time lastError string hasData bool } func (c *Cache) Update(contentType string, err error) { c.mu.Lock() defer c.mu.Unlock() c.lastAttempt = time.Now() if err != nil { c.lastError = "fetch failed (check server logs for details)" return } c.contentType = contentType c.lastSuccess = time.Now() c.lastError = "" c.hasData = true } func (c *Cache) GetContent() (filePath string, contentType string, ok bool) { c.mu.RLock() defer c.mu.RUnlock() if !c.hasData { return "", "", false } return c.filePath, c.contentType, true } func (c *Cache) Status() map[string]interface{} { c.mu.RLock() defer c.mu.RUnlock() var lastSuccessStr, lastAttemptStr, lastErrorStr string if !c.lastSuccess.IsZero() { lastSuccessStr = c.lastSuccess.UTC().Format(time.RFC3339) } if !c.lastAttempt.IsZero() { lastAttemptStr = c.lastAttempt.UTC().Format(time.RFC3339) } if c.lastError != "" { lastErrorStr = c.lastError } return map[string]interface{}{ "has_data": c.hasData, "last_success": lastSuccessStr, "last_attempt": lastAttemptStr, "last_error": lastErrorStr, "content_present": c.hasData, } } func fetchOnce(url, destPath string) (string, error) { client := &http.Client{ Timeout: 30 * time.Second, } req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return "", fmt.Errorf("creating request: %w", err) } resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("performing request: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) } tmpPath := destPath + ".tmp" f, err := os.Create(tmpPath) if err != nil { return "", fmt.Errorf("creating temp file: %w", err) } _, copyErr := io.Copy(f, resp.Body) closeErr := f.Close() if copyErr != nil { _ = os.Remove(tmpPath) return "", fmt.Errorf("copying body: %w", copyErr) } if closeErr != nil { _ = os.Remove(tmpPath) return "", fmt.Errorf("closing temp file: %w", closeErr) } if err := os.Rename(tmpPath, destPath); err != nil { _ = os.Remove(tmpPath) return "", fmt.Errorf("renaming temp file: %w", err) } contentType := resp.Header.Get("Content-Type") if contentType == "" { contentType = "text/xml; charset=utf-8" } return contentType, nil } func startFetcher(ctx context.Context, cache *Cache, url string, okInterval, failInterval time.Duration) { go func() { for { contentType, err := fetchOnce(url, cache.filePath) if err != nil { log.Printf("error fetching %s: %v", url, err) } cache.Update(contentType, err) var nextInterval time.Duration if err != nil || !cache.hasData { nextInterval = failInterval } else { nextInterval = okInterval } select { case <-ctx.Done(): return case <-time.After(nextInterval): } } }() } func parseEnvDurationHours(key string, defaultHours int) time.Duration { val := os.Getenv(key) if val == "" { return time.Duration(defaultHours) * time.Hour } hours, err := strconv.Atoi(val) if err != nil || hours <= 0 { log.Printf("Invalid value for %s=%q, using default %d hours", key, val, defaultHours) return time.Duration(defaultHours) * time.Hour } return time.Duration(hours) * time.Hour } func main() { url := os.Getenv("CACHE_URL") if url == "" { log.Fatal("CACHE_URL env var is required") } okInterval := parseEnvDurationHours("CACHE_OK_INTERVAL_HOURS", 8) failInterval := parseEnvDurationHours("CACHE_FAIL_INTERVAL_HOURS", 4) port := os.Getenv("PORT") if port == "" { port = "8080" } exePath, err := os.Executable() if err != nil { log.Fatalf("failed to get executable path: %v", err) } exeDir := filepath.Dir(exePath) cacheFilePath := filepath.Join(exeDir, "cache.xml") cache := &Cache{ filePath: cacheFilePath, } ctx, cancel := context.WithCancel(context.Background()) defer cancel() startFetcher(ctx, cache, url, okInterval, failInterval) mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { path, contentType, ok := cache.GetContent() if !ok { http.Error(w, "Cache not yet initialized; no successful fetch so far", http.StatusServiceUnavailable) return } if contentType != "" { w.Header().Set("Content-Type", contentType) } http.ServeFile(w, r, path) }) mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { status := cache.Status() w.Header().Set("Content-Type", "application/json; charset=utf-8") enc := json.NewEncoder(w) enc.SetIndent("", " ") if err := enc.Encode(status); err != nil { http.Error(w, "failed to encode status", http.StatusInternalServerError) return } }) addr := ":" + port log.Printf("Starting server on %s, caching %s to %s (ok interval: %s, fail interval: %s)", addr, url, cacheFilePath, okInterval, failInterval) if err := http.ListenAndServe(addr, mux); err != nil { log.Fatalf("server error: %v", err) } }