Skip to content

RSS Feed Worker

The RSS Feed Worker generates standards-compliant podcast RSS feeds. Since Epic 17 D-1 the canonical feed URL is rss.cdn.media/{podcast_id} — the podcast UUID, the show's permanent, rename-proof key. Slug-form paths (the pre-Epic-17 scheme, and any renamed slug via the permanent alias table) answer a cacheable 301 to the ID form, resolved live-slug-first then podcast_slug_aliases; that redirect lane is monitored production surface, forever (R-6). The pre-flip host feed.podcasterplus.com never comes down either (live subscribers hold its URLs, Epic 16 D-23) and reaches the ID feed in at most two hops. Every internal key — the KV cache, the R2 snapshot, WAE analytics, queue grouping — is the podcast ID, which also closed the old freed-slug stale-cache hazard (a podcast ID is never reused). This is mission-critical infrastructure for podcast distribution to Apple Podcasts, Spotify, and other directories.

Overview

PropertyValue
URLhttps://rss.cdn.media/{podcast_id} (slug-form paths 301 here; old hosts 301 in)
Worker Namepodcasterplus-rss-feed
Source Location/workers/rss-feed/
DeploymentCloudflare Workers
DatabaseSupabase PostgreSQL via Hyperdrive
CacheCloudflare KV (no CDN caching — every request runs the worker)

Architecture

┌─────────────────────────────────────────────────────────────────────────┐
│                           Request Flow                                   │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│   Client Request                                                         │
│        │                                                                 │
│        ▼                                                                 │
│   ┌─────────────┐     ┌─────────────┐     ┌─────────────────────────┐  │
│   │   Browser   │     │   Worker    │     │      KV Cache           │  │
│   │   Cache     │────▶│  (every     │────▶│   (Global, 1hr TTL)     │  │
│   │  (60s TTL)  │     │  request)   │     │                         │  │
│   └─────────────┘     └─────────────┘     └───────────┬─────────────┘  │
│                                                        │                 │
│                                           Cache Miss   │                 │
│                                                        ▼                 │
│                                           ┌─────────────────────────┐   │
│                                           │       Hyperdrive        │   │
│                                           │   (Connection Pooling)  │   │
│                                           └───────────┬─────────────┘   │
│                                                        │                 │
│                                                        ▼                 │
│                                           ┌─────────────────────────┐   │
│                                           │   Supabase PostgreSQL   │   │
│                                           │      (Source Data)      │   │
│                                           └─────────────────────────┘   │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────┐
│                       Cache Invalidation Flow                            │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│   Main App (podcast/episode update)                                      │
│        │                                                                 │
│        ├───────────────────┐                                            │
│        ▼                   ▼                                            │
│   ┌─────────────┐    ┌─────────────┐                                   │
│   │   Queue     │    │    HTTP     │  (fallback if queue unavailable)  │
│   │  Producer   │    │  Endpoint   │                                   │
│   └──────┬──────┘    └──────┬──────┘                                   │
│          │                   │                                          │
│          ▼                   ▼                                          │
│   ┌─────────────────────────────────────┐                              │
│   │         RSS Feed Worker             │                              │
│   │   (Queue Consumer / HTTP Handler)   │                              │
│   └──────────────┬──────────────────────┘                              │
│                  │                                                      │
│                  ▼                                                      │
│   ┌─────────────────────────────────────┐                              │
│   │      KV Cache Invalidation          │                              │
│   │   (Delete feed:{slug} entries)      │                              │
│   └─────────────────────────────────────┘                              │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

Cloudflare Services

Workers

The RSS Feed Worker is deployed as a Cloudflare Worker with route binding:

toml
# workers/rss-feed/wrangler.toml
name = "podcasterplus-rss-feed"
main = "src/index.ts"
compatibility_date = "2024-12-30"
compatibility_flags = ["nodejs_compat"]

routes = [
  { pattern = "feed.podcasterplus.com/*", zone_name = "podcasterplus.com" },
  { pattern = "rss.cdn.media/*", zone_name = "cdn.media" }
]

Current Version ID: 7a0c0f47-e392-42d2-b8b8-93c6238bab0a

KV Namespace

Global key-value store for feed caching:

PropertyValue
BindingRSS_CACHE
Namespace IDc22b685ea2bb4099a58a4788b58e1007
TTL3600 seconds (1 hour)

Key Structure:

feed:{slug}           # Full XML content
feed:{slug}:etag      # ETag hash for 304 responses
feed:{slug}:meta      # Generation metadata (timestamp, version)

Hyperdrive

Connection pooling for Supabase PostgreSQL (10-100x faster cold starts):

PropertyValue
BindingHYPERDRIVE
Config IDa81d477ff9264805989f5a72f0354ee8
TargetSupabase PostgreSQL direct connection

Configuration:

toml
# workers/rss-feed/wrangler.toml
[[hyperdrive]]
binding = "HYPERDRIVE"
id = "a81d477ff9264805989f5a72f0354ee8"

Usage in Code:

typescript
// workers/rss-feed/src/db/client.ts
import postgres from 'postgres';

const sql = postgres(hyperdrive.connectionString, {
  max: 1,           // Hyperdrive handles pooling
  prepare: false    // Required for Hyperdrive compatibility
});

Queue

Asynchronous cache invalidation:

PropertyValue
Queue Namerss-invalidation
ProducerMain app (RSS_INVALIDATION_QUEUE binding)
ConsumerRSS Feed Worker
Max Batch Size10
Max Retries3

Message Schema:

typescript
type RssInvalidationMessage = {
  type: string;           // e.g., "episode.published", "podcast.updated"
  podcast_id: string;     // UUID
  podcast_slug: string;   // Used for cache key
  episode_id?: string;    // Optional, for episode-specific invalidation
  timestamp: string;      // ISO 8601
};

Windowed publishing from settings saves

The podcast settings pages save through one consolidated ?/save action per page (the explicit "Save changes" bar — the per-card autosave era is gone), so a save click issues exactly ONE feed publish. Feed-affecting saves (General's whole page including cover-art uploads via updateImage; Distribution's ownership fields, itunes/podcast flags, and embed branding/colour) call scheduleFeedSync() (src/lib/utils/rss-invalidate.ts): the first save in a window claims podcasts.feed_sync_scheduled_at with an atomic conditional UPDATE, publishes IMMEDIATELY (single edits reach the feed in seconds), and enqueues ONE trailing sweep delayed by FEED_SYNC_WINDOW_SECONDS (300 s — cheap coalescing, far below podcast apps' polling cadence); every further save inside the window rides along free. Hard cap: two invalidations per podcast per window regardless of how often the user saves, and rebuilds stay lazy (the next fetch after each invalidation publishes everything accumulated). The "Update feed now" button (Distribution page + settings header kebab) remains as an immediate accelerator (manual.invalidation) and resets the stamp. The only remaining immediate settings invalidation is the 24-hour owner-email visibility window (podcast.email_visibility.updated), which is a time-boxed switch rather than a draftable setting.

Project Structure

workers/rss-feed/
├── wrangler.toml           # Worker configuration
├── package.json            # Dependencies (postgres, hono)
├── tsconfig.json           # TypeScript configuration
└── src/
    ├── index.ts            # Entry point, request routing
    ├── types/
    │   └── env.ts          # Environment bindings, data types
    ├── db/
    │   └── client.ts       # Hyperdrive/postgres client with REST fallback
    ├── rss/
    │   ├── generator.ts    # Main XML generation orchestrator
    │   ├── channel.ts      # Channel-level RSS tags
    │   ├── item.ts         # Episode item tags
    │   ├── podcast2.ts     # Podcast 2.0 namespace elements
    │   └── utils.ts        # CDATA, escaping, date formatting
    └── cache/
        ├── kv.ts           # KV read/write operations
        └── etag.ts         # ETag generation and validation

RSS Feed Specification

Namespaces

xml
<rss version="2.0"
    xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
    xmlns:podcast="https://podcastindex.org/namespace/1.0"
    xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom">

Channel Tags (Required)

TagSource FieldNotes
<title>podcasts.titlePlain text
<description>podcasts.descriptionCDATA wrapped
<link>podcasts.website_urlWebsite URL
<language>podcasts.languageISO 639-1
<itunes:image>podcasts.cover_image_url1400x1400 minimum
<itunes:category>podcasts.categoryiTunes category
<itunes:explicit>podcasts.explicittrue/false
<itunes:author>podcasts.authorCreator name
TagSource FieldNotes
<itunes:owner> / <itunes:name>owner_nameAlways emitted when set (not privacy-sensitive)
<itunes:owner> / <itunes:email>owner_emailGated by email_visible_until — see Privacy-First Ownership
<itunes:type>podcasts.show_typeepisodic/serial
<copyright>podcasts.copyrightRights statement
<itunes:subtitle>podcasts.subtitleShort description
<image>podcasts.cover_image_urlRSS 2.0 image

Podcast 2.0 Tags

TagSource FieldNotes
<podcast:guid>podcasts.podcast_guidPermanent UUID
<podcast:locked>podcasts.is_lockedPrevent hijacking; optional owner="…" attribute gated by email_visible_until
<podcast:medium>podcasts.mediumpodcast/music/video
<podcast:funding>podcast_fundingSupport links
<podcast:txt purpose="applepodcastsverify">podcasts.apple_verify_tokenApple Podcasts Connect claim code
<podcast:txt purpose="verify">podcasts.verification_tokens[]One tag per entry, generic platform claim tokens
<podcast:txt purpose="ai-content">podcasts.ai_contenttrue/false — emitted only when explicitly set

Episode Item Tags

TagSource FieldNotes
<title>episodes.titlePlain text
<enclosure>audio_url, audio_file_size_bytes, audio_content_typeRequired
<guid>episodes.episode_guidPermanent identifier
<pubDate>episodes.published_atRFC 822 format
<description>episodes.descriptionCDATA wrapped
<itunes:duration>episodes.audio_duration_secondsHH:MM:SS format
<itunes:episode>episodes.episode_numberEpisode number
<itunes:season>episodes.season_numberSeason number
<itunes:episodeType>episodes.episode_typefull/trailer/bonus

Episode Podcast 2.0 Tags

TagSource FieldNotes
<podcast:transcript>transcript_url, transcript_type, transcript_languageTranscript file. language is the TRANSCRIPT'S own language (episode_transcripts.language, LEFT JOINed one-to-one by both feed queries); falls back to the channel language only when NULL (#297 item 5)
<podcast:chapters>chapters_urlJSON chapters
<podcast:location>location_name, location_geoRecording location
<podcast:person>episode_creditsContributors
<podcast:soundbite>episode_soundbitesAudio clips
<podcast:txt purpose="ai-content">episodes.ai_contentItem-level override; null inherits the show-level disclosure (no item tag emitted)

Privacy-First Ownership and Verification

The feed is privacy-first by default: the owner email is hidden unless the user has deliberately opened a 24-hour visibility window (24h matches the longest platform confirmation-link lifetime — Amazon's expires after 24 hours), and platform verification is preferred via <podcast:txt> tokens instead of a public email.

Implemented in migration 20260422080013_privacy_and_verification.sql, enforced by workers/rss-feed/src/rss/channel.ts and workers/rss-feed/src/rss/item.ts, and surfaced in Settings → DistributionOwnership & Verification at src/routes/(app)/p/[slug]/settings/distribution/+page.svelte (Content Disclosure remains on General settings).

New columns

TableColumnTypeDefaultMeaning
podcastsemail_visible_untiltimestamptzNULLWhen in the future, owner email is rendered in the feed
podcastsapple_verify_tokentextNULLApple Podcasts Connect claim code (e.g. "05124")
podcastsverification_tokenstext[]'{}'Free-form per-platform verification strings
podcastsai_contentbooleanNULLShow-level AI disclosure — NULL = not disclosed, no tag emitted
episodesai_contentbooleanNULLEpisode-level override — NULL = inherit show (no item tag emitted)

Email visibility gating

Owner email visibility is controlled by podcasts.email_visible_until. The isEmailVisible() helper in channel.ts treats a NULL value — and any timestamp already in the past — as hidden:

typescript
export function isEmailVisible(podcast: PodcastRow): boolean {
  if (!podcast.email_visible_until) return false;
  const expiresAt = new Date(podcast.email_visible_until).getTime();
  return Number.isFinite(expiresAt) && expiresAt > Date.now();
}

The flag controls two distinct emissions in the channel:

  1. <itunes:email> inside <itunes:owner> — omitted entirely when hidden. <itunes:name> still renders from owner_name because a display name is not privacy-sensitive; if both are absent, <itunes:owner> is not emitted at all.
  2. The optional owner="…" attribute on <podcast:locked> — omitted when hidden, so other hosts cannot read the email from the lock tag either.

Existing rows are not backfilled. Every podcast deployed before the migration has email_visible_until = NULL, so the feed worker will stop emitting <itunes:email> on deploy. Users re-open a 24-hour window from Settings → Distribution.

Window lifecycle

Opening and closing the window is a form action on the Distribution settings page — see setEmailVisibility in +page.server.ts:

  1. setEmailVisibility(show=true) sets email_visible_until = now() + 24h and fires an immediate RSS invalidation. No at-expiry rebuild is scheduled: Cloudflare Queues caps message delays at 12 hours (the invalidation API rejects delay_seconds > 43200), so a 24h-out invalidation is unschedulable. Expiry is covered by the worker's 1-hour KV TTL — the last cached "visible" feed retires itself within ~an hour of the window closing and isEmailVisible() hides the address on the rebuild.
  2. setEmailVisibility(show=false) ("Hide now") nulls the column and fires an immediate invalidation.
  3. Opening a window also requires a non-empty owner_email; the UI disables the button otherwise.

Platform verification via <podcast:txt>

<podcast:txt> tags replace the need to publish a contact email for platform claim flows. Spec: https://podcasting2.org/docs/podcast-namespace/tags/txt.

PurposeSourceEmission
applepodcastsverifypodcasts.apple_verify_tokenSingle tag when non-null
verifypodcasts.verification_tokensOne tag per non-empty element, in stored array order
ai-content (channel)podcasts.ai_contentSingle tag when non-null (true/false)
ai-content (item)episodes.ai_contentItem-level override; null inherits show and emits none

All token values go through escapeXml() so XML-unsafe characters (e.g. <, &) cannot break the feed — exercised by the privacy/disclosure test suites in workers/rss-feed/src/rss/__tests__/channel.privacy.test.ts and item.disclosure.test.ts.

Visibility flow

Cache invalidation triggers

These new settings feed into the existing rss-invalidation queue flow — see Cache Invalidation below. The settings page triggers invalidation on:

Form actionReason code
updateOwnershippodcast.ownership.updated
setEmailVisibility (on)podcast.email_visibility.updated + delayed podcast.email_visibility.expired
setEmailVisibility (off)podcast.email_visibility.updated
updateContentDisclosurepodcast.disclosure.updated

Podcast Lifecycle Handling

The RSS Feed Worker is lifecycle-aware and handles each podcast status differently:

Active & Paused Podcasts

Both active and paused podcasts serve their RSS feed normally. Paused podcasts continue serving feeds so that subscribers and directories are unaffected — the pause only freezes app features.

Pending Deletion Podcasts

Podcasts with status = 'pending_deletion' follow a tiered response strategy:

ConditionResponse
Past deletion_scheduled_at404 (hard delete imminent)
Has deletion_redirect_url, days 1-7200 with feed + <itunes:new-feed-url> tag
Has deletion_redirect_url, days 8+301 redirect to new URL
No redirect URL, within window200 with feed (grace period)

The 7-day threshold allows podcast apps time to parse the <itunes:new-feed-url> tag before switching to a hard 301 redirect that saves Worker compute.

typescript
// workers/rss-feed/src/index.ts
if (feedData.podcast.status === 'pending_deletion') {
  // Past scheduled date → 404
  if (isPastScheduledDate) return 404;

  if (redirectUrl) {
    if (daysSinceDeletion > 7) return 301; // Hard redirect
    feedData.podcast.redirect_url = redirectUrl; // Include tag in feed
  }
  // No redirect URL → serve normally (grace period)
}

See Podcast Lifecycle Guide for the full deletion flow.

API Endpoints

GET /

Generate RSS feed for a podcast.

Response Codes:

CodeCondition
200Feed generated successfully
301Podcast has redirect_url set, or pending_deletion with redirect URL after 7 days
304ETag matches (Not Modified)
400Invalid slug format
404Podcast not found, inactive, or past deletion_scheduled_at
500Database or generation error

Response Headers:

Content-Type: application/rss+xml; charset=utf-8
Cache-Control: public, max-age=60, s-maxage=180
ETag: "3435b1c995479ff49be1002b1932540c"
X-Cache: HIT|MISS

POST /_internal/invalidate

Invalidate cached feed (authenticated).

Headers:

Authorization: Bearer {RSS_INVALIDATION_SECRET}
Content-Type: application/json

Body:

json
{
  "slug": "podcast-slug",
  "reason": "episode.published"
}

Response:

json
{
  "success": true,
  "invalidated": ["feed:podcast-slug", "feed:podcast-slug:etag"]
}

Database Queries

The Worker executes optimized parallel queries via Hyperdrive:

sql
-- 1. Podcast metadata (lifecycle-aware)
SELECT id, title, slug, description, subtitle, author, language,
       cover_image_url, category, subcategory, explicit, website_url,
       owner_name, owner_email, email_visible_until,
       apple_verify_token, verification_tokens, ai_content,
       copyright, podcast_guid, show_type,
       medium, is_locked, is_blocked, is_complete, redirect_url,
       is_active, status, overage_suspended_at,
       deletion_redirect_url, deletion_requested_at,
       deletion_scheduled_at, updated_at
FROM podcasts
WHERE slug = $1
  AND hosting_type = 'podcasterplus'
  AND (
    (status IN ('active', 'paused') AND is_active = true AND overage_suspended_at IS NULL)
    OR status = 'pending_deletion'
  )
LIMIT 1;

-- 2. Published episodes (parallel; episode_transcripts is one-to-one, its
--    language rides along for <podcast:transcript>'s language attribute)
SELECT e.id, e.title, e.slug, e.description, e.show_notes, e.subtitle,
       e.audio_url, e.audio_duration_seconds, e.audio_file_size_bytes,
       e.audio_content_type, e.cover_image_url, e.episode_number, e.season_number,
       e.episode_type, e.published_at, e.is_explicit, e.is_blocked, e.episode_guid,
       e.transcript_url, e.transcript_type, et.language AS transcript_language,
       e.chapters_url, e.location_name, e.location_geo,
       e.ai_content
FROM episodes e
LEFT JOIN episode_transcripts et ON et.episode_id = e.id
WHERE e.podcast_id = $1 AND e.status = 'published' AND e.is_blocked != true
ORDER BY e.published_at DESC
LIMIT 1000;

-- 3. Funding links (parallel)
SELECT url, title, display_order
FROM podcast_funding
WHERE podcast_id = $1
ORDER BY display_order ASC;

-- 4. Episode credits (parallel)
SELECT ec.episode_id, ec.name, ec.role_label, ec.avatar_url,
       ec.external_url, ec.display_order
FROM episode_credits ec
INNER JOIN episodes e ON ec.episode_id = e.id
WHERE e.podcast_id = $1 AND e.status = 'published'
ORDER BY ec.display_order ASC;

-- 5. Episode soundbites (parallel)
SELECT es.episode_id, es.start_time, es.duration, es.title, es.display_order
FROM episode_soundbites es
INNER JOIN episodes e ON es.episode_id = e.id
WHERE e.podcast_id = $1 AND e.status = 'published'
ORDER BY es.display_order ASC;

Visibility contract: the suspension gate

overage_suspended_at IS NULL in the podcast predicate is the hosted-content suspension gate (downgrade-overages §7.4, docs/planning/plans/2026-07-12-downgrade-overages.md). podcasts.overage_suspended_at is a platform-owned stamp written only by the overage escalation (lifecycle-manager sweep / webhook reconciler — a user-JWT write raises 42501); while it is set, the feed is a clean null → 404, exactly like is_active = false.

  • Both query paths gate. The Hyperdrive query above filters in SQL (workers/rss-feed/src/db/client.ts:79-84); the REST fallback selects the column and applies the same check in code after the fetch (:193-215). Adding a visibility rule to one path only is a bug.
  • pending_deletion bypasses the gate (both the is_active toggle and the suspension stamp) — the deletion flow owns that feed's tiered 410/301 behaviour and its podcasts are never selected for suspension.
  • A suspension-gated null is a clean 404, not a backend failure: it does not engage the KV stale:/R2-snapshot DR fallback (that lives in the catch path), and the invalidation consumer's refreshFeedSnapshot deletes the DR snapshot for a no-longer-servable podcast.
  • The same contract is mirrored by the public-api worker, both listen pages, and the two podcast_listen_link guards — see Public API Worker and Automation Executor. Keep them in lockstep.

Caching Strategy

Multi-Tier Cache

TierLocationTTLPurpose
BrowserClient60sReduce repeat requests
KVGlobal3600sPersistent feed storage (per-colo read cache adds ~60s visibility lag)

Cloudflare's CDN does not cache feed responses — no cache rule engages on this host, so s-maxage is inert and every request runs the worker. Freshness is governed by KV: the invalidation marker's TTL matches the feed entry TTL (3600 s) so a colo that sees the marker late can never be left with a stale entry it will never re-check.

Cache Headers

typescript
const headers = {
  'Content-Type': 'application/rss+xml; charset=utf-8',
  'Cache-Control': 'public, max-age=60, s-maxage=180',
  'ETag': etag,
  'X-Cache': cacheHit ? 'HIT' : 'MISS'
};

ETag Support

typescript
// Check for conditional request
const ifNoneMatch = request.headers.get('If-None-Match');
if (ifNoneMatch === cachedEtag) {
  return new Response(null, {
    status: 304,
    headers: { 'ETag': cachedEtag }
  });
}

Cache Invalidation

Via Queue (Preferred)

typescript
// Main app: src/api/routes/rss.ts
await c.env.RSS_INVALIDATION_QUEUE.send({
  type: 'episode.published',
  podcast_id: podcast.id,
  podcast_slug: podcast.slug,
  episode_id: episode.id,
  timestamp: new Date().toISOString()
});

Via HTTP (Fallback)

typescript
// If queue unavailable
await fetch('https://feed.podcasterplus.com/_internal/invalidate', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${RSS_INVALIDATION_SECRET}`
  },
  body: JSON.stringify({ slug, reason })
});

Invalidation Triggers

EventTrigger LocationInvalidation Type
Podcast settings updatedSettings pageFull feed
Episode publishedEpisode editorFull feed
Episode updatedEpisode editorFull feed
Episode deletedEpisode listFull feed
Credits changedEpisode editorFull feed
Episode people credit added/removedEpisode People APIFull feed
Episode people credits reorderedEpisode People APIFull feed
Funding changedSettings pageFull feed

Environment Variables

Worker Secrets

Set via wrangler secret put:

SecretDescription
SUPABASE_SECRET_KEYDatabase access (service role or secret key)
RSS_INVALIDATION_SECRETHTTP invalidation authentication

Worker Variables

Set in wrangler.toml:

VariableValue
PUBLIC_MEDIA_URLhttps://media.podcasterplus.com
PUBLIC_FEED_URLhttps://feed.podcasterplus.com
PUBLIC_SUPABASE_URLhttps://cmhnfgvbfrkgayrrgrmf.supabase.co

Deployment

Deploy Worker

bash
cd workers/rss-feed
npx wrangler deploy

Set Secrets

bash
cd workers/rss-feed

# Database access
npx wrangler secret put SUPABASE_SECRET_KEY

# HTTP invalidation auth
npx wrangler secret put RSS_INVALIDATION_SECRET

View Logs

bash
# Real-time logs
npx wrangler tail

# Or via dashboard
# https://dash.cloudflare.com > Workers > podcasterplus-rss-feed > Logs

Performance

Measured Results

MetricValueTarget
Uncached response~250ms< 500ms
KV cached response~160ms< 100ms
ETag 304 response~135ms< 100ms
Feed size (empty)~2KBN/A
Feed size (100 episodes)~150KB< 500KB

Optimization Techniques

  1. Hyperdrive: Connection pooling eliminates cold start connection overhead
  2. Parallel queries: All related data fetched concurrently
  3. KV caching: Avoids database queries for cached feeds
  4. No CDN caching: every request runs the worker; KV keeps it fast and invalidation honest
  5. ETag/304: Reduces bandwidth for unchanged feeds
  6. Streaming: XML generated incrementally (future enhancement)

Troubleshooting

Feed Returns 404

  1. Check podcast exists: SELECT slug, status, is_active, overage_suspended_at, hosting_type, deletion_scheduled_at FROM podcasts WHERE slug = 'xxx';
  2. Verify hosting_type = 'podcasterplus' (external podcasts have no feed)
  3. Verify status is active, paused, or pending_deletion (not yet past deletion_scheduled_at)
  4. For active/paused podcasts: verify is_active = true AND overage_suspended_at IS NULL (a set stamp = hosted-content suspension; restored by upgrade/override or getting under the caps, never by the Active switch)
  5. Check Worker logs for errors

Feed Not Updating

  1. Verify invalidation is triggered (check main app logs)
  2. Check Queue is processing (Cloudflare Dashboard > Queues)
  3. Manually invalidate: POST /_internal/invalidate
  4. Check KV entries: Cloudflare Dashboard > KV > RSS_CACHE

Hyperdrive Fallback to REST

If logs show "Hyperdrive not configured, falling back to REST API":

  1. Verify Hyperdrive config exists: npx wrangler hyperdrive list
  2. Check wrangler.toml has correct ID
  3. Verify Supabase connection string is valid

Slow Response Times

  1. Check if hitting cache (X-Cache: HIT header)
  2. Verify Hyperdrive is connected (not REST fallback)
  3. Check episode count (>500 episodes may need pagination)
  4. Review Cloudflare Analytics for patterns

Internal documentation - Not for public distribution