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).
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
)
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(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(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.
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
}
Cache decorates an engine.Dispatcher with a response cache.
type Cache struct {
// contains filtered or unexported fields
}
func New(cfg Config) *Cache
New builds a Cache.
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 (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 (c *Cache) Resolve(rawURL string) domain.Engine
Resolve delegates to the wrapped dispatcher.
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
}
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"`
}
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(cfg FailoverConfig) *Failover
NewFailover builds a Failover that starts on Redis.
func (f *Failover) Backend() string
Backend is the active backend’s name: BackendRedis or BackendMemory.
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 (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 (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.
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)
}
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(cfg MemoryConfig) *Memory
NewMemory builds an in-process LRU backend.
func (m *Memory) Bytes() int
Bytes is the approximate size of everything held.
func (m *Memory) Get(_ context.Context, key string) (Entry, bool, error)
Get returns the live entry for key, if any.
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).
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
}
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(cfg RedisConfig) *Redis
NewRedis builds a Redis backend.
func (r *Redis) Get(ctx context.Context, key string) (Entry, bool, error)
Get reads and decodes key.
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 (r *Redis) Set(ctx context.Context, key string, e Entry, ttl time.Duration) error
Set encodes and stores e under key with ttl.
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