omnifeed

fetchcache

import "github.com/kinorai/omnifeed/internal/fetchcache"

Package fetchcache is the fetch_url response cache: a decorator around the engine dispatcher (engine.Dispatcher), so the MCP tool and the Open WebUI loader share one cache without either knowing it exists.

Why it exists: the same Reddit/HN threads are re-fetched every few minutes (a retry job, agents re-reading a URL they already have), and every repeat spends Reddit’s tight per-IP budget — on 2026-10-08 that budget ran out and the egress IP was blocked. A short TTL turns those repeats into free hits.

What it caches: successful, complete documents only. Errors, generic fallback renders (domain.FallbackFromKey) and partial crawls (domain.PartialKey) are never stored, so the next caller gets a fresh chance at the real thing. Concurrent identical requests share one upstream fetch (singleflight). Backend failures degrade to a miss, never to a failed request; a Redis that refuses the cache’s keys (ACL) is replaced by the in-process backend (Failover).

Index

Constants

Metadata keys the cache adds to every document it returns.

const (
    // MetaCache is "hit" (served from the cache, or shared with an identical
    // in-flight request), "miss" (fetched upstream) or "bypass" (the caller
    // asked for no_cache).
    MetaCache = domain.CacheKey
    // MetaCachedAt is when the content was fetched upstream and stored
    // (RFC 3339, UTC). Absent when the response is not in the cache — an
    // error, a fallback render, a partial crawl, or a failed store.
    MetaCachedAt = "cached_at"
)

Cache results, also the values of the omnifeed_cache_requests_total `result` label. ResultError is a backend failure that was served as a miss.

const (
    ResultHit    = "hit"
    ResultMiss   = "miss"
    ResultBypass = "bypass"
    ResultError  = "error"
)

Backend names, the values of the omnifeed_cache_backend `backend` label.

const (
    BackendRedis  = "redis"
    BackendMemory = "memory"
)

Kinds of backend error, the values of the omnifeed_cache_backend_errors_total `kind` label.

const (
    ErrKindNoPerm    = "noperm"    // permission/auth: permanent until the ACL changes
    ErrKindTransient = "transient" // timeouts, refused connections, LOADING…: cooldown and retry
    ErrKindOther     = "other"     // startup probe only: Redis answered, but not as a cache can use
)

Variables

ErrPermission wraps a Redis error that retrying will not fix: the ACL user may not touch the cache’s keys (NOPERM), or the connection is not authenticated (NOAUTH, WRONGPASS). It does not trip the cooldown — a Failover answers it by switching to its in-process backend for good.

var ErrPermission = errors.New("fetch cache: redis permission denied")

ErrTooLarge is returned by Set for an entry over the backend’s item cap. The response is still served; it just is not cached.

var ErrTooLarge = errors.New("fetch cache entry over the item size cap")

ErrUnavailable is returned while the Redis backend is cooling down after a failure: the cache serves a miss without paying another timeout.

var ErrUnavailable = errors.New("fetch cache backend unavailable (cooling down)")

func Cacheable

func Cacheable(doc domain.Document) bool

Cacheable reports whether doc may be stored: a complete, non-empty document from the engine that claimed the URL. Fallback renders and partial crawls are stand-ins for the real thing; caching them would serve a degraded answer for a whole TTL after the upstream recovered.

func Key

func Key(rawURL string, opts domain.EngineOptions) (string, bool)

Key derives the cache key from the normalized URL and every option that reaches the engine. Options are serialized whole (so a field added to domain.EngineOptions later is keyed automatically), minus NoCache, which changes how the request is served but never what an engine renders. ok is false when rawURL does not parse.

max_chars / start_char are deliberately absent: fetch_url applies them to the document AFTER the dispatcher, so the cache holds the whole document and each character window is cut from it — paging through a long page is one upstream fetch, not one per chunk.

type Backend

Backend stores entries. Get reports a miss as (Entry{}, false, nil); an error means the backend could not answer, which the cache serves as a miss.

type Backend interface {
    Get(ctx context.Context, key string) (Entry, bool, error)
    Set(ctx context.Context, key string, e Entry, ttl time.Duration) error
}

type Cache

Cache decorates an engine.Dispatcher with a response cache.

type Cache struct {
    // contains filtered or unexported fields
}

func New

func New(cfg Config) *Cache

New builds a Cache.

func (*Cache) CacheEnabled

func (c *Cache) CacheEnabled() bool

CacheEnabled reports that a response cache sits in front of the engines (engine.CacheReporter), so a transport labels an uncached error “miss” rather than “off”.

func (*Cache) Crawl

func (c *Cache) Crawl(ctx context.Context, rawURL string, opts domain.EngineOptions) (domain.Document, error)

Crawl serves rawURL from the cache when it can, and otherwise fetches it through the wrapped dispatcher, storing a cacheable result.

func (*Cache) Resolve

func (c *Cache) Resolve(rawURL string) domain.Engine

Resolve delegates to the wrapped dispatcher.

type Config

Config configures a Cache.

type Config struct {
    Inner   engine.Dispatcher
    Backend Backend
    // DefaultTTL applies to documents from engines EngineTTL does not list
    // (the generic page fallback). EngineTTL overrides it per engine name.
    // A TTL <= 0 means "do not cache".
    DefaultTTL time.Duration
    EngineTTL  map[string]time.Duration
    Metrics    *observability.Metrics
    Logger     *slog.Logger
    Now        func() time.Time // defaults to time.Now; tests inject a fake clock
}

type Entry

Entry is one cached document and the time it was fetched upstream.

type Entry struct {
    Doc      domain.Document `json:"doc"`
    CachedAt time.Time       `json:"cached_at"`
}

type Failover

Failover is the Redis backend with an in-process fallback for the failures retrying cannot fix. On 2026-10-08 the Redis ACL user could only touch the rate limiter’s keys: every cache GET and SET answered NOPERM, the Redis backend cooled down and retried every 30 s forever, and nothing was ever cached, on any replica — silently, since every request still succeeded as a miss. A permission error (at the startup probe or on any later Get/Set) now switches this replica to the in-process LRU for good and says so once at ERROR. Transient errors keep the Redis backend and its degrade-to-miss cooldown: Redis coming back is the expected outcome.

type Failover struct {
    // contains filtered or unexported fields
}

func NewFailover

func NewFailover(cfg FailoverConfig) *Failover

NewFailover builds a Failover that starts on Redis.

func (*Failover) Backend

func (f *Failover) Backend() string

Backend is the active backend’s name: BackendRedis or BackendMemory.

func (*Failover) Get

func (f *Failover) Get(ctx context.Context, key string) (Entry, bool, error)

Get reads from the active backend. A permission error from Redis switches to the in-process backend and answers from it (a miss, the first time), so the caller’s fetch is stored there.

func (*Failover) Probe

func (f *Failover) Probe(ctx context.Context, timeout time.Duration)

Probe runs the Redis backend’s startup check (Redis.Probe) with timeout per command. A permission error, or any other answer Redis will keep giving, switches to the in-process backend; a Redis that did not answer keeps the Redis backend, whose runtime cooldown covers it until it does.

func (*Failover) Set

func (f *Failover) Set(ctx context.Context, key string, e Entry, ttl time.Duration) error

Set writes to the active backend, switching on a permission error like Get.

type FailoverConfig

FailoverConfig configures a Failover.

type FailoverConfig struct {
    Redis  *Redis
    Memory Backend // the fallback; used only after a switch
    Prefix string  // the cache key prefix, named in the ERROR log
    Logger *slog.Logger
    // OnBackend, when set, is called with the active backend's name
    // (BackendRedis at construction, BackendMemory on the switch).
    OnBackend func(name string)
}

type Memory

Memory is the in-process LRU backend, used when OMNIFEED_REDIS_URL is unset, and as the Failover’s fallback when Redis refuses the cache’s keys. It is bounded by the approximate byte size of what it holds; expired entries are dropped on read and are first in line for eviction by recency.

type Memory struct {
    // contains filtered or unexported fields
}

func NewMemory

func NewMemory(cfg MemoryConfig) *Memory

NewMemory builds an in-process LRU backend.

func (*Memory) Bytes

func (m *Memory) Bytes() int

Bytes is the approximate size of everything held.

func (*Memory) Get

func (m *Memory) Get(_ context.Context, key string) (Entry, bool, error)

Get returns the live entry for key, if any.

func (*Memory) Set

func (m *Memory) Set(_ context.Context, key string, e Entry, ttl time.Duration) error

Set stores e for ttl, evicting least-recently-used entries to stay within the byte budget. An entry over the item cap is skipped (ErrTooLarge).

type MemoryConfig

MemoryConfig configures a Memory backend.

type MemoryConfig struct {
    MaxBytes     int              // total budget; <= 0 stores nothing
    MaxItemBytes int              // one entry's cap; <= 0 means MaxBytes
    Now          func() time.Time // defaults to time.Now
    OnSize       func(bytes int)  // called with the new total after every change
}

type Redis

Redis is the shared backend, used when OMNIFEED_REDIS_URL is set, so every replica serves every other replica’s fetches. Values are gzip-compressed JSON; Redis expires them by TTL. Any Redis error trips a cooldown during which Get/Set fail fast with ErrUnavailable — the cache then serves misses and requests go upstream as if there were no cache.

type Redis struct {
    // contains filtered or unexported fields
}

func NewRedis

func NewRedis(cfg RedisConfig) *Redis

NewRedis builds a Redis backend.

func (*Redis) Get

func (r *Redis) Get(ctx context.Context, key string) (Entry, bool, error)

Get reads and decodes key.

func (*Redis) Probe

func (r *Redis) Probe(ctx context.Context, timeout time.Duration) (kind string, err error)

Probe checks, once at startup, that this Redis user can do what the cache does: SET a key under the prefix (with a short TTL, so nothing needs deleting) and GET it back. Each command is bounded by timeout. The returned kind is “” on success, ErrKindNoPerm for a permission error, ErrKindOther for any other answer Redis will keep giving (an error reply such as READONLY or an unknown command, or a value not read back), and ErrKindTransient for a Redis that did not answer (timeout, refused connection) or answered with a retryable error (LOADING, TRYAGAIN, BUSY, CLUSTERDOWN, MASTERDOWN, and OOM or MISCONF: a full or unsaveable Redis refuses writes until memory frees or the disk recovers, which the runtime path already treats as transient).

func (*Redis) Set

func (r *Redis) Set(ctx context.Context, key string, e Entry, ttl time.Duration) error

Set encodes and stores e under key with ttl.

type RedisConfig

RedisConfig configures a Redis backend.

type RedisConfig struct {
    Client       redis.UniversalClient
    Prefix       string           // key namespace, e.g. "omnifeed:cache"
    MaxItemBytes int              // compressed size cap per entry; <= 0 = no cap
    Cooldown     time.Duration    // defaults to 30s
    Now          func() time.Time // defaults to time.Now
    // OnError, when set, is called once per Redis error with the operation
    // ("get", "set", "probe") and its kind (ErrKindNoPerm, ErrKindTransient,
    // ErrKindOther). Cooldown fast-fails, oversize entries, corrupt values
    // and callers hanging up are not Redis errors and are not reported.
    OnError func(op, kind string)
}

Generated by gomarkdoc